402 lines
8.7 KiB
TypeScript
402 lines
8.7 KiB
TypeScript
import { RankState } from "./state.ts";
|
|
import {
|
|
BankaraMatchChallenge,
|
|
BattleListNode,
|
|
HistoryGroups,
|
|
RankParam,
|
|
} from "./types.ts";
|
|
import { gameId, parseHistoryDetailId } 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,
|
|
},
|
|
};
|
|
}
|
|
|
|
const battleTime = (id: string) => {
|
|
const { timestamp } = parseHistoryDetailId(id);
|
|
|
|
const dateStr = timestamp.replace(
|
|
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
|
|
"$1-$2-$3T$4:$5:$6Z",
|
|
);
|
|
|
|
return new Date(dateStr);
|
|
};
|
|
|
|
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<string, Delta> = new Map();
|
|
// key: after game id
|
|
protected stateMap: Map<string, { before: RankState; after: RankState }> =
|
|
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<BattleListNode>["nodes"],
|
|
) {
|
|
// 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;
|
|
}
|
|
}
|