s3si.ts/src/GameFetcher.ts

286 lines
7.1 KiB
TypeScript

import { Mutex } from "../deps.ts";
import { RankState, State } from "./state.ts";
import { Splatnet3 } from "./splatnet3.ts";
import {
BattleListNode,
ChallengeProgress,
CoopHistoryGroups,
CoopInfo,
Game,
HistoryGroupItem,
VsInfo,
VsMode,
} from "./types.ts";
import { Cache, MemoryCache } from "./cache.ts";
import { gameId } from "./utils.ts";
import { RankTracker } from "./RankTracker.ts";
/**
* Fetch game and cache it. It also fetches bankara match challenge info.
* if splatnet is not given, it will use cache only
*/
export class GameFetcher {
private _splatnet?: Splatnet3;
private cache: Cache;
private rankTracker: RankTracker;
private lock: Record<string, Mutex | undefined> = {};
private bankaraLock = new Mutex();
private bankaraHistory?: HistoryGroupItem<BattleListNode>[];
private coopLock = new Mutex();
private coopHistory?: CoopHistoryGroups["nodes"];
private xMatchLock = new Mutex();
private xMatchHistory?: HistoryGroupItem<BattleListNode>[];
constructor(
{ cache = new MemoryCache(), splatnet, state }: {
splatnet?: Splatnet3;
state: State;
cache?: Cache;
},
) {
this._splatnet = splatnet;
this.cache = cache;
this.rankTracker = new RankTracker(state.rankState);
}
private get splatnet() {
if (!this._splatnet) {
throw new Error("splatnet is not set");
}
return this._splatnet;
}
private getLock(id: string): Mutex {
let cur = this.lock[id];
if (!cur) {
cur = new Mutex();
this.lock[id] = cur;
}
return cur;
}
setRankState(state: RankState | undefined) {
this.rankTracker.setState(state);
}
async updateRank(): Promise<RankState | undefined> {
const finalState = await this.rankTracker.updateState(
await this.getBankaraHistory(),
);
return finalState;
}
getRankStateById(id: string) {
return this.rankTracker.getRankStateById(id);
}
getXMatchHistory() {
if (!this._splatnet) {
return [];
}
return this.xMatchLock.use(async () => {
if (this.xMatchHistory) {
return this.xMatchHistory;
}
const { xBattleHistories: { historyGroups } } = await this.splatnet
.getXBattleHistories();
this.xMatchHistory = historyGroups.nodes;
return this.xMatchHistory;
});
}
getBankaraHistory() {
if (!this._splatnet) {
return [];
}
return this.bankaraLock.use(async () => {
if (this.bankaraHistory) {
return this.bankaraHistory;
}
const { bankaraBattleHistories: { historyGroups } } = await this.splatnet
.getBankaraBattleHistories();
this.bankaraHistory = historyGroups.nodes;
return this.bankaraHistory;
});
}
getCoopHistory() {
if (!this._splatnet) {
return [];
}
return this.coopLock.use(async () => {
if (this.coopHistory) {
return this.coopHistory;
}
const { coopResult: { historyGroups } } = await this.splatnet
.getCoopHistories();
this.coopHistory = historyGroups.nodes;
return this.coopHistory;
});
}
async getCoopMetaById(id: string): Promise<Omit<CoopInfo, "detail">> {
const coopHistory = this._splatnet ? await this.getCoopHistory() : [];
const group = coopHistory.find((i) =>
i.historyDetails.nodes.some((i) => i.id === id)
);
if (!group) {
return {
type: "CoopInfo",
listNode: null,
groupInfo: null,
};
}
const { historyDetails, ...groupInfo } = group;
const listNode = historyDetails.nodes.find((i) => i.id === id) ??
null;
return {
type: "CoopInfo",
listNode,
groupInfo,
};
}
async getBattleMetaById(
id: string,
vsMode: VsMode,
): Promise<Omit<VsInfo, "detail">> {
const gid = await gameId(id);
const gameIdMap = new Map<BattleListNode, string>();
let group: HistoryGroupItem<BattleListNode> | null = null;
let listNode: BattleListNode | null = null;
if (vsMode === "BANKARA" || vsMode === "X_MATCH") {
const bankaraHistory = vsMode === "BANKARA"
? await this.getBankaraHistory()
: await this.getXMatchHistory();
for (const i of bankaraHistory) {
for (const j of i.historyDetails.nodes) {
gameIdMap.set(j, await gameId(j.id));
}
}
group = bankaraHistory.find((i) =>
i.historyDetails.nodes.some((i) =>
gameIdMap.get(i) === gid
)
) ?? null;
}
if (!group) {
return {
type: "VsInfo",
challengeProgress: null,
bankaraMatchChallenge: null,
listNode: null,
rankState: null,
rankBeforeState: null,
groupInfo: null,
};
}
const { bankaraMatchChallenge, xMatchMeasurement } = group;
const { historyDetails, ...groupInfo } = group;
listNode = historyDetails.nodes.find((i) => gameIdMap.get(i) === gid) ??
null;
const index = historyDetails.nodes.indexOf(listNode!);
let challengeProgress: null | ChallengeProgress = null;
const challengeOrMeasurement = bankaraMatchChallenge || xMatchMeasurement;
if (challengeOrMeasurement) {
const pastBattles = historyDetails.nodes.slice(0, index);
const { winCount, loseCount } = challengeOrMeasurement;
challengeProgress = {
index,
winCount: winCount -
pastBattles.filter((i) => i.judgement == "WIN").length,
loseCount: loseCount -
pastBattles.filter((i) =>
["LOSE", "DEEMED_LOSE"].includes(i.judgement)
).length,
};
}
const { before, after } = await this.rankTracker.getRankStateById(id) ??
{};
return {
type: "VsInfo",
bankaraMatchChallenge,
listNode,
challengeProgress,
rankState: after ?? null,
rankBeforeState: before ?? null,
groupInfo,
};
}
private cacheDetail<T>(
id: string,
getter: () => Promise<T>,
): Promise<T> {
const lock = this.getLock(id);
return lock.use(async () => {
const cached = await this.cache.read<T>(id);
if (cached) {
return cached;
}
const detail = await getter();
await this.cache.write(id, detail);
return detail;
});
}
fetch(type: Game["type"], id: string): Promise<Game> {
switch (type) {
case "VsInfo":
return this.fetchBattle(id);
case "CoopInfo":
return this.fetchCoop(id);
default:
throw new Error(`Unknown game type: ${type}`);
}
}
private async fetchBattle(id: string): Promise<VsInfo> {
const detail = await this.cacheDetail(
id,
() => this.splatnet.getBattleDetail(id).then((r) => r.vsHistoryDetail),
);
const metadata = await this.getBattleMetaById(id, detail.vsMode.mode);
const game: VsInfo = {
...metadata,
detail,
};
return game;
}
private async fetchCoop(id: string): Promise<CoopInfo> {
const detail = await this.cacheDetail(
id,
() => this.splatnet.getCoopDetail(id).then((r) => r.coopHistoryDetail),
);
const metadata = await this.getCoopMetaById(id);
const game: CoopInfo = {
...metadata,
detail,
};
return game;
}
}