import { RankState } from "./state.ts"; import { BankaraMatchChallenge, BattleListNode, HistoryGroups, RankParam, } from "./types.ts"; import { battleTime, gameId } from "./utils.ts"; import { getSeason } from "./VersionData.ts"; const splusParams = () => { const out: RankParam[] = []; for (let i = 0; i < 50; i++) { const level = i % 10; const item: RankParam = { rank: `S+${i}`, pointRange: [300 + level * 350, 300 + (level + 1) * 350], charge: 180, }; if (level === 9) { item.promotion = true; } out.push(item); } out.push({ rank: "S+50", pointRange: [0, 9999], charge: 180, }); return out; }; export const RANK_PARAMS: RankParam[] = [{ rank: "C-", pointRange: [0, 200], charge: 0, }, { rank: "C", pointRange: [200, 400], charge: 20, }, { rank: "C+", pointRange: [400, 600], charge: 40, promotion: true, }, { rank: "B-", pointRange: [100, 350], charge: 55, }, { rank: "B", pointRange: [350, 600], charge: 70, }, { rank: "B+", pointRange: [600, 850], charge: 85, promotion: true, }, { rank: "A-", pointRange: [200, 500], charge: 110, }, { rank: "A", pointRange: [500, 800], charge: 120, }, { rank: "A+", pointRange: [800, 1100], charge: 130, promotion: true, }, { rank: "S", pointRange: [300, 1000], charge: 170, promotion: true, }, ...splusParams()]; type Delta = { before: { gameId: string; timestamp: number; }; gameId: string; timestamp: number; rank?: string; rankAfter?: string; rankPoint: number; isPromotion: boolean; isRankUp: boolean; isChallengeFirst: boolean; }; // delta's beforeGameId must be state's gameId function addRank( state: RankState | undefined, delta: Delta, ): { before: RankState; after: RankState } | undefined { if (!state) { // is rank up, generate state here if (delta.isPromotion && delta.isRankUp) { state = getRankStateByDelta(delta); } else { return; } } if (state.gameId !== delta.before.gameId) { throw new Error("Invalid state"); } const { rank, rankPoint } = state; const { gameId, timestamp, rankAfter, isPromotion, isRankUp, isChallengeFirst, } = delta; if (state.timestamp) { const oldSeason = getSeason(new Date(state.timestamp * 1000)); if (oldSeason) { const newSeason = getSeason(new Date(timestamp * 1000)); if (newSeason?.id !== oldSeason.id) { return; } } } const rankIndex = RANK_PARAMS.findIndex((r) => r.rank === rank); if (rankIndex === -1) { throw new Error(`Rank not found: ${rank}`); } const rankParam = RANK_PARAMS[rankIndex]; if (isChallengeFirst) { return { before: state, after: { gameId, timestamp, rank, rankPoint: rankPoint - rankParam.charge, }, }; } // S+50 is the highest rank if (rankIndex === RANK_PARAMS.length - 1) { return { before: state, after: { timestamp, gameId, rank, rankPoint: Math.min( rankPoint + delta.rankPoint, rankParam.pointRange[1], ), }, }; } if (isPromotion && isRankUp) { const nextRankParam = RANK_PARAMS[rankIndex + 1]; return { before: state, after: { gameId, timestamp, rank: nextRankParam.rank, rankPoint: nextRankParam.pointRange[0], }, }; } return { before: state, after: { gameId, timestamp, rank: rankAfter ?? rank, rankPoint: rankPoint + delta.rankPoint, }, }; } type FlattenItem = { id: string; gameId: string; time: Date; bankaraMatchChallenge: BankaraMatchChallenge | null; index: number; groupLength: number; detail: BattleListNode; }; function beginPoint( state: RankState | undefined, flatten: FlattenItem[], ): [firstItem: FlattenItem, unProcessed: FlattenItem[]] { if (state) { const index = flatten.findIndex((i) => i.gameId === state.gameId); if (index !== -1) { return [flatten[index], flatten.slice(index)]; } } if (flatten.length === 0) { throw new Error("flatten must not be empty"); } return [flatten[0], flatten]; } function getTimestamp(date: Date) { return Math.floor(date.getTime() / 1000); } function generateDeltaList( state: RankState | undefined, flatten: FlattenItem[], ) { const [firstItem, unProcessed] = beginPoint(state, flatten); const deltaList: Delta[] = []; let before = { gameId: firstItem.gameId, timestamp: getTimestamp(firstItem.time), }; for (const i of unProcessed.slice(1)) { if (!i.detail.bankaraMatch) { throw new TypeError("bankaraMatch must be defined"); } let delta: Delta = { before, gameId: i.gameId, timestamp: getTimestamp(i.time), rankPoint: 0, isPromotion: false, isRankUp: false, isChallengeFirst: false, }; before = { gameId: i.gameId, timestamp: Math.floor(i.time.getTime() / 1000), }; if (i.bankaraMatchChallenge) { // challenge if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") { // last battle in challenge delta = { ...delta, rank: i.detail.udemae, rankAfter: i.bankaraMatchChallenge.udemaeAfter ?? undefined, rankPoint: i.bankaraMatchChallenge.earnedUdemaePoint ?? 0, isPromotion: i.bankaraMatchChallenge.isPromo ?? false, isRankUp: i.bankaraMatchChallenge.isUdemaeUp ?? false, isChallengeFirst: false, }; } else if (i.index === i.groupLength - 1) { // first battle in challenge delta = { ...delta, isChallengeFirst: true, }; } } else { // open delta = { ...delta, // TODO: rankAfter should be undefined in open battle rankAfter: i.detail.udemae, rankPoint: i.detail.bankaraMatch?.earnedUdemaePoint ?? 0, }; } deltaList.push(delta); } return { firstItem, deltaList, }; } function getRankStateByDelta(i: Delta): RankState { const rank = i.rank; const nextRank = i.rankAfter; const earnedUdemaePoint = i.rankPoint; if (!rank || !nextRank) { throw new Error("rank and nextRank must be defined"); } const param = RANK_PARAMS.find((i) => i.rank === rank); const nextParam = RANK_PARAMS.find((i) => i.rank === nextRank); if (!param || !nextParam) { throw new Error(`Rank or nextRank not found: ${rank} ${nextRank}`); } const oldRankPoint = nextParam.pointRange[0] - earnedUdemaePoint; return { gameId: i.before.gameId, timestamp: i.before.timestamp, rank, rankPoint: oldRankPoint, }; } /** * if state is empty, it will not track rank. */ export class RankTracker { // key: privous game id protected deltaMap: Map = new Map(); // key: after game id protected stateMap: Map = new Map(); constructor(protected state: RankState | undefined) {} async getRankStateById( id: string, ): Promise<{ before: RankState; after: RankState } | undefined> { const gid = await gameId(id); return this.stateMap.get(gid); } setState(state: RankState | undefined) { this.state = state; } async updateState( history: HistoryGroups["nodes"], ) { if (history.length === 0) { return; } // history order by time. 0 is the oldest. const flatten: FlattenItem[] = await Promise.all( history .flatMap( ({ historyDetails, bankaraMatchChallenge }) => { return historyDetails.nodes.map((j, index) => ({ id: j.id, time: battleTime(j.id), gameId: gameId(j.id), bankaraMatchChallenge, index, groupLength: historyDetails.nodes.length, detail: j, })); }, ) .sort((a, b) => a.time.getTime() - b.time.getTime()) .map((i) => i.gameId.then((gameId) => ({ ...i, gameId }))), ); let curState: RankState | undefined = this.state; const { firstItem, deltaList } = generateDeltaList(curState, flatten); // history don't contain current state, so skip update if (curState && firstItem.gameId !== curState.gameId) { return; } for (const delta of deltaList) { this.deltaMap.set(delta.before.gameId, delta); const result = addRank(curState, delta); curState = result?.after; if (result) { this.stateMap.set(result.after.gameId, result); } } return curState; } }