diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4e3ab..0787a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.6 + +feat: add tri-color support + ## 0.2.5 feat: add crown diff --git a/scripts/export-file-to-stat-ink.ts b/scripts/export-file-to-stat-ink.ts index d3279e6..923c556 100644 --- a/scripts/export-file-to-stat-ink.ts +++ b/scripts/export-file-to-stat-ink.ts @@ -15,14 +15,15 @@ import { Game } from "../src/types.ts"; import { parseHistoryDetailId } from "../src/utils.ts"; async function exportType( - { statInkExporter, fileExporter, type, gameFetcher }: { + { statInkExporter, fileExporter, type, gameFetcher, filter }: { statInkExporter: StatInkExporter; fileExporter: FileExporter; gameFetcher: GameFetcher; type: Game["type"]; + filter?: (game: Game) => boolean; }, ) { - const gameList = await fileExporter.exportedGames({ uid, type }); + const gameList = await fileExporter.exportedGames({ uid, type, filter }); const workQueue = [ ...await statInkExporter.notExported({ @@ -32,7 +33,10 @@ async function exportType( ] .reverse().map((id) => gameList.find((i) => i.id === id)!); - console.log(`Exporting ${workQueue.length} ${type} games`); + console.log( + `Exporting ${workQueue.length} ${type} games` + + (filter ? " (filtered)" : ""), + ); let exported = 0; for (const { getContent } of workQueue) { @@ -79,7 +83,7 @@ if (opts.help) { `Usage: deno run -A ${Deno.mainModule} [options] Options: - --type Type of game to export. Can be vs, coop, or all. (default: coop) + --type Type of game to export. Can be vs, tri-color, coop, or all. (default: coop) --profile-path , -p Path to config file (default: ./profile.json) --help Show this help message and exit`, ); @@ -130,7 +134,24 @@ const statInkExporter = new StatInkExporter({ uploadMode: "Manual", env, }); -const type = (opts.type ?? "coop").replace("all", "vs,coop"); +const type = (opts.type ?? "coop").replace("all", "vs,coop,tri-color"); + +if (type.includes("tri-color")) { + [ + await exportType({ + type: "VsInfo", + fileExporter, + statInkExporter, + gameFetcher, + filter: (game) => { + if (game.type === "CoopInfo") { + return false; + } + return game.detail.vsRule.rule === "TRI_COLOR"; + }, + }), + ]; +} if (type.includes("vs")) { await exportType({ diff --git a/src/constant.ts b/src/constant.ts index f363bb0..b41bd0f 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,7 +1,7 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; export const AGENT_NAME = "s3si.ts"; -export const S3SI_VERSION = "0.2.5"; +export const S3SI_VERSION = "0.2.6"; export const NSOAPP_VERSION = "2.4.0"; export const WEB_VIEW_VERSION = "2.0.0-bd36a652"; export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"; @@ -46,8 +46,7 @@ export const SPLATNET3_STATINK_MAP: { LOFT: "yagura", GOAL: "hoko", CLAM: "asari", - // TODO: support tri-color - TRI_COLOR: "nawabari", + TRI_COLOR: "tricolor", }, RESULT: { WIN: "win", diff --git a/src/exporters/file.ts b/src/exporters/file.ts index 4f8bfa9..b9767e6 100644 --- a/src/exporters/file.ts +++ b/src/exporters/file.ts @@ -59,7 +59,11 @@ export class FileExporter implements GameExporter { * Get all exported files */ async exportedGames( - { uid, type }: { uid: string; type: Game["type"] }, + { uid, type, filter }: { + uid: string; + type: Game["type"]; + filter?: (game: Game) => boolean; + }, ): Promise<{ id: string; getContent: () => Promise }[]> { const out: { id: string; filepath: string; timestamp: string }[] = []; @@ -78,12 +82,18 @@ export class FileExporter implements GameExporter { continue; } if (body.type === "VS" && type === "VsInfo") { + if (filter && !filter(body.data)) { + continue; + } out.push({ id: body.data.detail.id, filepath, timestamp, }); } else if (body.type === "COOP" && type === "CoopInfo") { + if (filter && !filter(body.data)) { + continue; + } out.push({ id: body.data.detail.id, filepath, diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index ccf5632..5f6cdca 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -5,6 +5,7 @@ import { USERAGENT, } from "../constant.ts"; import { + Color, CoopHistoryDetail, CoopHistoryPlayerResult, CoopInfo, @@ -261,14 +262,6 @@ export class StatInkExporter implements GameExporter { return vsMode.mode === "FEST" && b64Number(vsMode.id) === 8; } async exportGame(game: Game): Promise { - if (game.type === "VsInfo" && this.isTriColor(game.detail)) { - // TODO: support tri-color fest - return { - status: "skip", - reason: "Tri-color fest is not supported", - }; - } - if (game.type === "VsInfo") { const body = await this.mapBattle(game); const { url } = await this.api.postBattle(body); @@ -333,7 +326,7 @@ export class StatInkExporter implements GameExporter { } else if (modeId === 7) { return "splatfest_challenge"; } else if (modeId === 8) { - throw new Error("Tri-color battle is not supported"); + return "splatfest_open"; } } else if (vsMode === "X_MATCH") { return "xmatch"; @@ -406,6 +399,7 @@ export class StatInkExporter implements GameExporter { result.assist = player.result.assist; result.kill = result.kill_or_assist - result.assist; result.death = player.result.death; + result.signal = player.result.noroshiTry ?? undefined; result.special = player.result.special; } return result; @@ -437,6 +431,10 @@ export class StatInkExporter implements GameExporter { } const startedAt = Math.floor(new Date(playedTime).getTime() / 1000); + if (otherTeams.length === 0) { + throw new Error(`Other teams is empty`); + } + const result: StatInkPostBody = { uuid: await gameId(vsDetail.id), lobby: this.mapLobby(vsDetail), @@ -452,7 +450,7 @@ export class StatInkExporter implements GameExporter { our_team_players: await Promise.all(myTeam.players.map(this.mapPlayer)), their_team_players: await Promise.all( - otherTeams.flatMap((i) => i.players).map( + otherTeams[0].players.map( this.mapPlayer, ), ), @@ -472,16 +470,23 @@ export class StatInkExporter implements GameExporter { result.assist = self.result.assist; result.kill = result.kill_or_assist - result.assist; result.death = self.result.death; + result.signal = self.result.noroshiTry ?? undefined; result.special = self.result.special; } + result.our_team_color = this.mapColor(myTeam.color); + result.their_team_color = this.mapColor(otherTeams[0].color); + if (otherTeams.length === 2) { + result.third_team_color = this.mapColor(otherTeams[1].color); + } + if (festMatch) { result.fest_dragon = SPLATNET3_STATINK_MAP.DRAGON[festMatch.dragonMatchType]; result.clout_change = festMatch.contribution; result.fest_power = festMatch.myFestPower ?? undefined; } - if (rule === "TURF_WAR") { + if (rule === "TURF_WAR" || rule === "TRI_COLOR") { result.our_team_percent = (myTeam?.result?.paintRatio ?? 0) * 100; result.their_team_percent = (otherTeams?.[0]?.result?.paintRatio ?? 0) * 100; @@ -493,6 +498,39 @@ export class StatInkExporter implements GameExporter { (acc, i) => acc + i.paint, 0, ); + + if (myTeam.tricolorRole && myTeam.festTeamName) { + result.our_team_role = myTeam.tricolorRole === "DEFENSE" + ? "defender" + : "attacker"; + result.our_team_theme = myTeam.festTeamName; + } + if (otherTeams[0].tricolorRole && otherTeams[0].festTeamName) { + result.their_team_role = otherTeams[0].tricolorRole === "DEFENSE" + ? "defender" + : "attacker"; + result.their_team_theme = otherTeams[0].festTeamName; + } + + if (otherTeams.length === 2) { + result.third_team_players = await Promise.all( + otherTeams[0].players.map( + this.mapPlayer, + ), + ); + result.third_team_percent = (otherTeams[1]?.result?.paintRatio ?? 0) * + 100; + result.third_team_inked = otherTeams[1].players.reduce( + (acc, i) => acc + i.paint, + 0, + ); + if (otherTeams[1].tricolorRole && otherTeams[1].festTeamName) { + result.third_team_role = otherTeams[1].tricolorRole === "DEFENSE" + ? "defender" + : "attacker"; + result.third_team_theme = otherTeams[1].festTeamName; + } + } } if (knockout) { result.knockout = knockout === "NEITHER" ? "no" : "yes"; @@ -569,6 +607,13 @@ export class StatInkExporter implements GameExporter { return result; } + mapColor(color: Color): string | undefined { + const float2hex = (i: number) => + Math.round(i * 255).toString(16).padStart(2, "0"); + // rgba + const nums = [color.r, color.g, color.b, color.a]; + return nums.map(float2hex).join(""); + } isRandom(image: Image | null): boolean { // question mark const RANDOM_FILENAME = diff --git a/src/types.ts b/src/types.ts index da47b60..0eb7433 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,6 +154,7 @@ export type VsPlayer = { death: number; assist: number; special: number; + noroshiTry: null | number; } | null; paint: number; crown: boolean; @@ -162,8 +163,17 @@ export type VsPlayer = { clothingGear: PlayerGear; shoesGear: PlayerGear; }; +export type Color = { + a: number; + b: number; + g: number; + r: number; +}; export type VsTeam = { players: VsPlayer[]; + color: Color; + tricolorRole: null | "DEFENSE" | "ATTACK1" | "ATTACK2"; + festTeamName: null | string; result: null | { paintRatio: null | number; score: null | number; @@ -637,6 +647,7 @@ export type StatInkPlayer = { assist?: number; kill_or_assist?: number; death?: number; + signal?: number; special?: number; gears?: StatInkGears; crown?: "yes" | "no"; @@ -742,7 +753,7 @@ export type StatInkPostBody = { | "splatfest_challenge" | "splatfest_open" | "private"; - rule: "nawabari" | "area" | "hoko" | "yagura" | "asari"; + rule: "nawabari" | "area" | "hoko" | "yagura" | "asari" | "tricolor"; stage: string; weapon: string; result: "win" | "lose" | "draw" | "exempted_lose"; @@ -752,15 +763,27 @@ export type StatInkPostBody = { assist?: number; kill_or_assist?: number; // equals to kill + assist if you know them death?: number; + signal?: number; special?: number; // use count inked: number; // not including bonus medals: string[]; // 0-3 elements our_team_inked?: number; // TW, not including bonus their_team_inked?: number; // TW, not including bonus + third_team_inked?: number; // Tricolor Turf War our_team_percent?: number; // TW their_team_percent?: number; // TW + third_team_percent?: number; // Tricolor Turf War our_team_count?: number; // Anarchy their_team_count?: number; // Anarchy + our_team_color?: string; + their_team_color?: string; + third_team_color?: string; + our_team_role?: "attacker" | "defender"; + their_team_role?: "attacker" | "defender"; + third_team_role?: "attacker" | "defender"; + our_team_theme?: string; + their_team_theme?: string; + third_team_theme?: string; level_before?: number; level_after?: number; rank_before?: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s- @@ -790,6 +813,7 @@ export type StatInkPostBody = { cash_after?: number; our_team_players: StatInkPlayer[]; their_team_players: StatInkPlayer[]; + third_team_players?: StatInkPlayer[]; // Tricolor Turf War agent: string; agent_version: string;