s3si.ts/src/exporters/stat.ink.ts

870 lines
24 KiB
TypeScript

import {
AGENT_NAME,
COMBINED_VERSION,
S3SI_VERSION,
SPLATNET3_STATINK_MAP,
USERAGENT,
} from "../constant.ts";
import {
Color,
CoopHistoryDetail,
CoopHistoryPlayerResult,
CoopInfo,
ExportResult,
Game,
GameExporter,
Image,
PlayerGear,
StatInkAbility,
StatInkCoopPlayer,
StatInkCoopPostBody,
StatInkCoopWave,
StatInkGear,
StatInkGears,
StatInkPlayer,
StatInkPostBody,
StatInkPostResponse,
StatInkStage,
StatInkUuidList,
StatInkWeapon,
VsHistoryDetail,
VsInfo,
VsPlayer,
} from "../types.ts";
import { msgpack, Mutex } from "../../deps.ts";
import { APIError } from "../APIError.ts";
import {
b64Number,
gameId,
nonNullable,
s3sCoopGameId,
s3siGameId,
urlSimplify,
} from "../utils.ts";
import { Env } from "../env.ts";
const COOP_POINT_MAP: Record<number, number | undefined> = {
0: -20,
1: -10,
2: 0,
3: 20,
};
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 StatInkAPI {
statInk = "https://stat.ink";
FETCH_LOCK = new Mutex();
cache: Record<string, unknown> = {};
constructor(private statInkApiKey: string, private env: Env) {
if (statInkApiKey.length !== 43) {
throw new Error("Invalid stat.ink API key");
}
}
requestHeaders() {
return {
"User-Agent": USERAGENT,
"Authorization": `Bearer ${this.statInkApiKey}`,
};
}
async uuidList(type: Game["type"]): Promise<string[]> {
const fetch = this.env.newFetcher();
const response = await fetch.get({
url: type === "VsInfo"
? `${this.statInk}/api/v3/s3s/uuid-list`
: `${this.statInk}/api/v3/salmon/uuid-list`,
headers: this.requestHeaders(),
});
await checkResponse(response);
const uuidResult: StatInkUuidList = await response.json();
if (!Array.isArray(uuidResult)) {
throw new APIError({
response,
json: uuidResult,
message: uuidResult.message,
});
}
return uuidResult;
}
async postBattle(body: StatInkPostBody) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.statInk}/api/v3/battle`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json: StatInkPostResponse = await resp.json().catch(() => ({}));
if (resp.status !== 200 && resp.status !== 201) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
if (json.error) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
async postCoop(body: StatInkCoopPostBody) {
const fetch = this.env.newFetcher();
const resp = await fetch.post({
url: `${this.statInk}/api/v3/salmon`,
headers: {
...this.requestHeaders(),
"Content-Type": "application/x-msgpack",
},
body: msgpack.encode(body),
});
const json: StatInkPostResponse = await resp.json().catch(() => ({}));
if (resp.status !== 200 && resp.status !== 201) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
if (json.error) {
throw new APIError({
response: resp,
message: "Failed to export battle",
json,
});
}
return json;
}
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();
}
}
// Splatnet returns `14式竹筒槍‧甲`, and stat.ink returns `14式竹筒槍·甲`.
// Maybe a typo of splatnet?
private _getAliasName(name: string): string[] {
const STAT_INK_DOT = "·";
const SPLATNET_DOT = "‧";
if (name.includes(STAT_INK_DOT)) {
return [name, name.replaceAll(STAT_INK_DOT, SPLATNET_DOT)];
} else {
return [name];
}
}
_salmonWeaponMap = new Map<string, string>();
async getSalmonWeaponMap() {
if (this._salmonWeaponMap.size === 0) {
const weapons = await this.getSalmonWeapon();
for (const weapon of weapons) {
for (
const name of Object.values(weapon.name).flatMap((n) =>
this._getAliasName(n)
)
) {
const prevKey = this._salmonWeaponMap.get(name);
if (prevKey !== undefined && prevKey !== weapon.key) {
console.warn(`Duplicate weapon name: ${name}`);
}
this._salmonWeaponMap.set(name, weapon.key);
}
}
if (this._salmonWeaponMap.size === 0) {
throw new Error("Failed to get salmon weapon map");
}
}
return this._salmonWeaponMap;
}
getSalmonWeapon = () =>
this._getCached<StatInkWeapon>(
`${this.statInk}/api/v3/salmon/weapon?full=1`,
);
getWeapon = () =>
this._getCached<StatInkWeapon>(`${this.statInk}/api/v3/weapon?full=1`);
getAbility = () =>
this._getCached<StatInkAbility>(`${this.statInk}/api/v3/ability?full=1`);
getStage = () =>
this._getCached<StatInkStage>(`${this.statInk}/api/v3/stage`);
}
export type NameDict = {
gearPower: Record<string, number | undefined>;
};
/**
* Exporter to stat.ink.
*
* This is the default exporter. It will upload each battle detail to stat.ink.
*/
export class StatInkExporter implements GameExporter {
name = "stat.ink";
private api: StatInkAPI;
private uploadMode: string;
constructor(
{ statInkApiKey, uploadMode, env }: {
statInkApiKey: string;
uploadMode: string;
env: Env;
},
) {
this.api = new StatInkAPI(statInkApiKey, env);
this.uploadMode = uploadMode;
}
isTriColor({ vsMode }: VsHistoryDetail): boolean {
return vsMode.mode === "FEST" && b64Number(vsMode.id) === 8;
}
async exportGame(game: Game): Promise<ExportResult> {
if (game.type === "VsInfo") {
const body = await this.mapBattle(game);
const { url } = await this.api.postBattle(body);
return {
status: "success",
url,
};
} else {
const body = await this.mapCoop(game);
const { url } = await this.api.postCoop(body);
return {
status: "success",
url,
};
}
}
async notExported(
{ type, list }: { list: string[]; type: Game["type"] },
): Promise<string[]> {
const uuid = await this.api.uuidList(type);
const out: string[] = [];
for (const id of list) {
const s3sId = await gameId(id);
const s3siId = await s3siGameId(id);
const s3sCoopId = await s3sCoopGameId(id);
if (
!uuid.includes(s3sId) && !uuid.includes(s3siId) &&
!uuid.includes(s3sCoopId)
) {
out.push(id);
}
}
return out;
}
mapLobby(vsDetail: VsHistoryDetail): StatInkPostBody["lobby"] {
const { mode: vsMode } = vsDetail.vsMode;
if (vsMode === "REGULAR") {
return "regular";
} else if (vsMode === "BANKARA") {
const { mode } = vsDetail.bankaraMatch ?? { mode: "UNKNOWN" };
const map = {
OPEN: "bankara_open",
CHALLENGE: "bankara_challenge",
UNKNOWN: "",
} as const;
const result = map[mode];
if (result) {
return result;
}
} else if (vsMode === "PRIVATE") {
return "private";
} else if (vsMode === "FEST") {
const modeId = b64Number(vsDetail.vsMode.id);
if (modeId === 6) {
return "splatfest_open";
} else if (modeId === 7) {
return "splatfest_challenge";
} else if (modeId === 8) {
return "splatfest_open";
}
} else if (vsMode === "X_MATCH") {
return "xmatch";
}
throw new TypeError(`Unknown vsMode ${vsMode}`);
}
async mapStage({ vsStage }: VsHistoryDetail): Promise<string> {
const id = b64Number(vsStage.id).toString();
const stage = await this.api.getStage();
const result = stage.find((s) => s.aliases.includes(id));
if (!result) {
throw new Error("Unknown stage: " + vsStage.name);
}
return result.key;
}
async mapGears(
{ headGear, clothingGear, shoesGear }: VsPlayer,
): Promise<StatInkGears> {
const amap = (await this.api.getAbility()).map((i) => ({
...i,
names: Object.values(i.name),
}));
const mapAbility = ({ name }: { name: string }): string | null => {
const result = amap.find((a) => a.names.includes(name));
if (!result) {
return null;
}
return result.key;
};
const mapGear = (
{ primaryGearPower, additionalGearPowers }: PlayerGear,
): StatInkGear => {
const primary = mapAbility(primaryGearPower);
if (!primary) {
throw new Error("Unknown ability: " + primaryGearPower.name);
}
return {
primary_ability: primary,
secondary_abilities: additionalGearPowers.map(mapAbility),
};
};
return {
headgear: mapGear(headGear),
clothing: mapGear(clothingGear),
shoes: mapGear(shoesGear),
};
}
mapPlayer = async (
player: VsPlayer,
index: number,
): Promise<StatInkPlayer> => {
const result: StatInkPlayer = {
me: player.isMyself ? "yes" : "no",
rank_in_team: index + 1,
name: player.name,
number: player.nameId ?? undefined,
splashtag_title: player.byname,
weapon: b64Number(player.weapon.id).toString(),
inked: player.paint,
gears: await this.mapGears(player),
crown: player.crown ? "yes" : "no",
disconnected: player.result ? "no" : "yes",
};
if (player.result) {
result.kill_or_assist = player.result.kill;
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;
};
async mapBattle(
{
groupInfo,
challengeProgress,
bankaraMatchChallenge,
listNode,
detail: vsDetail,
rankBeforeState,
rankState,
}: VsInfo,
): Promise<StatInkPostBody> {
const {
knockout,
vsRule: { rule },
myTeam,
otherTeams,
bankaraMatch,
festMatch,
playedTime,
} = vsDetail;
const self = vsDetail.myTeam.players.find((i) => i.isMyself);
if (!self) {
throw new Error("Self not found");
}
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),
rule: SPLATNET3_STATINK_MAP.RULE[vsDetail.vsRule.rule],
stage: await this.mapStage(vsDetail),
result: SPLATNET3_STATINK_MAP.RESULT[vsDetail.judgement],
weapon: b64Number(self.weapon.id).toString(),
inked: self.paint,
rank_in_team: vsDetail.myTeam.players.indexOf(self) + 1,
medals: vsDetail.awards.map((i) => i.name),
our_team_players: await Promise.all(myTeam.players.map(this.mapPlayer)),
their_team_players: await Promise.all(
otherTeams[0].players.map(
this.mapPlayer,
),
),
agent: AGENT_NAME,
agent_version: COMBINED_VERSION,
agent_variables: {
"Upload Mode": this.uploadMode,
},
automated: "yes",
start_at: startedAt,
end_at: startedAt + vsDetail.duration,
};
if (self.result) {
result.kill_or_assist = self.result.kill;
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" || rule === "TRI_COLOR") {
result.our_team_percent = (myTeam?.result?.paintRatio ?? 0) * 100;
result.their_team_percent = (otherTeams?.[0]?.result?.paintRatio ?? 0) *
100;
result.our_team_inked = myTeam.players.reduce(
(acc, i) => acc + i.paint,
0,
);
result.their_team_inked = otherTeams?.[0].players.reduce(
(acc, i) => acc + i.paint,
0,
);
if (myTeam.festTeamName) {
result.our_team_theme = myTeam.festTeamName;
}
if (myTeam.tricolorRole) {
result.our_team_role = myTeam.tricolorRole === "DEFENSE"
? "defender"
: "attacker";
}
if (otherTeams[0].festTeamName) {
result.their_team_theme = otherTeams[0].festTeamName;
}
if (otherTeams[0].tricolorRole) {
result.their_team_role = otherTeams[0].tricolorRole === "DEFENSE"
? "defender"
: "attacker";
}
if (otherTeams.length === 2) {
result.third_team_players = await Promise.all(
otherTeams[1].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].festTeamName) {
result.third_team_theme = otherTeams[1].festTeamName;
}
if (otherTeams[1].tricolorRole) {
result.third_team_role = otherTeams[1].tricolorRole === "DEFENSE"
? "defender"
: "attacker";
}
}
}
if (knockout) {
result.knockout = knockout === "NEITHER" ? "no" : "yes";
}
result.our_team_count = myTeam?.result?.score ?? undefined;
result.their_team_count = otherTeams?.[0]?.result?.score ?? undefined;
result.rank_exp_change = bankaraMatch?.earnedUdemaePoint ?? undefined;
if (listNode?.udemae) {
[result.rank_before, result.rank_before_s_plus] = parseUdemae(
listNode.udemae,
);
}
if (bankaraMatchChallenge && challengeProgress) {
result.rank_up_battle = bankaraMatchChallenge.isPromo ? "yes" : "no";
if (challengeProgress.index === 0 && bankaraMatchChallenge.udemaeAfter) {
[result.rank_after, result.rank_after_s_plus] = parseUdemae(
bankaraMatchChallenge.udemaeAfter,
);
result.rank_exp_change = bankaraMatchChallenge.earnedUdemaePoint ??
undefined;
} else {
result.rank_after = result.rank_before;
result.rank_after_s_plus = result.rank_before_s_plus;
}
}
if (challengeProgress) {
result.challenge_win = challengeProgress.winCount;
result.challenge_lose = challengeProgress.loseCount;
}
if (vsDetail.xMatch) {
result.x_power_before = result.x_power_after = vsDetail.xMatch.lastXPower;
if (
groupInfo?.xMatchMeasurement &&
groupInfo?.xMatchMeasurement.state === "COMPLETED" &&
challengeProgress?.index === 0
) {
result.x_power_after = groupInfo.xMatchMeasurement.xPowerAfter;
}
}
if (rankBeforeState && rankState) {
result.rank_before_exp = rankBeforeState.rankPoint;
result.rank_after_exp = rankState.rankPoint;
// splatnet returns null, so we need to calculate it.
// don't calculate if it's a promotion battle.
if (
!bankaraMatchChallenge?.isUdemaeUp &&
result.rank_exp_change === undefined
) {
result.rank_exp_change = result.rank_after_exp - result.rank_before_exp;
}
if (!result.rank_after) {
[result.rank_after, result.rank_after_s_plus] = parseUdemae(
rankState.rank,
);
}
}
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 =
"473fffb2442075078d8bb7125744905abdeae651b6a5b7453ae295582e45f7d1";
// 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);
} else if (url === undefined || url === null) {
return false;
} else {
return url.pathname.includes(RANDOM_FILENAME);
}
}
async mapCoopWeapon(
{ name, image }: { name: string; image: Image | null },
): Promise<string | null> {
const weaponMap = await this.api.getSalmonWeaponMap();
const weapon = weaponMap.get(name);
if (!weapon) {
if (this.isRandom(image)) {
return null;
}
throw new Error(`Weapon not found: ${name}`);
}
return weapon;
}
mapSpecial({ name, image }: {
image: Image;
name: string;
}): Promise<string | undefined> {
const { url } = image;
const imageName = typeof url === "object" ? url.pathname : url ?? "";
const hash = /\/(\w+)_0\.\w+/.exec(imageName)?.[1] ?? "";
const special = SPLATNET3_STATINK_MAP.COOP_SPECIAL_MAP[hash];
if (!special) {
if (this.isRandom(image)) {
return Promise.resolve(undefined);
}
throw new Error(`Special not found: ${name} (${imageName})`);
}
return Promise.resolve(special);
}
async mapCoopPlayer(isMyself: boolean, {
player,
weapons,
specialWeapon,
defeatEnemyCount,
deliverCount,
goldenAssistCount,
goldenDeliverCount,
rescueCount,
rescuedCount,
}: CoopHistoryPlayerResult): Promise<StatInkCoopPlayer> {
const disconnected = [
goldenDeliverCount,
deliverCount,
rescueCount,
rescuedCount,
defeatEnemyCount,
].every((v) => v === 0) || !specialWeapon;
return {
me: isMyself ? "yes" : "no",
name: player.name,
number: player.nameId,
splashtag_title: player.byname,
uniform: b64Number(player.uniform.id).toString(),
special: specialWeapon ? await this.mapSpecial(specialWeapon) : undefined,
weapons: await Promise.all(weapons.map((w) => this.mapCoopWeapon(w))),
golden_eggs: goldenDeliverCount,
golden_assist: goldenAssistCount,
power_eggs: deliverCount,
rescue: rescueCount,
rescued: rescuedCount,
defeat_boss: defeatEnemyCount,
disconnected: disconnected ? "yes" : "no",
};
}
mapKing(id?: string) {
if (!id) {
return undefined;
}
const nid = b64Number(id).toString();
return nid;
}
async mapWave(
wave: CoopHistoryDetail["waveResults"]["0"],
): Promise<StatInkCoopWave> {
const event = wave.eventWave
? SPLATNET3_STATINK_MAP.COOP_EVENT_MAP[b64Number(wave.eventWave.id)]
: undefined;
const special_uses = (await Promise.all(
wave.specialWeapons.map((w) => this.mapSpecial(w)),
))
.flatMap((key) => key ? [key] : [])
.reduce((p, key) => ({
...p,
[key]: (p[key] ?? 0) + 1,
}), {} as Record<string, number | undefined>) as Record<string, number>;
return {
tide: SPLATNET3_STATINK_MAP.WATER_LEVEL_MAP[wave.waterLevel],
event,
golden_quota: wave.deliverNorm,
golden_appearances: wave.goldenPopCount,
golden_delivered: wave.teamDeliverCount,
special_uses,
};
}
async mapCoop(
{
gradeBefore,
groupInfo,
detail,
}: CoopInfo,
): Promise<StatInkCoopPostBody> {
const {
dangerRate,
resultWave,
bossResult,
myResult,
memberResults,
scale,
playedTime,
enemyResults,
smellMeter,
waveResults,
} = detail;
const startedAt = Math.floor(new Date(playedTime).getTime() / 1000);
const golden_eggs = waveResults.reduce(
(prev, i) => prev + i.teamDeliverCount,
0,
);
const power_eggs = myResult.deliverCount +
memberResults.reduce((p, i) => p + i.deliverCount, 0);
const bosses = Object.fromEntries(
enemyResults.map((
i,
) => [b64Number(i.enemy.id), {
appearances: i.popCount,
defeated: i.teamDefeatCount,
defeated_by_me: i.defeatCount,
}]),
);
const title_after = detail.afterGrade
? b64Number(detail.afterGrade.id).toString()
: undefined;
const title_exp_after = detail.afterGradePoint;
let clear_waves: number;
if (waveResults.length > 0) {
// when cleared, resultWave === 0, so we need to add 1.
clear_waves = waveResults.filter((i) => i.waveNumber < 4).length -
1 + (resultWave === 0 ? 1 : 0);
} else {
clear_waves = 0;
}
let title_before: string | undefined = undefined;
let title_exp_before: number | undefined = undefined;
if (gradeBefore) {
title_before = b64Number(gradeBefore.grade.id).toString();
title_exp_before = gradeBefore.gradePoint;
} else {
const expDiff = COOP_POINT_MAP[clear_waves];
if (
nonNullable(title_after) && nonNullable(title_exp_after) &&
nonNullable(expDiff)
) {
if (title_exp_after === 40 && expDiff === 20) {
// 20 -> 40 or ?(rank up) -> 40
} else if (
title_exp_after === 40 && expDiff < 0 && title_after !== "8"
) {
// 60,50 -> 40 or ?(rank down) to 40
} else if (title_exp_after === 999 && expDiff !== 0) {
// 980,990 -> 999
title_before = title_after;
} else {
if (title_exp_after - expDiff >= 0) {
title_before = title_after;
title_exp_before = title_exp_after - expDiff;
} else {
title_before = (parseInt(title_after) - 1).toString();
}
}
}
}
let fail_reason: StatInkCoopPostBody["fail_reason"] = null;
// failed
if (clear_waves !== 3 && waveResults.length > 0) {
const lastWave = waveResults[waveResults.length - 1];
if (lastWave.teamDeliverCount >= lastWave.deliverNorm) {
fail_reason = "wipe_out";
}
}
const result: StatInkCoopPostBody = {
uuid: await gameId(detail.id),
private: groupInfo?.mode === "PRIVATE_CUSTOM" ? "yes" : "no",
big_run: detail.rule === "BIG_RUN" ? "yes" : "no",
stage: b64Number(detail.coopStage.id).toString(),
danger_rate: dangerRate * 100,
clear_waves,
fail_reason,
king_smell: smellMeter,
king_salmonid: this.mapKing(detail.bossResult?.boss.id),
clear_extra: bossResult?.hasDefeatBoss ? "yes" : "no",
title_before,
title_exp_before,
title_after,
title_exp_after,
golden_eggs,
power_eggs,
gold_scale: scale?.gold,
silver_scale: scale?.silver,
bronze_scale: scale?.bronze,
job_point: detail.jobPoint,
job_score: detail.jobScore,
job_rate: detail.jobRate,
job_bonus: detail.jobBonus,
waves: await Promise.all(waveResults.map((w) => this.mapWave(w))),
players: await Promise.all([
this.mapCoopPlayer(true, myResult),
...memberResults.map((p) => this.mapCoopPlayer(false, p)),
]),
bosses,
agent: AGENT_NAME,
agent_version: S3SI_VERSION,
agent_variables: {
"Upload Mode": this.uploadMode,
},
automated: "yes",
start_at: startedAt,
};
return result;
}
}
function parseUdemae(udemae: string): [string, number | undefined] {
const [rank, rankNum] = udemae.split(/([0-9]+)/);
return [
rank.toLowerCase(),
rankNum === undefined ? undefined : parseInt(rankNum),
];
}