Compare commits

...

90 Commits

Author SHA1 Message Date
Rosalina 91a0e0cc46
Merge remote-tracking branch 'upstream/main' into splashcat-exporter-v2 2025-09-07 20:32:08 -04:00
Jingrong Chen c2fde4bdfd
fix: update outdated GraphQL query hashes for SplatNet 3 API (#104) 2025-07-18 17:27:04 +08:00
cypas 6ca0bb06d4 feat:update NSOAPP_VERSION 2024-07-08 13:13:18 +08:00
spacemeowx2 563cd3d92b ci: fix pnpm/action-setup 2024-07-08 13:12:58 +08:00
spacemeowx2 6a21775c90 chore: 0.4.20 2024-06-18 20:29:10 +08:00
spacemeowx2 58dbafaa22 fix: wrong NSOAPP_VERSION 2024-06-18 20:28:37 +08:00
spacemeowx2 2ee9321612 feat: 0.4.19 2024-06-17 14:55:18 +08:00
imspace 9ad3fc3c50 feat: 0.4.18 2024-06-01 22:49:47 +08:00
Rosalina 0574a12f96
Merge remote-tracking branch 'upstream/main' into splashcat-exporter-v2 2024-05-14 01:38:39 -04:00
Rosalina cd3088eb16
merge? 2024-05-14 01:37:47 -04:00
imspace 96a2607da5 feat: update `WEB_VIEW_VERSION` and `NSOAPP_VERSION` 2024-05-10 19:06:26 +08:00
spacemeowx2 7b18e23988 style: fix lint 2024-02-29 18:29:21 +08:00
spacemeowx2 cab8af8e31 chore: bump version 2024-02-29 18:21:34 +08:00
spacemeowx2 14405e996f feat: add Fresh Season 2024 2024-02-29 18:20:32 +08:00
spacemeowx2 a75b200d7d fix: `The stream is already locked` in monitor mode 2024-02-29 18:19:52 +08:00
spacemeowx2 52f42bc42f chore: bump version and update CHANGELOG 2024-02-27 15:10:03 +08:00
spacemeowx2 75ba29e5c7 feat: add znca headers (#95) 2024-02-27 15:07:58 +08:00
cypas 60562fedcd deno Format 2024-02-27 13:24:51 +08:00
cypas 610bcdf424 update nso-version 2024-02-27 13:24:51 +08:00
cypas 6d5f4b66a5 update User-Agent 2024-02-27 13:24:51 +08:00
spacemeowx2 d7bd6309eb fix: NSOAPP_VERSION 2024-02-24 14:22:09 +08:00
spacemeowx2 c77519c229 chore: update `WEB_VIEW_VERSION` and queries
fix: readLines may read corrupted data
2024-02-23 17:11:52 +08:00
spacemeowx2 5bc3003f6d ci: fix gui build 2024-01-31 19:37:20 +08:00
spacemeowx2 109658680a chore: bump version 2024-01-31 19:10:56 +08:00
spacemeowx2 aa039c7be6 chore: update deps 2024-01-31 19:08:24 +08:00
spacemeowx2 9d222cbac2 fix: deno test 2024-01-31 19:08:24 +08:00
spacemeowx2 854c02f8cb fix: scripts 2024-01-31 19:08:24 +08:00
spacemeowx2 bd569c1c80 refactor: use swr 2024-01-31 19:08:24 +08:00
spacemeowx2 32079a50e7 refactor: upgrade deps 2024-01-31 19:08:24 +08:00
spacemeowx2 f69aaef6d7 fix: use webstream 2024-01-31 19:08:24 +08:00
spacemeowx2 a5fde51eeb refactor: update std version 2024-01-31 19:08:24 +08:00
spacemeowx2 5f04eb68a2 fix: readline throws EOF (fixes #91) 2024-01-22 15:13:11 +08:00
Rosalina 2c72bb7eaf
Send a user agent with nxapi presence requests (#89)
* event stream implementation

(committing this right now because event streams seem broke)

* switch to polling every monitor interval

* simplify cli arguments to just nxapi-presence and add to readme

* run deno fmt

* use s3si.ts fetcher and send a user agent
2024-01-18 01:43:35 +08:00
Rosalina 16c83c34e3
Splashcat exporter (#88)
* add Splashcat exporter

* update readme and cli help

* use splashcat exporter when set using cli flags

* run deno fmt

* use s3si.ts fetcher and send a user agent
2024-01-18 01:42:52 +08:00
Rosalina 55bdb2d284
Merge remote-tracking branch 'upstream/main' into splashcat-exporter-v2 2024-01-16 08:47:05 -05:00
Rosalina 715a28f198
use s3si.ts fetcher and send a user agent 2024-01-16 08:45:39 -05:00
spacemeowx2 02a01188c6 ci: update oldest deno version 2024-01-16 16:05:39 +08:00
spacemeowx2 54a7ff55fa ci: skip fmt and lint for old version 2024-01-16 16:04:28 +08:00
spacemeowx2 ed5d286ac7 fix: lint 2024-01-16 15:54:36 +08:00
spacemeowx2 f33c03c691 ci: remove constant-check 2024-01-16 15:51:26 +08:00
Rosalina 99eb9e71c8
run deno fmt 2024-01-14 03:19:20 -05:00
Rosalina 7d6b71d752
Merge remote-tracking branch 'upstream/main' into splashcat-exporter-v2 2024-01-14 03:18:07 -05:00
Rosalina 94fb731fe0
use splashcat exporter when set using cli flags 2024-01-14 02:48:38 -05:00
Rosalina cb0df39102
update readme and cli help 2024-01-14 02:48:07 -05:00
Rosalina 425fa1ef73
add Splashcat exporter 2024-01-14 02:41:26 -05:00
Rosalina 055b1405df run deno fmt 2024-01-14 15:10:33 +08:00
Rosalina b8e53fc719 simplify cli arguments to just nxapi-presence and add to readme 2024-01-14 15:10:33 +08:00
Rosalina cbe7a5424a switch to polling every monitor interval 2024-01-14 15:10:33 +08:00
Rosalina 21b02fb44d event stream implementation
(committing this right now because event streams seem broke)
2024-01-14 15:10:33 +08:00
imspace cc348653d6 chore: update `WEB_VIEW_VERSION` 2023-12-15 21:47:02 +08:00
spacemeowx2 4a0cda32ab feat: update `NSOAPP_VERSION` 2023-12-07 14:47:08 +08: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
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
spacemeowx2 addb535d96 chore: update `NSOAPP_VERSION` 2023-11-09 15:57:05 +08: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
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
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
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
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
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
imspace 1bc0d3eefc fix: list method is not auto 2023-06-15 19:22:00 +08:00
spacemeowx2 a67bb4814d chore: update `NSOAPP_VERSION` 2023-06-13 21:47:39 +08: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
60 changed files with 3416 additions and 4147 deletions

View File

@ -7,7 +7,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
deno: [1.x, "1.31.x", canary]
deno: [1.x, "1.37.x", canary]
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
@ -15,8 +15,10 @@ jobs:
deno-version: ${{ matrix.deno }}
- name: Check fmt
run: deno fmt --check
if: ${{ matrix.deno != '1.31.x' }}
- name: Run lint
run: deno lint
if: ${{ matrix.deno != '1.31.x' }}
- name: All entries
uses: tj-actions/glob@v16
id: entries

View File

@ -1,22 +0,0 @@
name: Constant Check
on:
pull_request:
branches:
- main
push:
branches:
- main
schedule:
- cron: "0 0 * * *"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: denoland/setup-deno@v1
with:
deno-version: 1.x
- name: Check constant updates
run: deno run -A ./scripts/update-constant.ts
- name: Check if workspace is clean
run: git diff --exit-code

View File

@ -27,9 +27,9 @@ jobs:
with:
deno-version: 1.x
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
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,92 @@
## 0.4.20
fix: update `NSOAPP_VERSION`
## 0.4.19
feat: update `NSOAPP_VERSION`
## 0.4.18
feat: update `WEB_VIEW_VERSION` and `NSOAPP_VERSION`, update VersionData
## 0.4.17
feat: update `WEB_VIEW_VERSION` and `NSOAPP_VERSION`
## 0.4.16
fix: `The stream is already locked` in monitor mode
feat: add Fresh Season 2024
## 0.4.15
feat: add znca headers ([#95](https://github.com/spacemeowx2/s3si.ts/issues/95))
feat: update User-Agent ([#94](https://github.com/spacemeowx2/s3si.ts/pull/94))
## 0.4.14
chore: update `WEB_VIEW_VERSION` and queries
fix: readLines may read corrupted data
## 0.4.13
refactor: upgraded the version of dependencies and fixed the deprecated API
([#92](https://github.com/spacemeowx2/s3si.ts/issues/92))
## 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).
@ -19,13 +19,18 @@ 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")
(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
--nxapi-presence Extends monitoring mode to use Nintendo Switch presence from nxapi
```
3. If it's your first time running this, follow the instructions to login to
@ -34,6 +39,12 @@ Options:
- If you want to use a different profile, use `-p` to specify the path to the
profile file.
### Splashcat Notes
Due to limitations with SplatNet 3 data, Splashcat requires battles uploaded to
use `en-US` (set with `userLang`). Splashcat will localize most parts of battle
results into the user's language when displayed.
### Track your rank
- Run
@ -67,7 +78,8 @@ Options:
// userLang will effect the language of the exported games to stat.ink
"userLang": "zh-CN",
"userCountry": "JP",
"statInkApiKey": "..."
"statInkApiKey": "...",
"splashcatApiKey": "..."
}
```

217
deno.lock
View File

@ -1,46 +1,167 @@
{
"version": "2",
"version": "3",
"remote": {
"https://deno.land/std@0.141.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.141.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d",
"https://deno.land/std@0.141.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9",
"https://deno.land/std@0.141.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.141.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37",
"https://deno.land/std@0.141.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
"https://deno.land/std@0.141.0/io/types.d.ts": "01f60ae7ec02675b5dbed150d258fc184a78dfe5c209ef53ba4422b46b58822c",
"https://deno.land/std@0.141.0/streams/conversion.ts": "8268f3f1a43324953dd8e9e4e31adb42e3caddb4502433bde03c279e43d70a3b",
"https://deno.land/std@0.160.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.160.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
"https://deno.land/std@0.160.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.160.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"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/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",
"https://deno.land/std@0.160.0/io/types.d.ts": "107e1e64834c5ba917c783f446b407d33432c5d612c4b3430df64fc2b4ecf091",
"https://deno.land/std@0.160.0/io/util.ts": "23e706b4b6a3ebb34af00ad74d7549d906f3211fc98c1fba1185a36e017fb727",
"https://deno.land/std@0.160.0/io/writers.ts": "2e1c63ffd0cfba411b1fd8374609abff9ea86187c9d4d885d42e6fc20325ef0e",
"https://deno.land/std@0.160.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
"https://deno.land/std@0.160.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
"https://deno.land/std@0.160.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
"https://deno.land/std@0.160.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
"https://deno.land/std@0.160.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
"https://deno.land/std@0.160.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
"https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
"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/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",
"https://deno.land/std@0.160.0/uuid/v4.ts": "3e983c6ac895ea2a7ba03da927a2438fe1c26ac43fb38dc44f2f8aa50c23cb53",
"https://deno.land/std@0.160.0/uuid/v5.ts": "43973aeda44ad212f2ec9b8d6c042b74d5cef4ce583d6aa6fc4cdb339344c74c",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie.ts": "72d6a6633ea13dd2f13b53d9726735b194996353a958024072c4d6b077c97baf",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie_jar.ts": "9accd36e76929f2f06fa710d2165fb544703617245fa36ac63560b9fa2a22a25",
"https://deno.land/x/another_cookiejar@v4.1.4/fetch_wrapper.ts": "d8918c0776413b2d4a675415727973390b4401a026f6dfdcffedce3296b5e0dc",
"https://deno.land/x/another_cookiejar@v4.1.4/mod.ts": "eff949014965771f2cd447fe78625a1ad28b59333afa40640f02c0922534d89a",
"https://deno.land/std@0.210.0/fmt/colors.ts": "2685c524bef9b16b3059a417daf6860c754eb755e19e812762ef5dff62f24481",
"https://deno.land/std@0.213.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.213.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840",
"https://deno.land/std@0.213.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4",
"https://deno.land/std@0.213.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5",
"https://deno.land/std@0.213.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f",
"https://deno.land/std@0.213.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1",
"https://deno.land/std@0.213.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e",
"https://deno.land/std@0.213.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9",
"https://deno.land/std@0.213.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769",
"https://deno.land/std@0.213.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c",
"https://deno.land/std@0.213.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219",
"https://deno.land/std@0.213.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444",
"https://deno.land/std@0.213.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2",
"https://deno.land/std@0.213.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005",
"https://deno.land/std@0.213.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0",
"https://deno.land/std@0.213.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1",
"https://deno.land/std@0.213.0/assert/assert_not_equals.ts": "f3edda73043bc2c9fae6cbfaa957d5c69bbe76f5291a5b0466ed132c8789df4c",
"https://deno.land/std@0.213.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931",
"https://deno.land/std@0.213.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f",
"https://deno.land/std@0.213.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be",
"https://deno.land/std@0.213.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49",
"https://deno.land/std@0.213.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54",
"https://deno.land/std@0.213.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366",
"https://deno.land/std@0.213.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7",
"https://deno.land/std@0.213.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7",
"https://deno.land/std@0.213.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8",
"https://deno.land/std@0.213.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2",
"https://deno.land/std@0.213.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c",
"https://deno.land/std@0.213.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b",
"https://deno.land/std@0.213.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd",
"https://deno.land/std@0.213.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145",
"https://deno.land/std@0.213.0/bytes/concat.ts": "9cac3b4376afbef98ff03588eb3cf948e0d1eb6c27cfe81a7651ab6dd3adc54a",
"https://deno.land/std@0.213.0/bytes/copy.ts": "f29c03168853720dfe82eaa57793d0b9e3543ebfe5306684182f0f1e3bfd422a",
"https://deno.land/std@0.213.0/crypto/_fnv/fnv32.ts": "ba2c5ef976b9f047d7ce2d33dfe18671afc75154bcf20ef89d932b2fe8820535",
"https://deno.land/std@0.213.0/crypto/_fnv/fnv64.ts": "580cadfe2ff333fe253d15df450f927c8ac7e408b704547be26aab41b5772558",
"https://deno.land/std@0.213.0/crypto/_fnv/mod.ts": "8dbb60f062a6e77b82f7a62ac11fabfba52c3cd408c21916b130d8f57a880f96",
"https://deno.land/std@0.213.0/crypto/_fnv/util.ts": "27b36ce3440d0a180af6bf1cfc2c326f68823288540a354dc1d636b781b9b75f",
"https://deno.land/std@0.213.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "76c727912539737def4549bb62a96897f37eb334b979f49c57b8af7a1617635e",
"https://deno.land/std@0.213.0/crypto/_wasm/mod.ts": "c55f91473846827f077dfd7e5fc6e2726dee5003b6a5747610707cdc638a22ba",
"https://deno.land/std@0.213.0/crypto/crypto.ts": "633e3ac52c496c52b1b6815dc6565db9af93a316665d2719bf7457f7342f372c",
"https://deno.land/std@0.213.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
"https://deno.land/std@0.213.0/encoding/base64.ts": "0ec6d6e6b68fc38f6396277e5184bcd47c1a9db0222fd0b563487eb67e352741",
"https://deno.land/std@0.213.0/flags/mod.ts": "58da4edceb20cbcb30fba78583e64525177aff1d80fb6b7f8bea85ecfd21463b",
"https://deno.land/std@0.213.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb",
"https://deno.land/std@0.213.0/io/_common.ts": "36705cdb4dfcd338d6131bca1b16e48a4d5bf0d1dada6ce397268e88c17a5835",
"https://deno.land/std@0.213.0/io/_constants.ts": "3c7ad4695832e6e4a32e35f218c70376b62bc78621ef069a4a0a3d55739f8856",
"https://deno.land/std@0.213.0/io/buf_reader.ts": "ccbd43ace0a9eebbd5e1b4765724b79da79d1e28b90c2b08537b99192da4a1f7",
"https://deno.land/std@0.213.0/io/buf_writer.ts": "bf68b9c74b1bccf51b9960c54db5eec60e7e3d922c7c62781b0d3971770021ba",
"https://deno.land/std@0.213.0/io/buffer.ts": "79182995c8340ece2fa8763a8da86d282c507e854921d0a4c2ba7425c63609ef",
"https://deno.land/std@0.213.0/io/copy.ts": "63c6a4acf71fb1e89f5e47a7b3b2972f9d2c56dd645560975ead72db7eb23f61",
"https://deno.land/std@0.213.0/io/copy_n.ts": "e4a169b8965b69e6a05175d06bf14565caa91266143ec895e54e95b6cdb27cf2",
"https://deno.land/std@0.213.0/io/limited_reader.ts": "2b3e6c2d134bbbabbc918584db5fd2f8b21091843357f75af0d9f262cb5c94c1",
"https://deno.land/std@0.213.0/io/mod.ts": "571384032c5f60530542a28f2e8b0e73e47e87eca77056ba7e2363f4d4a4573a",
"https://deno.land/std@0.213.0/io/multi_reader.ts": "ca8a7813208a3393dfaed75894d955fe58a38c21b880e69839a4e0547eadbf61",
"https://deno.land/std@0.213.0/io/read_all.ts": "876c1cb20adea15349c72afc86cecd3573335845ae778967aefb5e55fe5a8a4a",
"https://deno.land/std@0.213.0/io/read_delim.ts": "fb0884d97adc398877c6f59e1d1450be12e078790f52845fae7876dc119bb8f6",
"https://deno.land/std@0.213.0/io/read_int.ts": "6ada4e0eec5044982df530e4de804e32ae757a2c318b57eba622d893841ffe2a",
"https://deno.land/std@0.213.0/io/read_lines.ts": "34555eaa25269f6cfb9a842a03daedc9eae4f8295c8f933bd2b1639274ce89e3",
"https://deno.land/std@0.213.0/io/read_long.ts": "199cba44526464f8499e1f3d96008d513bcadc8e5665356a9b84425cac6b16ad",
"https://deno.land/std@0.213.0/io/read_range.ts": "a0c930ea61fdc3ea5520be4df34a7927fe8a2d6da9b04bfaa7b9588ef2e1a718",
"https://deno.land/std@0.213.0/io/read_short.ts": "73777709ad41b6faeff3638c275a329cc820c1082f4dad07909f48875a35a71d",
"https://deno.land/std@0.213.0/io/read_string_delim.ts": "8c604ceea5c3c7ab244583570b467ce194238ace6d49b1d47f25d4f75de86d59",
"https://deno.land/std@0.213.0/io/slice_long_to_bytes.ts": "9769174a8f3b4449f1e1af1a79f78e58ef84d0aaf2f457e1fdc31a01f92439b7",
"https://deno.land/std@0.213.0/io/string_reader.ts": "b0176211e61e235a684abef722e7ecc7a6481238ba264f1a7b199b8a1d2a62f5",
"https://deno.land/std@0.213.0/io/string_writer.ts": "4fe4dcbdadff11c726bf79b0239e14fa9b1e8468a795b465622e4dbd6c1f819c",
"https://deno.land/std@0.213.0/io/to_readable_stream.ts": "ed03a44a1ec1cc55a85a857acf6cac472035298f6f3b6207ea209f93b4aefb39",
"https://deno.land/std@0.213.0/io/to_writable_stream.ts": "ef422e0425963c8a1e0481674e66c3023da50f0acbe5ef51ec9789efc3c1e2ed",
"https://deno.land/std@0.213.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96",
"https://deno.land/std@0.213.0/io/write_all.ts": "24aac2312bb21096ae3ae0b102b22c26164d3249dff96dbac130958aa736f038",
"https://deno.land/std@0.213.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297",
"https://deno.land/std@0.213.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2",
"https://deno.land/std@0.213.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031",
"https://deno.land/std@0.213.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
"https://deno.land/std@0.213.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
"https://deno.land/std@0.213.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b",
"https://deno.land/std@0.213.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf",
"https://deno.land/std@0.213.0/path/_common/glob_to_reg_exp.ts": "2007aa87bed6eb2c8ae8381adcc3125027543d9ec347713c1ad2c68427330770",
"https://deno.land/std@0.213.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
"https://deno.land/std@0.213.0/path/_common/normalize_string.ts": "dfdf657a1b1a7db7999f7c575ee7e6b0551d9c20f19486c6c3f5ff428384c965",
"https://deno.land/std@0.213.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607",
"https://deno.land/std@0.213.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a",
"https://deno.land/std@0.213.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883",
"https://deno.land/std@0.213.0/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600",
"https://deno.land/std@0.213.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
"https://deno.land/std@0.213.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668",
"https://deno.land/std@0.213.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643",
"https://deno.land/std@0.213.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36",
"https://deno.land/std@0.213.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c",
"https://deno.land/std@0.213.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441",
"https://deno.land/std@0.213.0/path/format.ts": "98fad25f1af7b96a48efb5b67378fcc8ed77be895df8b9c733b86411632162af",
"https://deno.land/std@0.213.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069",
"https://deno.land/std@0.213.0/path/glob_to_regexp.ts": "83c5fd36a8c86f5e72df9d0f45317f9546afa2ce39acaafe079d43a865aced08",
"https://deno.land/std@0.213.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7",
"https://deno.land/std@0.213.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141",
"https://deno.land/std@0.213.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
"https://deno.land/std@0.213.0/path/join_globs.ts": "e9589869a33dc3982101898ee50903db918ca00ad2614dbe3934d597d7b1fbea",
"https://deno.land/std@0.213.0/path/mod.ts": "ffeaccb713dbe6c72e015b7c767f753f8ec5fbc3b621ff5eeee486ffc2c0ddda",
"https://deno.land/std@0.213.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352",
"https://deno.land/std@0.213.0/path/normalize_glob.ts": "98ee8268fad271193603271c203ae973280b5abfbdd2cbca1053fd2af71869ca",
"https://deno.land/std@0.213.0/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb",
"https://deno.land/std@0.213.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
"https://deno.land/std@0.213.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843",
"https://deno.land/std@0.213.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4",
"https://deno.land/std@0.213.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1",
"https://deno.land/std@0.213.0/path/posix/dirname.ts": "6535d2bdd566118963537b9dda8867ba9e2a361015540dc91f5afbb65c0cce8b",
"https://deno.land/std@0.213.0/path/posix/extname.ts": "8d36ae0082063c5e1191639699e6f77d3acf501600a3d87b74943f0ae5327427",
"https://deno.land/std@0.213.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1",
"https://deno.land/std@0.213.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40",
"https://deno.land/std@0.213.0/path/posix/glob_to_regexp.ts": "54d3ff40f309e3732ab6e5b19d7111d2d415248bcd35b67a99defcbc1972e697",
"https://deno.land/std@0.213.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede",
"https://deno.land/std@0.213.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9",
"https://deno.land/std@0.213.0/path/posix/join.ts": "aef88d5fa3650f7516730865dbb951594d1a955b785e2450dbee93b8e32694f3",
"https://deno.land/std@0.213.0/path/posix/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9",
"https://deno.land/std@0.213.0/path/posix/mod.ts": "563a18c2b3ddc62f3e4a324ff0f583e819b8602a72ad880cb98c9e2e34f8db5b",
"https://deno.land/std@0.213.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
"https://deno.land/std@0.213.0/path/posix/normalize_glob.ts": "65f0138fa518ef9ece354f32889783fc38cdf985fb02dcf1c3b14fa47d665640",
"https://deno.land/std@0.213.0/path/posix/parse.ts": "d5bac4eb21262ab168eead7e2196cb862940c84cee572eafedd12a0d34adc8fb",
"https://deno.land/std@0.213.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c",
"https://deno.land/std@0.213.0/path/posix/resolve.ts": "bac20d9921beebbbb2b73706683b518b1d0c1b1da514140cee409e90d6b2913a",
"https://deno.land/std@0.213.0/path/posix/separator.ts": "c9ecae5c843170118156ac5d12dc53e9caf6a1a4c96fc8b1a0ab02dff5c847b0",
"https://deno.land/std@0.213.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf",
"https://deno.land/std@0.213.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0",
"https://deno.land/std@0.213.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add",
"https://deno.land/std@0.213.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d",
"https://deno.land/std@0.213.0/path/separator.ts": "c6c890507f944a1f5cb7d53b8d638d6ce3cf0f34609c8d84a10c1eaa400b77a9",
"https://deno.land/std@0.213.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b",
"https://deno.land/std@0.213.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40",
"https://deno.land/std@0.213.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
"https://deno.land/std@0.213.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe",
"https://deno.land/std@0.213.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4",
"https://deno.land/std@0.213.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5",
"https://deno.land/std@0.213.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9",
"https://deno.land/std@0.213.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef",
"https://deno.land/std@0.213.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6",
"https://deno.land/std@0.213.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01",
"https://deno.land/std@0.213.0/path/windows/glob_to_regexp.ts": "6dcd1242bd8907aa9660cbdd7c93446e6927b201112b0cba37ca5d80f81be51b",
"https://deno.land/std@0.213.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a",
"https://deno.land/std@0.213.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9",
"https://deno.land/std@0.213.0/path/windows/join.ts": "e0b3356615c1a75c56ebb6a7311157911659e11fd533d80d724800126b761ac3",
"https://deno.land/std@0.213.0/path/windows/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9",
"https://deno.land/std@0.213.0/path/windows/mod.ts": "7d6062927bda47c47847ffb55d8f1a37b0383840aee5c7dfc93984005819689c",
"https://deno.land/std@0.213.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780",
"https://deno.land/std@0.213.0/path/windows/normalize_glob.ts": "179c86ba89f4d3fe283d2addbe0607341f79ee9b1ae663abcfb3439db2e97810",
"https://deno.land/std@0.213.0/path/windows/parse.ts": "b9239edd892a06a06625c1b58425e199f018ce5649ace024d144495c984da734",
"https://deno.land/std@0.213.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
"https://deno.land/std@0.213.0/path/windows/resolve.ts": "75b2e3e1238d840782cee3d8864d82bfaa593c7af8b22f19c6422cf82f330ab3",
"https://deno.land/std@0.213.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43",
"https://deno.land/std@0.213.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484",
"https://deno.land/std@0.213.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c",
"https://deno.land/std@0.213.0/uuid/_common.ts": "05c787c5735776c4e48e30294878332c39cb7738f50b209df4eb9f2b0facce4d",
"https://deno.land/std@0.213.0/uuid/constants.ts": "eb6c96871e968adf3355507d7ae79adce71525fd6c1ca55c51d32ace0196d64e",
"https://deno.land/std@0.213.0/uuid/mod.ts": "b9cb1cf73c3d87e15817486df7e885a63b74a7384768a927c708ccd045fcbf78",
"https://deno.land/std@0.213.0/uuid/v1.ts": "a089755e9ba5a172f3d568b617164d4692bd71e548b018e039d1bcb17d4f1bb6",
"https://deno.land/std@0.213.0/uuid/v3.ts": "aff081baee55498ed5804d006735a77b252ac1645e3b418058807218371de577",
"https://deno.land/std@0.213.0/uuid/v4.ts": "8a9c60c887446651be5d50b468a3d702b87bb821fc35f0edcb5515c3bc07b256",
"https://deno.land/std@0.213.0/uuid/v5.ts": "f6771dc89e89f26e74a9b51d25d6b711c27d2ddf3a3650312dd46e7edfe2491e",
"https://deno.land/x/another_cookiejar@v5.0.4/cookie.ts": "2be7548d01a3a9df97deb187761a843a77fd824057478919abf1e1e89ae1eb2e",
"https://deno.land/x/another_cookiejar@v5.0.4/cookie_jar.ts": "e47d7b2c608bcd9600fd26825b600946f16ae167216cea71935049188d2fc6d1",
"https://deno.land/x/another_cookiejar@v5.0.4/fetch_wrapper.ts": "73434cb1b7d5e595eecdcc23e60af6fbc099b9e4cb82bb92d9f1617a85516286",
"https://deno.land/x/another_cookiejar@v5.0.4/mod.ts": "eff949014965771f2cd447fe78625a1ad28b59333afa40640f02c0922534d89a",
"https://deno.land/x/msgpack@v1.4/CachedKeyDecoder.ts": "c39b6f1572902ae08c0e4971f639e81031ac59403957fc43c6fb3c7fe69d99a1",
"https://deno.land/x/msgpack@v1.4/Decoder.ts": "bdb68309cd51da2b9a897f269784c6d636796258838a97f25b0e1b399c6f369b",
"https://deno.land/x/msgpack@v1.4/Encoder.ts": "4852bbacb30cd66eb2bd61a9e20476802458b991e13aacb5eb984d0348247ffe",
@ -57,12 +178,14 @@
"https://deno.land/x/msgpack@v1.4/utils/stream.ts": "1315e29af5c1a40d97bfa6f1c4f7f73d26067b912236f56851981f2f049500b8",
"https://deno.land/x/msgpack@v1.4/utils/typedArrays.ts": "bb819c2f28cf7f85ed50b2e57f108462715555cc61ce315e8134cf1eef2ae662",
"https://deno.land/x/msgpack@v1.4/utils/utf8.ts": "93183055a7a41986080eeb711e83d553e7c8b121642da4379a5adf253b7beefd",
"https://deno.land/x/progress@v1.2.8/deps.ts": "e0abdc972a0c152508b28ced5ae9c4be26a5773f0aa4a3caa72371c84d2e28a2",
"https://deno.land/x/progress@v1.2.8/mod.ts": "5ef7c7ff079d71effed5055666af81cc58a566bc98e2df8473526bd6457976c5",
"https://deno.land/x/progress@v1.2.8/multi.ts": "392553552243204539d83ee53cadda990db20b1b421520411318ff9bd0320646",
"https://deno.land/x/semaphore@v1.1.1/mod.ts": "431abb51927a16c537cec1cfb05bf2de6a8f3916331f1ec3f9f13ad7ad6a4ea5",
"https://deno.land/x/semaphore@v1.1.1/mutex.ts": "2cc6490481f0fdfe97c6b326a2073819d76b76eac3877864a8ada6a2127492f2",
"https://deno.land/x/semaphore@v1.1.1/semaphore.ts": "0acf1159d635fa3b9198a4ad4acac9e877d79196601aa80544ac0db5a71c646d",
"https://deno.land/x/murmurhash@v1.0.0/mod.ts": "13fd2c5534dfd22ffbfcd4255ea13e47a2f2b99e9c90a83dc43e814a0e278829",
"https://deno.land/x/progress@v1.4.5/deps.ts": "f2886f3f87af20b397ffcf9723a0fabc5893491ce9ce7615a37b1d7a38539247",
"https://deno.land/x/progress@v1.4.5/mod.ts": "e26996fb5f23863c3402133896a9739ea4059b155a3d89ba207cad10b50524ea",
"https://deno.land/x/progress@v1.4.5/multi.ts": "bf50eff76d4c1b1b1a3118e73a58631b19b0b30e0dd4166ae4ef0886efae88a5",
"https://deno.land/x/progress@v1.4.5/time.ts": "001198ff9fe2a452830515fc944665c4369990102978b325e1c9094486cfd8ab",
"https://deno.land/x/semaphore@v1.1.2/mod.ts": "431abb51927a16c537cec1cfb05bf2de6a8f3916331f1ec3f9f13ad7ad6a4ea5",
"https://deno.land/x/semaphore@v1.1.2/mutex.ts": "2cc6490481f0fdfe97c6b326a2073819d76b76eac3877864a8ada6a2127492f2",
"https://deno.land/x/semaphore@v1.1.2/semaphore.ts": "a3da40292cd49c3f31be392aa16831d00c1ddf6daca50dd74eb61aa6ae8f52a3",
"https://deno.land/x/ts_essentials@v9.1.2/lib/functions.ts": "20681c98ce82d503dba56f5ef9313c196f18a2317ce7c0c331cc3fdea0d56688",
"https://deno.land/x/ts_essentials@v9.1.2/lib/literal-types/mod.ts": "c1b9e16a7e49814e9509bed8a5dec25b717761a37d0ef1589d411bd6130dd2e5",
"https://deno.land/x/ts_essentials@v9.1.2/lib/mod.ts": "d7e44a25aa621425ffd118a0210a492c5c354411018e2db648a68614d5901f5b",

19
deps.ts
View File

@ -2,15 +2,14 @@ export {
Cookie,
CookieJar,
wrapFetch,
} from "https://deno.land/x/another_cookiejar@v4.1.4/mod.ts";
export type { CookieOptions } from "https://deno.land/x/another_cookiejar@v4.1.4/mod.ts";
export * as base64 from "https://deno.land/std@0.160.0/encoding/base64.ts";
export * as flags from "https://deno.land/std@0.160.0/flags/mod.ts";
export * as io from "https://deno.land/std@0.160.0/io/mod.ts";
export * as uuid from "https://deno.land/std@0.160.0/uuid/mod.ts";
} from "https://deno.land/x/another_cookiejar@v5.0.4/mod.ts";
export type { CookieOptions } from "https://deno.land/x/another_cookiejar@v5.0.4/mod.ts";
export * as base64 from "https://deno.land/std@0.213.0/encoding/base64.ts";
export * as flags from "https://deno.land/std@0.213.0/flags/mod.ts";
export * as io from "https://deno.land/std@0.213.0/io/mod.ts";
export * as uuid from "https://deno.land/std@0.213.0/uuid/mod.ts";
export * as msgpack from "https://deno.land/x/msgpack@v1.4/mod.ts";
export * as path from "https://deno.land/std@0.160.0/path/mod.ts";
export { MultiProgressBar } from "https://deno.land/x/progress@v1.2.8/mod.ts";
export { Mutex } from "https://deno.land/x/semaphore@v1.1.1/mod.ts";
export * as path from "https://deno.land/std@0.213.0/path/mod.ts";
export { MultiProgressBar } from "https://deno.land/x/progress@v1.4.5/mod.ts";
export { Mutex } from "https://deno.land/x/semaphore@v1.1.2/mod.ts";
export type { DeepReadonly } from "https://deno.land/x/ts_essentials@v9.1.2/mod.ts";
export { writeAll } from "https://deno.land/std@0.160.0/streams/conversion.ts";

View File

@ -1 +1 @@
export { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts";
export { assertEquals } from "https://deno.land/std@0.213.0/assert/mod.ts";

12
gui/.eslintrc.cjs Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
}

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,50 +11,39 @@
"lint": "eslint --max-warnings=0 src"
},
"dependencies": {
"@tauri-apps/api": "^1.3.0",
"classnames": "^2.3.2",
"daisyui": "^2.52.0",
"i18next": "^22.5.0",
"i18next-browser-languagedetector": "^7.0.2",
"@tauri-apps/api": "^1.5.3",
"clsx": "^2.1.0",
"daisyui": "^4.6.1",
"i18next": "^23.8.1",
"i18next-browser-languagedetector": "^7.2.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-use": "^17.4.0"
"react-error-boundary": "^4.0.12",
"react-i18next": "^14.0.1",
"react-icons": "^5.0.1",
"react-router-dom": "^6.21.3",
"react-use": "^17.5.0",
"swr": "^2.2.4"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@types/node": "^20.2.5",
"@tauri-apps/cli": "^1.5.9",
"@types/node": "^20.11.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.41.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",
"vite": "^4.3.9",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@typescript-eslint/typescript-estree": "^6.20.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"i18next-http-backend": "^2.4.2",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.2.0"
},
"eslintConfig": {
"extends": "react-app"
},
"pnpm": {
"packageExtensions": {
"eslint-plugin-flowtype": {
"peerDependenciesMeta": {
"@babel/plugin-syntax-flow": {
"optional": true
},
"@babel/plugin-transform-react-jsx": {
"optional": true
}
}
}
}
"vite-tsconfig-paths": "^4.3.1"
}
}

File diff suppressed because it is too large Load Diff

814
gui/src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,14 +10,22 @@ 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.5.1", 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.5.4", features = [
"fs-all",
"path-all",
"process-relaunch",
"shell-execute",
"shell-open",
"shell-sidecar",
"window-all",
] }
serde = { version = "^1.0.196", features = ["derive"] }
serde_json = "^1.0.113"
tokio = { version = "^1.35.1", features = ["time"] }
backtrace = "^0.3.69"
[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.20"
},
"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,20 +4,21 @@ 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';
import { AppContextProvider } from 'context/app';
function App() {
useShowWindow();
return (
<AppContextProvider>
<Routes>
<Route path='/' element={<Layout />}>
<Route index element={<Home />} />
<Route path='/settings' element={<Settings />} />
<Route path='/guide' element={<Guide />} />
<Route path='/redirect' element={<RedirectLogin />} />
</Route>
</Routes>
</AppContextProvider>
);
}

View File

@ -11,6 +11,6 @@ export const CheckUpdate: React.FC<{ className?: string, children?: ReactNode }>
}
return <>
<button className={className} onClick={onClick}>{children}</button>
<button type='button' className={className} onClick={onClick}>{children}</button>
</>;
}

View File

@ -1,9 +1,10 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { AiOutlineWarning } from 'react-icons/ai'
import { FallbackProps } from 'react-error-boundary'
type ErrorContentProps = {
error: any
error: unknown
retry?: () => void
}
@ -18,9 +19,14 @@ export const ErrorContent: React.FC<ErrorContentProps> = ({ error, retry }) => {
<span className='inline-flex items-center'>
<AiOutlineWarning className='inline-block scale-[2] mr-4 justify-end flex-none' />
<div className='max-w-full break-all'>
<div>{t('发生了错误')}{retry && <button className='link link-info ml-1'>{t('重试')}</button>}</div>
<div>{t('发生了错误')}{retry && <button type='button' className='link link-info ml-1'>{t('重试')}</button>}</div>
{String(error)}
</div>
</span>
</div>
}
export const FallbackComponent: React.FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
console.error('FallbackComponent', error)
return <ErrorContent error={error} retry={resetErrorBoundary} />
}

View File

@ -9,6 +9,6 @@ type HeaderProps = {
export const Header: React.FC<HeaderProps> = ({ title }) => {
const navigate = useNavigate();
return <>
<h2 className="card-title" data-tauri-drag-region><button onClick={() => navigate(-1)}><AiOutlineLeft /></button>{title}</h2>
<h2 className="card-title" data-tauri-drag-region><button type='button' onClick={() => navigate(-1)}><AiOutlineLeft /></button>{title}</h2>
</>
}

View File

@ -1,10 +1,8 @@
import { invoke } from '@tauri-apps/api';
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import clsx from 'clsx';
import { useService, useServiceMutation } from 'services/useService';
import React, { useState } from 'react'
import { getConfig, getProfile, setProfile } from 'services/config';
import { ensureTokenValid } from 'services/s3si';
import { composeLoadable } from 'utils/composeLoadable';
import { ErrorContent } from './ErrorContent';
type OpenSplatnetProps = {
@ -12,30 +10,29 @@ type OpenSplatnetProps = {
}
export const OpenSplatnet: React.FC<OpenSplatnetProps> = ({ children }) => {
let { loading, error, retry, result } = composeLoadable({
config: usePromise(getConfig),
profile: usePromise(() => getProfile(0)),
});
const profileResult = useService('profile', 0)
const { trigger: setProfile } = useServiceMutation('profile', 0)
const [doing, setDoing] = useState(false);
const [err, setError] = useState<any>();
const [err, setError] = useState<unknown>();
const onClick = async () => {
setDoing(true);
try {
if (!result) {
if (!profileResult.data) {
return;
}
const state = result.profile.state;
const state = profileResult.data.state;
const newState = await ensureTokenValid(state);
await setProfile(0, {
...result.profile,
await setProfile({
...profileResult.data,
state: newState,
});
retry?.();
const gtoken = newState.loginState?.gToken;
await invoke('open_splatnet', {
gtoken,
lang: result.profile.state.userLang,
lang: profileResult.data.state.userLang,
});
} catch (e) {
setError(e);
@ -45,16 +42,21 @@ export const OpenSplatnet: React.FC<OpenSplatnetProps> = ({ children }) => {
};
if (error || err) {
if (err) {
return <>
<ErrorContent error={error || err} retry={retry} />
<ErrorContent error={err} />
</>
}
const btnLoading = profileResult.isLoading || doing;
return <>
<button className={classNames('btn', {
'btn-disabled': !result?.profile.state.loginState?.sessionToken,
'loading': loading || doing,
})} onClick={onClick}>{children}</button>
<button
type='button'
className={clsx('btn w-full', {
'btn-disabled': !profileResult.data?.state?.loginState?.sessionToken,
})}
onClick={onClick}
disabled={btnLoading}
>{btnLoading ? <span className='loading' /> : children}</button>
</>
}

View File

@ -1,71 +1,39 @@
import classNames from 'classnames';
import { usePromise } from 'hooks/usePromise';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { canExport, getProfile, setProfile } from 'services/config';
import { addLog, run, useLog } from 'services/s3si';
import { useLog } from 'services/s3si';
import { Checkbox } from './Checkbox';
import { Loading } from './Loading';
import { useService } from 'services/useService';
import { useAppContext } from 'context/app'
type RunPanelProps = {
}
type RunPanelProps = Record<string, never>
export const RunPanel: React.FC<RunPanelProps> = () => {
const { t } = useTranslation();
const { result } = usePromise(() => getProfile(0));
const { data: result } = useService('profile', 0)
const [exportBattle, setExportBattle] = useState(true);
const [exportCoop, setExportCoop] = useState(true);
const [loading, setLoading] = useState(false);
const { exports } = useAppContext()
const disabled = !exports
const isExporting = exports?.isExporting ?? false
if (!result) {
return <Loading />
}
const onClick = async () => {
setLoading(true);
try {
addLog({
level: 'log',
msg: ['Export started at', new Date().toLocaleString()],
})
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink",
monitor: false,
withSummary: false,
skipMode: exportBattle === false ? 'vs' : exportCoop === false ? 'coop' : undefined,
});
await setProfile(0, {
...result,
state: newState,
})
} catch (e) {
console.error(e)
addLog({
level: 'error',
msg: [e],
})
} finally {
addLog({
level: 'log',
msg: ['Export ended at', new Date().toLocaleString()],
})
setLoading(false);
}
}
const disabled = !canExport(result);
return <>
<div className="tooltip" data-tip={disabled ? t('请先在设置中完成Nintendo Account登录和stat.ink的API密钥') : undefined}>
<Checkbox disabled={disabled || loading} value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox disabled={disabled || loading} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<Checkbox disabled={disabled || isExporting} value={exportBattle} onChange={setExportBattle}>{t('导出对战数据')}</Checkbox>
<Checkbox disabled={disabled || isExporting} value={exportCoop} onChange={setExportCoop}>{t('导出打工数据')}</Checkbox>
<button
onClick={onClick}
className={classNames('btn w-full', {
type='button'
onClick={() => exports?.trigger({ exportBattle, exportCoop })}
className={clsx('btn btn-primary w-full', {
'btn-disabled': disabled || (!exportBattle && !exportCoop),
'loading': loading,
})}
>{t('导出')}</button>
disabled={isExporting}
>{isExporting ? <span className='loading' /> : t('导出')}</button>
</div>
</>
}

81
gui/src/context/app.tsx Normal file
View File

@ -0,0 +1,81 @@
import { ReactNode, createContext, useContext } from 'react'
import { canExport } from 'services/config';
import { addLog, run } from 'services/s3si';
import { useService, useServiceMutation } from 'services/useService';
import useSWRMutation from 'swr/mutation';
export type ExportArgs = {
exportBattle: boolean,
exportCoop: boolean,
}
const APP_CONTEXT = createContext<{
exports?: {
isExporting: boolean
trigger: (args: ExportArgs) => Promise<void>
}
}>({})
export const useAppContext = () => {
return useContext(APP_CONTEXT)
}
export const AppContextProvider: React.FC<{ children?: ReactNode }> = ({ children }) => {
const { data: result } = useService('profile', 0)
const { trigger: setProfile } = useServiceMutation('profile', 0)
const { trigger: doExport, isMutating } = useSWRMutation<
unknown,
Error,
string,
{
exportBattle: boolean,
exportCoop: boolean,
}
>('export', async (_, { arg: {
exportBattle, exportCoop,
} }) => {
try {
if (!result) {
return
}
addLog({
level: 'log',
msg: ['Export started at', new Date().toLocaleString()],
})
const { state } = result;
const newState = await run(state, {
exporter: "stat.ink",
monitor: false,
withSummary: false,
skipMode: exportBattle === false ? 'vs' : exportCoop === false ? 'coop' : undefined,
});
await setProfile({
...result,
state: newState,
})
} catch (e) {
console.error(e)
addLog({
level: 'error',
msg: [e],
})
} finally {
addLog({
level: 'log',
msg: ['Export ended at', new Date().toLocaleString()],
})
}
})
return <APP_CONTEXT.Provider value={{
exports: result && canExport(result) ? {
isExporting: isMutating,
trigger: async (args: ExportArgs) => {
await doExport(args)
},
} : undefined,
}}>
{children}
</APP_CONTEXT.Provider>
}

View File

@ -1,84 +0,0 @@
import { useState } from "react";
/**
* A hook that returns a promise and its state.
*
* @param factory A function that returns a promise.
* @returns An object containing the promise's state and result.
* @example
* const { loading, result, error } = usePromise(() => fetch('https://example.com')
* .then(response => response.text())
* );
* if (loading) {
* return <p>Loading...</p>;
* }
* if (error) {
* return <p>Error: {error.message}</p>;
* }
* return <p>Result: {result}</p>;
*/
export function usePromise<T>(factory: () => Promise<T>) {
const init = () => {
const promise = factory();
if (!promise || typeof promise.then !== "function") {
throw new Error("The factory function must return a promise.");
}
return promise
.then(r => {
setResult(r);
setLoading(false);
return r;
})
.catch(e => {
setError(e);
setLoading(false);
throw e;
});
}
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<any | undefined>(undefined);
const [promise, setPromise] = useState(init);
const retry = () => {
setLoading(true);
setResult(undefined);
setError(undefined);
setPromise(init);
}
return { loading, result, error, promise, retry };
}
/**
* A hook that returns a promise and its state.
*/
export function usePromiseLazy<T, Args extends any[]>(factory: (...args: Args) => Promise<T>) {
const init = (promise: Promise<T>) => {
if (!promise || typeof promise.then !== "function") {
throw new Error("The factory function must return a promise.");
}
return promise
.then(r => {
setResult(r);
setLoading(false);
return r;
})
.catch(e => {
setError(e);
setLoading(false);
throw e;
});
}
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<T | undefined>(undefined);
const [error, setError] = useState<any | undefined>(undefined);
const [promise, setPromise] = useState<Promise<T> | undefined>(undefined);
const execute = (...args: Args) => {
setLoading(true);
setResult(undefined);
setError(undefined);
setPromise(init(factory(...args)));
}
return [execute, { loading, result, error, promise }] as const;
}

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
type Maybe<T> = T | null | undefined;
type KeyOf<T extends Record<string, any>, K = keyof T> = K extends string ? (T[K] extends Function ? never : K) : never;
type DotField<T extends Maybe<Record<string, any>>, K = KeyOf<NonNullable<T>>> = K extends string

View File

@ -21,7 +21,7 @@ export class JSONRPCClient<S extends Service> {
protected transport: Transport;
protected requestMap: Map<
ID,
(result: RPCResult<any, ResponseError>) => void
(result: RPCResult<unknown, ResponseError>) => void
> = new Map();
protected fatal: unknown = undefined;
protected task: Promise<void>;
@ -55,6 +55,7 @@ export class JSONRPCClient<S extends Service> {
// receive response from server
protected async run() {
try {
// eslint-disable-next-line no-constant-condition
while (true) {
const data = await this.transport.recv();
if (data === undefined) {
@ -111,7 +112,7 @@ export class JSONRPCClient<S extends Service> {
if (result.error) {
rej(new JSONRPCError(result.error));
} else {
res(result.result);
res(result.result as R);
}
});
});
@ -120,6 +121,7 @@ export class JSONRPCClient<S extends Service> {
getProxy(): S {
const proxy = new Proxy({}, {
get: (_, method: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (...params: unknown[]) => this.call(method, ...params as any);
},
});

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

@ -1,4 +1,4 @@
import classNames from 'classnames';
import clsx from 'clsx';
import { Header } from 'components/Header';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -30,14 +30,16 @@ const Steps: React.FC<{ steps: Step[], className?: string }> = ({ className, ste
{Content && <Content onChange={setState} />}
<div className='mt-4 flex gap-2'>
<button
type='button'
onClick={() => setStep(s => s - 1)}
className={classNames('btn', {
className={clsx('btn', {
'btn-disabled': !hasPrev || !state.prev,
})}
>{t('上一步')}</button>
<button
type='button'
onClick={() => setStep(s => s + 1)}
className={classNames('btn', {
className={clsx('btn', {
'btn-disabled': !hasNext || !state.next,
})}
>{t('下一步')}</button>
@ -49,7 +51,11 @@ const LoginNintendoAccount: React.FC<{ onChange: (v: StepState) => void }> = ({
const { t } = useTranslation();
return <div className='my-3'>
<button className='btn' onClick={() => onChange({ next: true, prev: true })}>{t('点击登录')}</button>
<button
type='button'
className='btn'
onClick={() => onChange({ next: true, prev: true })}
>{t('点击登录')}</button>
</div>
}

View File

@ -1,14 +1,19 @@
import { OpenSplatnet } from 'components/OpenSplatnet';
import { LogPanel, RunPanel } from 'components/RunPanel';
import { STAT_INK } from 'constant';
import React from 'react'
import React, { Suspense } from 'react'
import { useTranslation } from 'react-i18next';
import { Link } from "react-router-dom";
import { Link } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { FallbackComponent } from 'components/ErrorContent';
import { Loading } from 'components/Loading';
export const Home: React.FC = () => {
const { t } = useTranslation();
return <div className='flex p-2 w-full h-full gap-2'>
return <ErrorBoundary FallbackComponent={FallbackComponent}>
<Suspense fallback={<Loading />}>
<div className='flex p-2 w-full h-full gap-2'>
<div className='max-w-full h-full md:max-w-sm flex-auto'>
<div className='flex flex-col gap-2 h-full'>
<LogPanel className='sm:hidden flex-auto' />
@ -16,10 +21,12 @@ 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>
<LogPanel className='hidden sm:block flex-1' />
</div>
</Suspense>
</ErrorBoundary>
}

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

@ -1,16 +1,17 @@
import { ErrorContent } from 'components/ErrorContent';
import { ErrorContent, FallbackComponent } from 'components/ErrorContent';
import { Loading } from 'components/Loading';
import { usePromise, usePromiseLazy } from 'hooks/usePromise';
import React, { useState } from 'react'
import React, { Suspense, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { Config, getConfig, getProfile, Profile, setConfig, setProfile } from 'services/config';
import { composeLoadable } from 'utils/composeLoadable';
import classNames from 'classnames';
import { Config, Profile } from 'services/config';
import clsx from 'clsx';
import { useLogin } from 'services/s3si';
import { STAT_INK } from 'constant';
import { Header } from 'components/Header';
import { useSubField } from 'hooks/useSubField';
import { useNavigate } from 'react-router-dom';
import useSWRMutation from 'swr/mutation'
import { useService, useServiceMutation } from 'services/useService';
import { ErrorBoundary } from 'react-error-boundary';
const STAT_INK_KEY_LENGTH = 43;
@ -57,6 +58,8 @@ const Form: React.FC<{
const { t, i18n } = useTranslation();
const [value, setValue] = useState(oldValue);
const { subField } = useSubField({ value, onChange: setValue });
const { trigger: setProfile } = useServiceMutation('profile', 0)
const { trigger: setConfig } = useServiceMutation('config')
const changed = JSON.stringify(value) !== JSON.stringify(oldValue);
@ -64,12 +67,12 @@ const Form: React.FC<{
const statInkApiKey = subField('profile.state.statInkApiKey')
const splatnet3Lang = subField('profile.state.userLang')
const [onSave, { loading, error }] = usePromiseLazy(async () => {
await setProfile(0, value.profile);
const { trigger: onSave, isMutating: loading, error } = useSWRMutation('saveSettings', async () => {
await setProfile(value.profile);
await setConfig(value.config);
onSaved?.();
})
const [onLogin, loginState] = usePromiseLazy(async () => {
const loginState = useSWRMutation('login', async () => {
const result = await login();
if (!result) {
return;
@ -85,11 +88,12 @@ const Form: React.FC<{
<label className="label">
<span className="label-text">{t('Nintendo Account 会话令牌')}</span>
<span className="label-text-alt"><button
className={classNames('link', {
loading: loginState.loading,
type='button'
className={clsx('link', {
loading: loginState.isMutating,
})}
onClick={onLogin}
disabled={loginState.loading}
onClick={() => loginState.trigger()}
disabled={loginState.isMutating}
>{t('网页登录')}</button></span>
</label>
<input
@ -113,7 +117,7 @@ const Form: React.FC<{
</label>
<div className='tooltip' data-tip={statInkKeyError ? t('密钥的长度应该为{{length}}, 请检查', { length: STAT_INK_KEY_LENGTH }) : null}>
<input
className={classNames("input input-bordered w-full", {
className={clsx("input input-bordered w-full", {
'input-error': statInkKeyError,
})}
type="text"
@ -148,38 +152,49 @@ const Form: React.FC<{
</div>
<ErrorContent error={error} />
<div className='flex gap-4 max-w-md justify-between flex-auto-all'>
<div className="tooltip" data-tip={changed ? undefined : t('没有更改')}>
<button className={classNames('btn btn-primary w-full', {
<div className='tooltip' data-tip={changed ? undefined : t('没有更改')}>
<button
type='button'
className={clsx('btn btn-primary w-full', {
loading,
})} onClick={onSave} disabled={!changed || statInkKeyError}>{t('保存')}</button>
})}
onClick={() => onSave()}
disabled={!changed || statInkKeyError}
>{t('保存')}</button>
</div>
<button className={classNames('btn', {
<button
type='button'
className={clsx('btn', {
loading,
})} onClick={() => setValue(oldValue)}>{t('重置')}</button>
})}
onClick={() => setValue(oldValue)}
>{t('重置')}</button>
</div>
</>
}
export const Settings: React.FC = () => {
const SettingsLoader: React.FC = () => {
const navigate = useNavigate();
let { loading, error, retry, result } = composeLoadable({
config: usePromise(getConfig),
profile: usePromise(() => getProfile(0)),
});
const { data: config } = useService('config')
const { data: profile } = useService('profile', 0)
if (loading) {
return <Page>
<div className='h-full flex items-center justify-center'><Loading /></div>
</Page>
if (!config || !profile) {
return <>
Error
</>
}
if (error) {
return <Page>
<ErrorContent error={error} retry={retry} />
</Page>
}
return <>
<Form oldValue={{ config, profile }} onSaved={() => navigate(-1)} />
</>
}
export const Settings: React.FC = () => {
return <Page>
{result && <Form oldValue={result} onSaved={() => navigate(-1)} />}
<ErrorBoundary FallbackComponent={FallbackComponent}>
<Suspense fallback={<div className='h-full flex items-center justify-center'><Loading /></div>}>
<SettingsLoader />
</Suspense>
</ErrorBoundary>
</Page>
}

View File

@ -1,6 +1,6 @@
import { fs } from "@tauri-apps/api"
import { appConfigDir, join } from '@tauri-apps/api/path'
import { State } from '../../../src/state';
import type { State } from '../../../src/state';
const configFile = appConfigDir().then(c => join(c, 'config.json'));
const profileDir = appConfigDir().then(c => join(c, 'profile'));
@ -9,8 +9,7 @@ export type Profile = {
state: State,
}
export type Config = {
}
export type Config = Record<string, never>
// TODO: import from state.ts.
const DEFAULT_STATE: State = {

View File

@ -9,6 +9,7 @@ const client = new JSONRPCClient<S3SIService>({
const LOG_SUB = new Set<(logs: Log[]) => void>();
async function getLogs() {
// eslint-disable-next-line no-constant-condition
while (true) {
const r = await client.getLogs()
@ -57,7 +58,7 @@ export const useLog = () => {
return useContext(LOG_CONTEXT);
}
function renderMsg(i: any) {
function renderMsg(i: unknown) {
if (i instanceof Error) {
return i.message
}
@ -91,14 +92,15 @@ export const LogProvider: React.FC<{ limit?: number, children?: React.ReactNode
LOG_SUB.delete(cb);
}
}, [limit])
const renderedLogs = useMemo(() => logs.map(renderLog), [logs])
return <LOG_CONTEXT.Provider value={{
const value = useMemo(() => {
const renderedLogs = logs.map(renderLog)
return {
logs,
renderedLogs,
}}>
}
}, [logs])
return <LOG_CONTEXT.Provider value={value}>
{children}
</LOG_CONTEXT.Provider>
}

View File

@ -0,0 +1,37 @@
import useSWR, { Key, SWRResponse } from 'swr'
import useSWRMutation, { SWRMutationResponse } from 'swr/mutation'
import { getConfig, getProfile, setConfig, setProfile } from './config'
const SERVICES = {
profile: {
fetcher: getProfile,
updater: setProfile,
},
config: {
fetcher: getConfig,
updater: setConfig,
},
} as const
export type Services = keyof typeof SERVICES
export const useService = <S extends Services>(service: S, ...args: Parameters<(typeof SERVICES)[S]['fetcher']>): SWRResponse<
Awaited<ReturnType<(typeof SERVICES)[S]['fetcher']>>
> => {
// @ts-expect-error TypeScript can not infer type here
return useSWR(['service', service, ...args], () => SERVICES[service].fetcher(...args), { suspense: true })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RemoveLastParamters<T extends (...args: any) => any> = T extends (...args: [...infer P, any]) => any ? P : never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LastParamter<T extends (...args: any) => any> = T extends (...args: [...infer _, infer P]) => any ? P : never;
export const useServiceMutation = <S extends Services>(service: S, ...args: RemoveLastParamters<(typeof SERVICES)[S]['updater']>): SWRMutationResponse<
Awaited<ReturnType<(typeof SERVICES)[S]['updater']>>,
Error,
Key,
LastParamter<(typeof SERVICES)[S]['updater']>
> => {
// @ts-expect-error TypeScript can not infer type here
return useSWRMutation(['service', service, ...args], (_, { arg }) => SERVICES[service].updater(...args, arg))
}

View File

@ -1,19 +0,0 @@
export type Loadable<T> = {
loading: boolean;
result?: T;
error?: any;
retry?: () => void;
}
export function composeLoadable<T extends Record<string, Loadable<any>>>(map: T): Loadable<{
[P in keyof T]: T[P] extends Loadable<infer R> ? R : never
}> {
const values = Object.values(map)
const loading = values.some(v => v.loading);
const error = values.find(v => v.error)?.error;
const result = loading || error ? undefined : Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v.result])) as any;
const retry = values.some(i => !!i.retry) ? () => Object.values(map).forEach(v => v.retry?.()) : undefined;
return { loading, result, error, retry };
}

19
s3si.ts
View File

@ -4,7 +4,13 @@ import { flags } from "./deps.ts";
const parseArgs = (args: string[]) => {
const parsed = flags.parse(args, {
string: ["profilePath", "exporter", "skipMode"],
string: [
"profilePath",
"exporter",
"skipMode",
"listMethod",
"nxapiPresenceUrl",
],
boolean: ["help", "noProgress", "monitor", "withSummary"],
alias: {
"help": "h",
@ -14,6 +20,8 @@ const parseArgs = (args: string[]) => {
"monitor": ["m"],
"skipMode": ["s", "skip-mode"],
"withSummary": "with-summary",
"listMethod": "list-method",
"nxapiPresenceUrl": ["nxapi-presence"],
},
});
return parsed;
@ -28,13 +36,18 @@ 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")
(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
--nxapi-presence Extends monitoring mode to use Nintendo Switch presence from nxapi`,
);
Deno.exit(0);
}

View File

@ -1,4 +1,4 @@
import * as path from "https://deno.land/std@0.178.0/path/mod.ts";
import * as path from "https://deno.land/std@0.213.0/path/mod.ts";
import { assertEquals } from "../dev_deps.ts";
if (import.meta.main) {
@ -39,6 +39,8 @@ if (import.meta.main) {
target,
"code:",
status.code,
"stderr:",
new TextDecoder().decode(status.stderr),
);
Deno.exit(status.code);
}

View File

@ -1 +0,0 @@
{}

View File

@ -1,88 +0,0 @@
{
"version": "2",
"remote": {
"https://deno.land/std@0.141.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.141.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d",
"https://deno.land/std@0.141.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9",
"https://deno.land/std@0.141.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.141.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37",
"https://deno.land/std@0.141.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
"https://deno.land/std@0.141.0/io/types.d.ts": "01f60ae7ec02675b5dbed150d258fc184a78dfe5c209ef53ba4422b46b58822c",
"https://deno.land/std@0.141.0/streams/conversion.ts": "8268f3f1a43324953dd8e9e4e31adb42e3caddb4502433bde03c279e43d70a3b",
"https://deno.land/std@0.160.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.160.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934",
"https://deno.land/std@0.160.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.160.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"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",
"https://deno.land/std@0.160.0/io/types.d.ts": "107e1e64834c5ba917c783f446b407d33432c5d612c4b3430df64fc2b4ecf091",
"https://deno.land/std@0.160.0/io/util.ts": "23e706b4b6a3ebb34af00ad74d7549d906f3211fc98c1fba1185a36e017fb727",
"https://deno.land/std@0.160.0/io/writers.ts": "2e1c63ffd0cfba411b1fd8374609abff9ea86187c9d4d885d42e6fc20325ef0e",
"https://deno.land/std@0.160.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3",
"https://deno.land/std@0.160.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09",
"https://deno.land/std@0.160.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677",
"https://deno.land/std@0.160.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633",
"https://deno.land/std@0.160.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee",
"https://deno.land/std@0.160.0/path/mod.ts": "56fec03ad0ebd61b6ab39ddb9b0ddb4c4a5c9f2f4f632e09dd37ec9ebfd722ac",
"https://deno.land/std@0.160.0/path/posix.ts": "6b63de7097e68c8663c84ccedc0fd977656eb134432d818ecd3a4e122638ac24",
"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",
"https://deno.land/std@0.160.0/uuid/v4.ts": "3e983c6ac895ea2a7ba03da927a2438fe1c26ac43fb38dc44f2f8aa50c23cb53",
"https://deno.land/std@0.160.0/uuid/v5.ts": "43973aeda44ad212f2ec9b8d6c042b74d5cef4ce583d6aa6fc4cdb339344c74c",
"https://deno.land/std@0.178.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
"https://deno.land/std@0.178.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3",
"https://deno.land/std@0.178.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
"https://deno.land/std@0.178.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b",
"https://deno.land/std@0.178.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0",
"https://deno.land/std@0.178.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000",
"https://deno.land/std@0.178.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1",
"https://deno.land/std@0.178.0/path/mod.ts": "4b83694ac500d7d31b0cdafc927080a53dc0c3027eb2895790fb155082b0d232",
"https://deno.land/std@0.178.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d",
"https://deno.land/std@0.178.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1",
"https://deno.land/std@0.178.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie.ts": "72d6a6633ea13dd2f13b53d9726735b194996353a958024072c4d6b077c97baf",
"https://deno.land/x/another_cookiejar@v4.1.4/cookie_jar.ts": "9accd36e76929f2f06fa710d2165fb544703617245fa36ac63560b9fa2a22a25",
"https://deno.land/x/another_cookiejar@v4.1.4/fetch_wrapper.ts": "d8918c0776413b2d4a675415727973390b4401a026f6dfdcffedce3296b5e0dc",
"https://deno.land/x/another_cookiejar@v4.1.4/mod.ts": "eff949014965771f2cd447fe78625a1ad28b59333afa40640f02c0922534d89a",
"https://deno.land/x/msgpack@v1.4/CachedKeyDecoder.ts": "c39b6f1572902ae08c0e4971f639e81031ac59403957fc43c6fb3c7fe69d99a1",
"https://deno.land/x/msgpack@v1.4/Decoder.ts": "bdb68309cd51da2b9a897f269784c6d636796258838a97f25b0e1b399c6f369b",
"https://deno.land/x/msgpack@v1.4/Encoder.ts": "4852bbacb30cd66eb2bd61a9e20476802458b991e13aacb5eb984d0348247ffe",
"https://deno.land/x/msgpack@v1.4/ExtData.ts": "8d97fe43568e119a1eeb93e1ef1c431e0a24e392fb0c6ffed775aac1e579f244",
"https://deno.land/x/msgpack@v1.4/ExtensionCodec.ts": "e8a24eb1786156239f589cc3058c8ff3d79ed393f420c40fdf7a93df943c91f2",
"https://deno.land/x/msgpack@v1.4/context.ts": "6228de10854dbadf6aef096960af0115214078ec3784eca4565587769fde3d1c",
"https://deno.land/x/msgpack@v1.4/decode.ts": "c808aeec46f6d0e5b28d0bbacd40e78d0a3614b229368c70db2e53c03f7555ca",
"https://deno.land/x/msgpack@v1.4/decodeAsync.ts": "19e4f33ba0cc8d200b857deb9721bace863c0e89f7bff73e2b04379e4ee85bad",
"https://deno.land/x/msgpack@v1.4/encode.ts": "c5598f8eec9efcbd0ef07f246ade049a8f4906703cdb601baf03b2774b293916",
"https://deno.land/x/msgpack@v1.4/mod.ts": "c28290db26b1ea027e1798085fd6c8055685ea086f1418d54a33542b285633c9",
"https://deno.land/x/msgpack@v1.4/timestamp.ts": "5169949efe39bc24f58cd5dcaae682cdf5353c762a54abf9ae6e18c8d9feb648",
"https://deno.land/x/msgpack@v1.4/utils/int.ts": "b08743982f954d2dd7f4f11d868019576b63cb8147d8acc1bce3843f39398188",
"https://deno.land/x/msgpack@v1.4/utils/prettyByte.ts": "35c8104d57ba2a727056beaf1063bbe941d512cdd23ce6b04d7c5b44dafcd46e",
"https://deno.land/x/msgpack@v1.4/utils/stream.ts": "1315e29af5c1a40d97bfa6f1c4f7f73d26067b912236f56851981f2f049500b8",
"https://deno.land/x/msgpack@v1.4/utils/typedArrays.ts": "bb819c2f28cf7f85ed50b2e57f108462715555cc61ce315e8134cf1eef2ae662",
"https://deno.land/x/msgpack@v1.4/utils/utf8.ts": "93183055a7a41986080eeb711e83d553e7c8b121642da4379a5adf253b7beefd",
"https://deno.land/x/murmurhash@v1.0.0/mod.ts": "13fd2c5534dfd22ffbfcd4255ea13e47a2f2b99e9c90a83dc43e814a0e278829",
"https://deno.land/x/progress@v1.2.8/deps.ts": "e0abdc972a0c152508b28ced5ae9c4be26a5773f0aa4a3caa72371c84d2e28a2",
"https://deno.land/x/progress@v1.2.8/mod.ts": "5ef7c7ff079d71effed5055666af81cc58a566bc98e2df8473526bd6457976c5",
"https://deno.land/x/progress@v1.2.8/multi.ts": "392553552243204539d83ee53cadda990db20b1b421520411318ff9bd0320646",
"https://deno.land/x/semaphore@v1.1.1/mod.ts": "431abb51927a16c537cec1cfb05bf2de6a8f3916331f1ec3f9f13ad7ad6a4ea5",
"https://deno.land/x/semaphore@v1.1.1/mutex.ts": "2cc6490481f0fdfe97c6b326a2073819d76b76eac3877864a8ada6a2127492f2",
"https://deno.land/x/semaphore@v1.1.1/semaphore.ts": "0acf1159d635fa3b9198a4ad4acac9e877d79196601aa80544ac0db5a71c646d",
"https://deno.land/x/ts_essentials@v9.1.2/lib/functions.ts": "20681c98ce82d503dba56f5ef9313c196f18a2317ce7c0c331cc3fdea0d56688",
"https://deno.land/x/ts_essentials@v9.1.2/lib/literal-types/mod.ts": "c1b9e16a7e49814e9509bed8a5dec25b717761a37d0ef1589d411bd6130dd2e5",
"https://deno.land/x/ts_essentials@v9.1.2/lib/mod.ts": "d7e44a25aa621425ffd118a0210a492c5c354411018e2db648a68614d5901f5b",
"https://deno.land/x/ts_essentials@v9.1.2/lib/types.ts": "7ee99797a880948c07020e90d569ca3c5d465c378949262110283aa7856f5603",
"https://deno.land/x/ts_essentials@v9.1.2/mod.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345"
}
}

View File

@ -23,7 +23,7 @@ function encryptKey(uid: string) {
hasher.hash(uid);
const hash = hasher.result();
const key = hash & 0xff;
const encrypted = base64.encode(
const encrypted = base64.encodeBase64(
new TextEncoder().encode(uid).map((i) => i ^ key),
);
return {

View File

@ -20,7 +20,7 @@ for (const file of files) {
const content: FileExporterType = JSON.parse(await Deno.readTextFile(file));
if (content.type === "SUMMARY") continue;
const id = content.data.detail.id;
const rawId = base64.decode(id);
const rawId = base64.decodeBase64(id);
const uuid = new TextDecoder().decode(rawId.slice(rawId.length - 36));
if (ids.has(uuid)) {
console.log(

View File

@ -22,7 +22,7 @@ class TestRankTracker extends RankTracker {
}
function genId(id: number, date = "20220101"): string {
return base64.encode(
return base64.encodeBase64(
`VsHistoryDetail-asdf:asdf:${date}T${
id.toString().padStart(6, "0")
}_------------------------------------`,

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,66 @@ 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"),
},
{
id: "season202403",
name: "Fresh Season 2024",
start: new Date("2024-03-01T00:00:00+00:00"),
end: new Date("2024-06-01T00:00:00+00:00"),
},
{
id: "season202406",
name: "Sizzle Season 2024",
start: new Date("2024-06-01T00:00:00+00:00"),
end: new Date("2024-09-01T00:00:00+00:00"),
},
{
id: "season202409",
name: "Drizzle Season 2024",
start: new Date("2024-09-01T00:00:00+00:00"),
end: new Date("2024-12-01T00:00:00+00:00"),
},
{
id: "season202412",
name: "Chill Season 2024",
start: new Date("2024-12-01T00:00:00+00:00"),
end: new Date("2025-03-01T00:00:00+00:00"),
},
{
id: "season202503",
name: "Fresh Season 2025",
start: new Date("2025-03-01T00:00:00+00:00"),
end: new Date("2025-06-01T00:00:00+00:00"),
},
{
id: "season202506",
name: "Sizzle Season 2025",
start: new Date("2025-06-01T00:00:00+00:00"),
end: new Date("2025-09-01T00:00:00+00:00"),
},
{
id: "season202509",
name: "Drizzle Season 2025",
start: new Date("2025-09-01T00:00:00+00:00"),
end: new Date("2025-12-01T00:00:00+00:00"),
},
{
id: "season202512",
name: "Chill Season 2025",
start: new Date("2025-12-01T00:00:00+00:00"),
end: new Date("2026-03-01T00:00:00+00:00"),
},
];
export const getSeason = (date: Date): Season | undefined => {

View File

@ -1,14 +1,17 @@
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";
import { delay, showError } from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
import { SPLATOON3_TITLE_ID } from "./constant.ts";
import { USERAGENT } from "./constant.ts";
export type Opts = {
profilePath: string;
@ -17,9 +20,11 @@ export type Opts = {
monitor: boolean;
withSummary: boolean;
skipMode?: string;
listMethod?: string;
cache?: Cache;
stateBackend?: StateBackend;
env: Env;
nxapiPresenceUrl?: string;
};
export const DEFAULT_OPTS: Opts = {
@ -28,6 +33,7 @@ export const DEFAULT_OPTS: Opts = {
noProgress: false,
monitor: false,
withSummary: false,
listMethod: "auto",
env: DEFAULT_ENV,
};
@ -52,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,
@ -63,6 +166,7 @@ function progress({ total, currentUrl, done }: StepProgress): Progress {
export class App {
profile: Profile;
env: Env;
splatoon3PreviouslyActive = false;
constructor(public opts: Opts) {
const stateBackend = opts.stateBackend ??
@ -72,6 +176,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")[] {
@ -115,6 +225,29 @@ export class App {
out.push(new FileExporter(state.fileExportPath));
}
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({
splashcatApiKey: this.profile.state.splashcatApiKey!,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
env: this.env,
}),
);
}
return out;
}
exporterProgress(title: string) {
@ -142,11 +275,13 @@ export class App {
);
}
};
const endBar = () => {
bar?.end();
};
const end = () => bar?.end();
return { redraw, endBar };
return {
redraw,
end,
[Symbol.dispose]: end,
};
}
private async exportOnce() {
const splatnet = new Splatnet3({ profile: this.profile, env: this.env });
@ -162,10 +297,12 @@ 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");
using bar1 = this.exporterProgress("Export vs games");
const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.profile.state,
@ -182,10 +319,10 @@ export class App {
type: "VsInfo",
fetcher,
exporter: e,
gameList,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
bar1.redraw(e.name, progress(stats[e.name]));
},
}),
)
@ -196,7 +333,7 @@ export class App {
),
);
endBar();
await bar1.end();
this.printStats(stats);
if (errors.length > 0) {
@ -216,12 +353,9 @@ 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");
using bar2 = this.exporterProgress("Export coop games");
const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.profile.state,
@ -236,10 +370,10 @@ export class App {
type: "CoopInfo",
fetcher,
exporter: e,
gameList: coopBattleList,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
bar2.redraw(e.name, progress(stats[e.name]));
},
}),
)
@ -250,7 +384,7 @@ export class App {
),
);
endBar();
await bar2.end();
this.printStats(stats);
if (errors.length > 0) {
@ -291,6 +425,36 @@ export class App {
}
}
}
async monitorWithNxapi() {
this.env.logger.debug("Monitoring with nxapi presence");
const fetcher = this.env.newFetcher();
await this.exportOnce();
while (true) {
await this.countDown(this.profile.state.monitorInterval);
const nxapiResponse = await fetcher.get({
url: this.opts.nxapiPresenceUrl!,
headers: {
"User-Agent": USERAGENT,
},
});
const nxapiData = await nxapiResponse.json();
const isSplatoon3Active = nxapiData.title?.id === SPLATOON3_TITLE_ID;
if (isSplatoon3Active || this.splatoon3PreviouslyActive) {
this.env.logger.log("Splatoon 3 is active, exporting data");
await this.exportOnce();
}
if (isSplatoon3Active !== this.splatoon3PreviouslyActive) {
this.env.logger.debug(
"Splatoon 3 status has changed from",
this.splatoon3PreviouslyActive,
"to",
isSplatoon3Active,
);
}
this.splatoon3PreviouslyActive = isSplatoon3Active;
}
}
async monitor() {
while (true) {
await this.exportOnce();
@ -304,6 +468,7 @@ export class App {
display: "[:bar] :completed/:total",
})
: undefined;
try {
for (const i of Array(sec).keys()) {
bar?.render([{
completed: i,
@ -311,7 +476,9 @@ export class App {
}]);
await delay(1000);
}
bar?.end();
} finally {
await bar?.end();
}
}
async run() {
await this.profile.readState();
@ -328,7 +495,9 @@ export class App {
});
}
if (this.opts.monitor) {
if (this.opts.nxapiPresenceUrl) {
await this.monitorWithNxapi();
} else if (this.opts.monitor) {
await this.monitor();
} else {
await this.exportOnce();
@ -342,30 +511,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);
@ -402,7 +565,7 @@ export class App {
}
printStats(stats: Record<string, StepProgress>) {
this.env.logger.log(
`Exported ${
`\nExported ${
Object.entries(stats)
.map(([name, { exported }]) => `${name}: ${exported}`)
.join(", ")

View File

@ -1,32 +1,46 @@
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "s3si.ts";
export const S3SI_VERSION = "0.4.1";
export const NSOAPP_VERSION = "2.5.1";
export const WEB_VIEW_VERSION = "4.0.0-d5178440";
export const S3SI_VERSION = "0.4.20";
export const NSOAPP_VERSION = "2.10.1";
export const WEB_VIEW_VERSION = "6.0.0-9f87c815";
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 =
"94faa2ff992222d11ced55e0f349920a82ac50f414ae33c83d1d1c9d8161c5dd",
CoopHistoryQuery =
"e11a8cf2c3de7348495dea5cdcaa25e0c153541c4ed63f044b6c174bc5b703df",
CoopHistoryDetailQuery =
"f2d55873a9281213ae27edc171e2b19131b3021a2ae263757543cdd3bf015cc8",
myOutfitCommonDataFilteringConditionQuery =
"d02ab22c9dccc440076055c8baa0fa7a",
myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",
HistoryRecordQuery = "d9246baf077b2a29b5f7aac321810a77",
ConfigureAnalyticsQuery = "f8ae00773cc412a50dd41a6d9a159ddd",
"ac20c44a952131cb0c9d00eda7bc1a84c1a99546f0f1fc170212d5a6bb51a426",
myOutfitCommonDataEquipmentsQuery =
"45a4c343d973864f7bb9e9efac404182be1d48cf2181619505e9b7cd3b56a6e8",
HistoryRecordQuery =
"a654ecc80161a7ca5c38761c1d9e502d405eae764e2d343618b9c74b1dc0a80f",
ConfigureAnalyticsQuery =
"2a9302bdd09a13f8b344642d4ed483b9464f20889ac17401e993dfa5c2bb3607",
}
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts";
export const USERAGENT = `${AGENT_NAME}/${S3SI_VERSION} (${S3SI_LINK})`;
export const DEFAULT_APP_USER_AGENT =
"Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
"Mozilla/5.0 (Linux; Android 14; Pixel 7a) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/94.0.4606.61 Mobile Safari/537.36";
"Chrome/120.0.6099.230 Mobile Safari/537.36";
export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net";
export const SPLATNET3_ENDPOINT =
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
@ -91,6 +105,10 @@ export const SPLATNET3_STATINK_MAP: {
"sameride",
"380e541b5bc5e49d77ff1a616f1343aeba01d500fee36aaddf8f09d74bd3d3bc":
"tripletornado",
"8a7ee88a06407f4be1595ef8af4d2d2ac22bbf213a622cd19bbfaf4d0f36bcd7":
"teioika",
"a75eac34675bc0d4bd9ca9977cf22472848f89e28e08ee986b4461a3f2af28fc":
"ultra_chakuchi",
},
WATER_LEVEL_MAP: {
0: "low",
@ -98,3 +116,5 @@ export const SPLATNET3_STATINK_MAP: {
2: "high",
},
};
export const SPLATOON3_TITLE_ID = "0100c2500fc20000";

View File

@ -126,8 +126,8 @@ if (import.meta.main) {
const service = new S3SIServiceImplement();
const server = new JSONRPCServer({
transport: new DenoIO({
reader: Deno.stdin,
writer: Deno.stdout,
reader: Deno.stdin.readable,
writer: Deno.stdout.writable,
}),
service,
});

View File

@ -0,0 +1,178 @@
export interface SplashcatUpload {
battle: SplashcatBattle;
data_type: "splashcat";
uploader_agent: {
name: string; // max of 32 characters
version: string; // max of 50 characters
extra: string; // max of 100 characters. displayed as a string at the bottom of battle details. useful for debug info such as manual/monitoring modes
};
}
/**
* 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: string;
splatfest?: Splatfest;
/**
* base64 decoded and split by `:` to get the last section
*/
splatnetId: string;
teams: Team[];
vsMode: VsMode;
vsRule: VsRule;
vsStageId: number;
xBattle?: XBattle;
}
export interface Anarchy {
mode?: AnarchyMode;
pointChange?: number;
points?: number;
power?: number;
rank?: Rank;
sPlusNumber?: number;
}
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;
}
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;
}
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;
}
export interface Color {
a: number;
b: number;
g: number;
r: number;
}
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;
}
/**
* A piece of gear. Use en-US locale for name and all abilities.
*/
export interface Gear {
name?: string;
primaryAbility?: string;
secondaryAbilities?: string[];
}
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;
}
export interface SplashcatRecentBattleIds {
battle_ids: string[];
}

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

@ -0,0 +1,359 @@
import { AGENT_NAME, S3SI_VERSION, 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 { Env } from "../env.ts";
import {
Gear,
Player,
Rank,
SplashcatBattle,
SplashcatRecentBattleIds,
Team,
TeamJudgement,
} from "./splashcat-types.ts";
import { SplashcatUpload } from "./splashcat-types.ts";
async function checkResponse(resp: Response) {
// 200~299
if (Math.floor(resp.status / 100) !== 2) {
const json = await resp.json().catch(() => undefined);
throw new APIError({
response: resp,
json,
message: "Failed to fetch data from stat.ink",
});
}
}
class SplashcatAPI {
splashcat = "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}`,
"Fly-Prefer-Region": "iad",
};
}
async uuidList(): Promise<string[]> {
const fetch = this.env.newFetcher();
const response = await fetch.get({
url: `${this.splashcat}/battles/api/recent/`,
headers: this.requestHeaders(),
});
await checkResponse(response);
const recentBattlesData: SplashcatRecentBattleIds = await response.json();
const recentBattleIds = recentBattlesData.battle_ids;
if (!Array.isArray(recentBattleIds)) {
throw new APIError({
response,
json: recentBattlesData,
});
}
return recentBattleIds;
}
async postBattle(body: SplashcatUpload) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.splashcat}/battles/api/upload/`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json = await resp.json().catch(() => ({}));
if (resp.status !== 200) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
async _getCached<T>(url: string): Promise<T> {
const release = await this.FETCH_LOCK.acquire();
try {
if (this.cache[url]) {
return this.cache[url] as T;
}
const fetch = this.env.newFetcher();
const resp = await fetch.get({
url,
headers: this.requestHeaders(),
});
await checkResponse(resp);
const json = await resp.json();
this.cache[url] = json;
return json;
} finally {
release();
}
}
}
export type NameDict = {
gearPower: Record<string, number | undefined>;
};
/**
* Exporter to Splashcat.
*/
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 battle = await this.mapBattle(game);
const body: SplashcatUpload = {
battle,
data_type: "splashcat",
uploader_agent: {
name: AGENT_NAME,
version: S3SI_VERSION,
extra: `Upload Mode: ${this.uploadMode}`,
},
};
const resp = await this.api.postBattle(body);
return {
status: "success",
url: resp.battle_id
? `https://splashcat.ink/battles/${resp.battle_id}/`
: undefined,
};
} else {
return {
status: "skip",
reason: "Splashcat does not support Salmon Run",
};
}
}
static getGameId(id: string) {
const plainText = new TextDecoder().decode(base64.decodeBase64(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 gameId = SplashcatExporter.getGameId(id)!;
if (
!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.decodeBase64(i.id)).split("-")[1],
)
: null
),
splashtagBackgroundId: Number(
new TextDecoder().decode(
base64.decodeBase64((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.decodeBase64(player.id)).split(
":",
).at(
-1,
)!,
paint: player.paint,
species: player.species,
weaponId: Number(
new TextDecoder().decode(base64.decodeBase64(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;
};
mapBattle(
{
detail: vsDetail,
rankState,
}: VsInfo,
): SplashcatBattle {
const {
myTeam,
otherTeams,
} = vsDetail;
const self = 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: 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.decodeBase64(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: rank as 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.decodeBase64(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,
festStreakWinCount: team.festStreakWinCount,
festTeamName: team.festTeamName ?? undefined,
festUniformBonusRate: team.festUniformBonusRate,
festUniformName: team.festUniformName,
noroshi: team.result?.noroshi ?? undefined,
paintRatio: team.result?.paintRatio ?? undefined,
score: team.result?.score ?? undefined,
tricolorRole: team.tricolorRole ?? undefined,
};
result.teams.push(teamResult);
}
return result;
}
mapColor(color: Color): string | undefined {
const float2hex = (i: number) =>
Math.round(i * 255).toString(16).padStart(2, "0");
// rgba
const numbers = [color.r, color.g, color.b, color.a];
return numbers.map(float2hex).join("");
}
mapGear(gear: PlayerGear): Gear {
return {
name: gear.name,
primaryAbility: gear.primaryGearPower.name,
secondaryAbilities: gear.additionalGearPowers.map((i) => i.name),
};
}
}

View File

@ -40,7 +40,7 @@ import {
urlSimplify,
} from "../utils.ts";
import { Env } from "../env.ts";
import GEAR_MAP from "../assets/gear-map.json" assert { type: "json" };
import GEAR_MAP from "../assets/gear-map.json" with { type: "json" };
const COOP_POINT_MAP: Record<number, number | undefined> = {
0: -20,
@ -365,7 +365,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 {
@ -393,6 +393,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) {
@ -403,6 +405,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(
@ -587,6 +596,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;
@ -618,16 +629,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(
@ -697,6 +710,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

@ -140,7 +140,8 @@ export async function getGToken(
"Content-Type": "application/json",
"Accept": "application/json",
"Connection": "Keep-Alive",
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 7.1.2)",
"User-Agent":
"Dalvik/2.1.0 (Linux; U; Android 14; Pixel 7a Build/UQ1A.240105.004)",
},
body: JSON.stringify({
"client_id": "71b963c1b7b6d119",
@ -194,7 +195,7 @@ export async function getGToken(
"Content-Type": "application/json; charset=utf-8",
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip",
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`,
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/14)`,
},
body: JSON.stringify({
parameter: {
@ -211,22 +212,24 @@ export async function getGToken(
);
const respJson = await resp.json();
const idToken2: string = respJson?.result?.webApiServerCredential
const idToken2: string | undefined = respJson?.result
?.webApiServerCredential
?.accessToken;
const coralUserId: number = respJson?.result?.user?.id;
const coralUserId: string | undefined = 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,
@ -244,7 +247,7 @@ export async function getGToken(
"Authorization": `Bearer ${idToken}`,
"Content-Type": "application/json; charset=utf-8",
"Accept-Encoding": "gzip",
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`,
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/14)`,
},
body: JSON.stringify({
parameter: {
@ -414,7 +417,7 @@ async function callImink(
step: number;
idToken: string;
userId: string;
coralUserId?: number;
coralUserId?: string;
env: Env;
},
): Promise<IminkResponse> {
@ -425,6 +428,8 @@ async function callImink(
headers: {
"User-Agent": USERAGENT,
"Content-Type": "application/json",
"X-znca-Platform": "Android",
"X-znca-Version": NSOAPP_VERSION,
},
body: JSON.stringify({
"token": idToken,

View File

@ -1 +0,0 @@
export { IPC } from "./stdio.ts";

View File

@ -1,40 +0,0 @@
/// <reference lib="deno.ns" />
import { io, writeAll } from "../../deps.ts";
import type { ExtractType } from "./types.ts";
export class IPC<T extends { type: string }> {
lines: AsyncIterableIterator<string>;
writer: Deno.Writer;
constructor({ reader, writer }: {
reader: Deno.Reader;
writer: Deno.Writer;
}) {
this.lines = io.readLines(reader);
this.writer = writer;
}
async recvType<K extends T["type"]>(
type: K,
): Promise<ExtractType<T, K>> {
const data = await this.recv();
if (data.type !== type) {
throw new Error(`Unexpected type: ${data.type}`);
}
return data as ExtractType<T, K>;
}
async recv(): Promise<T> {
const result = await this.lines.next();
if (!result.done) {
return JSON.parse(result.value);
}
throw new Error("EOF");
}
async send(data: T) {
await writeAll(
this.writer,
new TextEncoder().encode(JSON.stringify(data) + "\n"),
);
}
}

View File

@ -1,10 +0,0 @@
export type Command = {
type: "hello";
data: string;
};
export type ExtractType<T extends { type: string }, K extends T["type"]> =
Extract<
T,
{ type: K }
>;

View File

@ -1,15 +1,15 @@
import { io, writeAll } from "../../deps.ts";
import { readLines } from "../utils.ts";
import { Transport } from "./types.ts";
export class DenoIO implements Transport {
lines: AsyncIterableIterator<string>;
writer: Deno.Writer & Deno.Closer;
writer: WritableStreamDefaultWriter<Uint8Array>;
constructor({ reader, writer }: {
reader: Deno.Reader;
writer: Deno.Writer & Deno.Closer;
reader: ReadableStream<Uint8Array>;
writer: WritableStream<Uint8Array>;
}) {
this.lines = io.readLines(reader);
this.writer = writer;
this.lines = readLines(reader);
this.writer = writer.getWriter();
}
async recv(): Promise<string | undefined> {
const result = await this.lines.next();
@ -21,10 +21,8 @@ export class DenoIO implements Transport {
return undefined;
}
async send(data: string) {
await writeAll(
this.writer,
new TextEncoder().encode(data + "\n"),
);
await this.writer.ready;
await this.writer.write(new TextEncoder().encode(data + "\n"));
}
async close() {
await this.writer.close();

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

@ -30,6 +30,7 @@ export type State = {
statInkApiKey?: string;
fileExportPath: string;
monitorInterval: number;
splashcatApiKey?: string;
};
export const DEFAULT_STATE: State = {

View File

@ -8,6 +8,7 @@ export type VarsMap = {
[Queries.RegularBattleHistoriesQuery]: [];
[Queries.BankaraBattleHistoriesQuery]: [];
[Queries.XBattleHistoriesQuery]: [];
[Queries.EventBattleHistoriesQuery]: [];
[Queries.PrivateBattleHistoriesQuery]: [];
[Queries.VsHistoryDetailQuery]: [{
vsResultId: string;
@ -128,6 +129,7 @@ export type PlayerWeapon = {
};
};
export type VsPlayer = {
nameplate: Nameplate;
id: string;
nameId: string | null;
name: string;
@ -144,6 +146,7 @@ export type VsPlayer = {
} | null;
paint: number;
crown: boolean;
festDragonCert: "NONE" | "DRAGON" | "DOUBLE_DRAGON";
headGear: PlayerGear;
clothingGear: PlayerGear;
@ -156,6 +159,11 @@ export type Color = {
r: number;
};
export type VsTeam = {
festUniformName?: string;
festStreakWinCount?: number;
festUniformBonusRate?: number;
order: number;
judgement: string;
players: VsPlayer[];
color: Color;
tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2";
@ -163,6 +171,7 @@ export type VsTeam = {
result: null | {
paintRatio: null | number;
score: null | number;
noroshi: null | number;
};
};
export type VsRule =
@ -235,6 +244,9 @@ export type VsHistoryDetail = {
bankaraMatch: {
earnedUdemaePoint: null | number;
mode: "OPEN" | "CHALLENGE";
bankaraPower?: null | {
power?: null | number;
};
} | null;
festMatch: {
dragonMatchType: "NORMAL" | "DECUPLE" | "DRAGON" | "DOUBLE_DRAGON";
@ -266,6 +278,8 @@ export type CoopHistoryPlayerResult = {
name: string;
id: string;
};
isMyself: boolean;
species: "INKLING" | "OCTOLING";
};
weapons: { name: string; image: Image | null }[];
specialWeapon: null | {
@ -415,6 +429,11 @@ export type RespMap = {
};
[Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
[Queries.XBattleHistoriesQuery]: XBattleHistories;
[Queries.EventBattleHistoriesQuery]: {
eventBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
};
};
[Queries.PrivateBattleHistoriesQuery]: {
privateBattleHistories: {
historyGroups: HistoryGroups<BattleListNode>;
@ -601,10 +620,14 @@ export enum BattleListType {
Latest,
Regular,
Bankara,
Event,
XBattle,
Private,
Coop,
}
export type ListMethod = "latest" | "all" | "auto";
export type StatInkUuidList = {
status: number;
code: number;
@ -624,7 +647,7 @@ export type StatInkWeapon = {
}[];
export type StatInkGear = {
primary_ability: string;
primary_ability: string | null;
secondary_abilities: (string | null)[];
};
@ -650,7 +673,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 = {
@ -700,6 +725,7 @@ export type StatInkCoopPlayer = {
rescued: number;
defeat_boss: number;
disconnected: "yes" | "no";
species: "inkling" | "octoling";
};
export type StatInkCoopBoss = {
@ -809,6 +835,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

@ -9,12 +9,12 @@ const COOP_ID =
Deno.test("gameId", async () => {
assertEquals(
await gameId(base64.encode(VS_ID)),
await gameId(base64.encodeBase64(VS_ID)),
"042bcac9-6b25-5d2e-a5ea-800939a6dea1",
);
assertEquals(
await gameId(base64.encode(COOP_ID)),
await gameId(base64.encodeBase64(COOP_ID)),
"58329d62-737d-5b43-ac22-e35e6e44b077",
);
});
@ -22,7 +22,7 @@ Deno.test("gameId", async () => {
Deno.test("s3sCoopGameId", async () => {
const S3S_COOP_UUID = "be4435b1-0ac5-577b-81bb-766585bec028";
assertEquals(
await s3sCoopGameId(base64.encode(COOP_ID)),
await s3sCoopGameId(base64.encodeBase64(COOP_ID)),
S3S_COOP_UUID,
);
});

View File

@ -6,29 +6,53 @@ import {
} from "./constant.ts";
import { base64, uuid } from "../deps.ts";
import { Env } from "./env.ts";
import { io } from "../deps.ts";
const stdinLines = io.readLines(Deno.stdin);
export async function* readLines(readable: ReadableStream<Uint8Array>) {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of readable) {
buffer += decoder.decode(chunk, { stream: true });
let lineEndIndex;
while ((lineEndIndex = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, lineEndIndex).trim();
buffer = buffer.slice(lineEndIndex + 1);
yield line;
}
}
if (buffer.length > 0) {
yield buffer;
}
}
const stdinLines = readLines(Deno.stdin.readable);
export async function readline(
{ skipEmpty = true }: { skipEmpty?: boolean } = {},
) {
for await (const line of stdinLines) {
while (true) {
const result = await stdinLines.next();
if (result.done) {
throw new Error("EOF");
}
const line = result.value;
if (!skipEmpty || line !== "") {
return line;
}
}
throw new Error("EOF");
}
export function urlBase64Encode(data: ArrayBuffer) {
return base64.encode(data)
return base64.encodeBase64(data)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
export function urlBase64Decode(data: string) {
return base64.decode(
return base64.encodeBase64(
data
.replaceAll("_", "/")
.replaceAll("-", "+"),
@ -103,14 +127,14 @@ export function gameId(
);
return uuid.v5.generate(BATTLE_NAMESPACE, content);
} else if (parsed.type === "CoopHistoryDetail") {
return uuid.v5.generate(COOP_NAMESPACE, base64.decode(id));
return uuid.v5.generate(COOP_NAMESPACE, base64.decodeBase64(id));
} else {
throw new Error("Unknown type");
}
}
export function s3siGameId(id: string) {
const fullId = base64.decode(id);
const fullId = base64.decodeBase64(id);
const tsUuid = fullId.slice(fullId.length - 52, fullId.length);
return uuid.v5.generate(S3SI_NAMESPACE, tsUuid);
}
@ -122,7 +146,7 @@ export function s3siGameId(id: string) {
* @returns uuid used in stat.ink
*/
export function s3sCoopGameId(id: string) {
const fullId = base64.decode(id);
const fullId = base64.decodeBase64(id);
const tsUuid = fullId.slice(fullId.length - 52, fullId.length);
return uuid.v5.generate(COOP_NAMESPACE, tsUuid);
}
@ -131,7 +155,7 @@ export function s3sCoopGameId(id: string) {
* @param id VsHistoryDetail id or CoopHistoryDetail id
*/
export function parseHistoryDetailId(id: string) {
const plainText = new TextDecoder().decode(base64.decode(id));
const plainText = new TextDecoder().decode(base64.decodeBase64(id));
const vsRE =
/VsHistoryDetail-([a-z0-9-]+):(\w+):(\d{8}T\d{6})_([0-9a-f-]{36})/;
@ -167,7 +191,7 @@ export const delay = (ms: number) =>
* Decode ID and get number after '-'
*/
export function b64Number(id: string): number {
const text = new TextDecoder().decode(base64.decode(id));
const text = new TextDecoder().decode(base64.decodeBase64(id));
const [_, num] = text.split("-");
return parseInt(num);
}
@ -188,3 +212,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);
};