Compare commits

..

11 Commits

12 changed files with 848 additions and 517 deletions

View File

@ -1,3 +1,18 @@
## 0.4.1
feat: add support for Challenges
([#72](https://github.com/spacemeowx2/s3si.ts/issues/72))
## 0.4.0
feat: update `callImink`
feat: update VersionData
## 0.3.6
feat: update `WEB_VIEW_VERSION` and query hashes for 4.0.0
## 0.3.5
fix: wrong ability keys in some languages

View File

@ -11,34 +11,34 @@
"lint": "eslint --max-warnings=0 src"
},
"dependencies": {
"@tauri-apps/api": "^1.2.0",
"@tauri-apps/api": "^1.3.0",
"classnames": "^2.3.2",
"daisyui": "^2.51.3",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"daisyui": "^2.52.0",
"i18next": "^22.5.0",
"i18next-browser-languagedetector": "^7.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.2.0",
"react-icons": "^4.8.0",
"react-router-dom": "^6.8.2",
"react-i18next": "^12.3.1",
"react-icons": "^4.9.0",
"react-router-dom": "^6.11.2",
"react-use": "^17.4.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.3",
"@types/node": "^18.14.5",
"@tauri-apps/cli": "^1.3.1",
"@types/node": "^20.2.5",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.35.0",
"@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.1.1",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7",
"typescript": "^4.9.5",
"vite": "^4.1.4",
"i18next-http-backend": "^2.2.1",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.4",
"vite": "^4.3.9",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.0.5"
"vite-tsconfig-paths": "^4.2.0"
},
"eslintConfig": {
"extends": "react-app"

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "s3si-ts",
"version": "0.3.5"
"version": "0.4.1"
},
"tauri": {
"allowlist": {

View File

@ -9,22 +9,19 @@ if (import.meta.main) {
"x86_64-apple-darwin",
"aarch64-apple-darwin",
];
const rustInfo = new TextDecoder().decode(
await Deno.run({
cmd: ["rustc", "-Vv"],
stdout: "piped",
}).output(),
);
const target = /host: (\S+)/g.exec(rustInfo)?.[1] ?? "?";
const rustInfo = await (new Deno.Command("rustc", {
args: ["-Vv"],
})).output();
const target =
/host: (\S+)/g.exec(new TextDecoder().decode(rustInfo.stdout))?.[1] ?? "?";
if (!TARGETS.includes(target)) {
console.error(`Unsupported target: ${target}`);
Deno.exit(1);
}
const p = Deno.run({
cmd: [
"deno",
const p = new Deno.Command("deno", {
args: [
"compile",
"--target",
target,
@ -35,7 +32,7 @@ if (import.meta.main) {
],
cwd: __dirname,
});
const status = await p.status();
const status = await p.output();
if (!status.success) {
console.error(
"Failed to run deno compile for target",
@ -50,18 +47,21 @@ if (import.meta.main) {
Deno.build.os === "windows" ? ".exe" : ""
}`;
console.log("Test the binary");
const s3si = Deno.run({
cmd: [binPath],
const s3si = new Deno.Command(binPath, {
stdin: "piped",
stdout: "piped",
});
await s3si.stdin?.write(
}).spawn();
const s3siWriter = s3si.stdin.getWriter();
await s3siWriter.write(
new TextEncoder().encode(
'{"jsonrpc":"2.0","method":"hello","params":[],"id":1}\n',
),
);
s3si.stdin?.close();
const output = new TextDecoder().decode(await s3si.output());
const output = new TextDecoder().decode(
(await s3si.stdout.getReader().read()).value,
);
await s3siWriter.close();
assertEquals(
output,

View File

@ -30,6 +30,62 @@ function getConst(content: string, name: string): string {
return JSON.parse(match[1]);
}
function replaceEnum(
content: string,
name: string,
pairs: Record<string, string>,
): string {
const regex = new RegExp(`export enum ${name} {([\\s\\S^}]+?)}`);
const body = Object.entries(pairs).map(([key, value]) =>
` ${key} = "${value}"`
).join(",\n");
return content.replace(regex, `export enum ${name} {\n${body}\n}`);
}
function getEnumKeys(content: string, name: string): string[] {
const regex = new RegExp(`export enum ${name} {([\\s\\S^}]+?)}`);
const match = regex.exec(content);
if (!match) {
throw new Error(`Cannot find ${name}`);
}
const body = match[1];
// extract keys from `key = "value"`
const keys: string[] = [];
const keyRE = /\s*(\w+)\s*=/g;
while (true) {
const match = keyRE.exec(body);
if (!match) {
break;
}
keys.push(match[1]);
}
return keys;
}
function getQueryHash(js: string, query: string): string {
const regex = new RegExp(
`params:\\{id:"([^"]*?)",metadata:{},name:"${query}"`,
);
const match = regex.exec(js);
if (!match) {
throw new Error(`Cannot find ${query}`);
}
if (match[0].length > 500) {
throw new Error(`Match too large ${match[0].length}`);
}
return match[1];
}
async function printError<T>(p: Promise<T>): Promise<T | undefined> {
try {
return await p;
@ -39,7 +95,7 @@ async function printError<T>(p: Promise<T>): Promise<T | undefined> {
}
}
async function getWebViewVer(): Promise<string> {
async function getMainJSBody(): Promise<string> {
const splatnet3Home = await (await fetch(SPLATNET3_URL)).text();
const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1];
@ -50,9 +106,16 @@ async function getWebViewVer(): Promise<string> {
const mainJSBody = await (await fetch(SPLATNET3_URL + mainJS)).text();
const revision = /"([0-9a-f]{40})"/.exec(mainJSBody)?.[1];
return mainJSBody;
}
const mainJSBody = await getMainJSBody();
// deno-lint-ignore require-await
async function getWebViewVer(js: string): Promise<string> {
const revision = /"([0-9a-f]{40})"/.exec(js)?.[1];
const version = /revision_info_not_set.*?=("|`)(\d+\.\d+\.\d+)-/.exec(
mainJSBody,
js,
)
?.[2];
@ -83,7 +146,7 @@ const oldValues = {
};
const newValues: Record<string, string | undefined> = {};
newValues.WEB_VIEW_VERSION = await printError(getWebViewVer());
newValues.WEB_VIEW_VERSION = await printError(getWebViewVer(mainJSBody));
newValues.NSOAPP_VERSION = await printError(getNSOVer());
for (const [key, value] of Object.entries(newValues)) {
@ -91,8 +154,27 @@ for (const [key, value] of Object.entries(newValues)) {
content = replaceConst(content, key, value);
}
}
await Deno.writeTextFile(CONSTANT_PATH, content);
console.log("Done");
console.log("const updated");
console.log("Old:", oldValues);
console.log("New:", newValues);
const keys = getEnumKeys(content, "Queries");
const pairs = Object.fromEntries(
keys.map((key) => [key, getQueryHash(mainJSBody, key)]),
);
content = replaceEnum(content, "Queries", pairs);
console.log("query updated");
await Deno.writeTextFile(CONSTANT_PATH, content);
const command = new Deno.Command(Deno.execPath(), {
args: ["fmt", "./src/constant.ts"],
cwd: ROOT_DIR,
stdin: "inherit",
stdout: "inherit",
});
const { code } = command.outputSync();
if (code !== 0) {
Deno.exit(code);
}

View File

@ -26,6 +26,10 @@ Deno.test("getSeason", () => {
assertEquals(season3?.id, "season202303");
const season4 = getSeason(new Date("2023-06-01T00:00:00+00:00"));
assertEquals(season4?.id, "season202306");
const nonExist = getSeason(new Date("2022-06-09T00:00:00+00:00"));
assertEquals(nonExist, undefined);

View File

@ -24,6 +24,12 @@ export const SEASONS: Season[] = [
start: new Date("2023-03-01T00:00:00+00:00"),
end: new Date("2023-06-01T00:00:00+00:00"),
},
{
id: "season202306",
name: "Sizzle Season 2023",
start: new Date("2023-06-01T00:00:00+00:00"),
end: new Date("2023-09-01T00:00:00+00:00"),
},
];
export const getSeason = (date: Date): Season | undefined => {

View File

@ -2,10 +2,26 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "splashcat / s3si.ts";
export const AGENT_VERSION = "1.1.1";
export const S3SI_VERSION = "0.3.5";
export const S3SI_VERSION = "0.4.1";
export const COMBINED_VERSION = `${AGENT_VERSION}/${S3SI_VERSION}`;
export const NSOAPP_VERSION = "2.5.1";
export const WEB_VIEW_VERSION = "3.0.0-0742bda0";
export const WEB_VIEW_VERSION = "4.0.0-d5178440";
export enum Queries {
HomeQuery = "7dcc64ea27a08e70919893a0d3f70871",
LatestBattleHistoriesQuery = "0d90c7576f1916469b2ae69f64292c02",
RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de",
BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803",
XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5",
PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28",
VsHistoryDetailQuery = "9ee0099fbe3d8db2a838a75cf42856dd",
CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15",
CoopHistoryDetailQuery = "379f0d9b78b531be53044bcac031b34b",
myOutfitCommonDataFilteringConditionQuery =
"d02ab22c9dccc440076055c8baa0fa7a",
myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",
HistoryRecordQuery = "d9246baf077b2a29b5f7aac321810a77",
ConfigureAnalyticsQuery = "f8ae00773cc412a50dd41a6d9a159ddd",
}
export const S3SI_LINK = "https://forgejo.catgirlin.space/catgirl/s3si.ts";
export const USERAGENT = `${AGENT_NAME}/(${COMBINED_VERSION}) (${S3SI_LINK})`;

View File

@ -330,6 +330,8 @@ export class StatInkExporter implements GameExporter {
}
} else if (vsMode === "X_MATCH") {
return "xmatch";
} else if (vsMode === "LEAGUE") {
return "event";
}
throw new TypeError(`Unknown vsMode ${vsMode}`);
@ -421,6 +423,7 @@ export class StatInkExporter implements GameExporter {
myTeam,
otherTeams,
bankaraMatch,
leagueMatch,
festMatch,
playedTime,
} = vsDetail;
@ -563,6 +566,10 @@ export class StatInkExporter implements GameExporter {
result.rank_after_s_plus = result.rank_before_s_plus;
}
}
if (leagueMatch) {
result.event = leagueMatch.leagueMatchEvent?.id;
result.event_power = leagueMatch.myLeaguePower;
}
if (challengeProgress) {
result.challenge_win = challengeProgress.winCount;

View File

@ -175,13 +175,14 @@ export async function getGToken(
},
);
const uiRespJson = await uiResp.json();
const { nickname, birthday, language, country } = uiRespJson;
const { nickname, birthday, language, country, id: userId } = uiRespJson;
const getIdToken2 = async (idToken: string) => {
const { f, request_id: requestId, timestamp } = await callImink({
fApi,
step: 1,
idToken,
userId,
env,
});
const resp = await fetch.post(
@ -210,23 +211,28 @@ export async function getGToken(
);
const respJson = await resp.json();
const idToken2 = respJson?.result?.webApiServerCredential?.accessToken;
const idToken2: string = respJson?.result?.webApiServerCredential
?.accessToken;
const coralUserId: number = respJson?.result?.user?.id;
if (!idToken2) {
if (!idToken2 || !coralUserId) {
throw new APIError({
response: resp,
json: respJson,
message: "No idToken2 found",
message:
`No idToken2 or coralUserId found. Please try again later. ('${idToken2}', '${coralUserId}')`,
});
}
return idToken2 as string;
return [idToken2, coralUserId] as const;
};
const getGToken = async (idToken: string) => {
const getGToken = async (idToken: string, coralUserId: number) => {
const { f, request_id: requestId, timestamp } = await callImink({
step: 2,
idToken,
fApi,
userId,
coralUserId,
env,
});
const resp = await fetch.post(
@ -266,8 +272,8 @@ export async function getGToken(
return webServiceToken as string;
};
const idToken2 = await retry(() => getIdToken2(idToken));
const webServiceToken = await retry(() => getGToken(idToken2));
const [idToken2, coralUserId] = await retry(() => getIdToken2(idToken));
const webServiceToken = await retry(() => getGToken(idToken2, coralUserId));
return {
webServiceToken,
@ -403,13 +409,16 @@ type IminkResponse = {
timestamp: number;
};
async function callImink(
{ fApi, step, idToken, env }: {
params: {
fApi: string;
step: number;
idToken: string;
userId: string;
coralUserId?: number;
env: Env;
},
): Promise<IminkResponse> {
const { fApi, step, idToken, userId, coralUserId, env } = params;
const { post } = env.newFetcher();
const resp = await post({
url: fApi,
@ -420,6 +429,8 @@ async function callImink(
body: JSON.stringify({
"token": idToken,
"hash_method": step,
"na_id": userId,
"coral_user_id": coralUserId,
}),
});

View File

@ -1,23 +1,8 @@
import { splatNet3Types } from "../deps.ts";
import { RankState } from "./state.ts";
import { Queries } from "./constant.ts";
export { Queries };
export enum Queries {
HomeQuery = "22e2fa8294168003c21b00c333c35384",
LatestBattleHistoriesQuery = "0176a47218d830ee447e10af4a287b3f",
RegularBattleHistoriesQuery = "3baef04b095ad8975ea679d722bc17de",
BankaraBattleHistoriesQuery = "0438ea6978ae8bd77c5d1250f4f84803",
XBattleHistoriesQuery = "6796e3cd5dc3ebd51864dc709d899fc5",
PrivateBattleHistoriesQuery = "8e5ae78b194264a6c230e262d069bd28",
VsHistoryDetailQuery = "291295ad311b99a6288fc95a5c4cb2d2",
CoopHistoryQuery = "91b917becd2fa415890f5b47e15ffb15",
CoopHistoryDetailQuery = "379f0d9b78b531be53044bcac031b34b",
myOutfitCommonDataFilteringConditionQuery =
"d02ab22c9dccc440076055c8baa0fa7a",
myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",
HistoryRecordQuery = "f09da9d24d888797fdfb2f060dbdf4ed",
ConfigureAnalyticsQuery = "f8ae00773cc412a50dd41a6d9a159ddd",
StageRecordQuery = "f08a932d533845dde86e674e03bbb7d3",
}
export type VarsMap = {
[Queries.HomeQuery]: [];
[Queries.LatestBattleHistoriesQuery]: [];
@ -221,7 +206,13 @@ export type CoopInfo = {
};
};
export type Game = VsInfo | CoopInfo;
export type VsMode = "REGULAR" | "BANKARA" | "PRIVATE" | "FEST" | "X_MATCH";
export type VsMode =
| "REGULAR"
| "BANKARA"
| "PRIVATE"
| "FEST"
| "X_MATCH"
| "LEAGUE";
export type VsHistoryDetail = {
id: string;
vsRule: {
@ -252,6 +243,13 @@ export type VsHistoryDetail = {
contribution: number;
myFestPower: number | null;
} | null;
leagueMatch: {
leagueMatchEvent: {
"name": string;
"id": string;
} | null;
myLeaguePower: number | null;
} | null;
myTeam: VsTeam;
otherTeams: VsTeam[];
@ -770,12 +768,13 @@ export type StatInkPostBody = {
| "xmatch"
| "splatfest_challenge"
| "splatfest_open"
| "private";
| "private"
| "event";
rule: "nawabari" | "area" | "hoko" | "yagura" | "asari" | "tricolor";
stage: string;
weapon: string;
result: "win" | "lose" | "draw" | "exempted_lose";
knockout?: "yes" | "no"; // for TW, set null or not sending
knockout?: "yes" | "no" | null; // for TW, set null or not sending
rank_in_team: number; // position in scoreboard
kill?: number;
assist?: number;
@ -827,6 +826,8 @@ export type StatInkPostBody = {
clout_before?: number; // Splatfest Clout, before the battle
clout_after?: number; // Splatfest Clout, after the battle
clout_change?: number; // Splatfest Clout, equals to clout_after - clout_before if you know them
event?: string;
event_power?: number | null;
cash_before?: number;
cash_after?: number;
our_team_players: StatInkPlayer[];