diff --git a/CHANGELOG b/CHANGELOG index 5b01b20..b26909c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,8 @@ +0.1.13 + +feat: auto track after promotion challenge success +fix: failed promotion challenge can also lead to tracked promotions + 0.1.12 feat: add rank tracker diff --git a/src/RankTracker.test.ts b/src/RankTracker.test.ts index 36b32e7..001d0c0 100644 --- a/src/RankTracker.test.ts +++ b/src/RankTracker.test.ts @@ -53,6 +53,92 @@ function genOpenWins( return result; } +Deno.test("RankTracker don't promo after failed challenge", async () => { + const tracker = new TestRankTracker(undefined); + assertEquals(tracker.testGet(), { + state: undefined, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: { + winCount: 0, + loseCount: 3, + maxWinCount: 3, + maxLoseCount: 3, + state: "FAILED", + isPromo: true, + isUdemaeUp: false, + udemaeAfter: "B+", + earnedUdemaePoint: null, + }, + historyDetails: { + nodes: [{ + id: genId(1), + udemae: "B+", + judgement: "LOSE", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, { + id: genId(0), + udemae: "B+", + judgement: "LOSE", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }], + }, + }]); + + assertEquals(finalState, undefined); +}); + +Deno.test("RankTracker autotrack after promotion", async () => { + const tracker = new TestRankTracker(undefined); + assertEquals(tracker.testGet(), { + state: undefined, + deltaMap: new Map(), + }); + + const finalState = await tracker.updateState([{ + bankaraMatchChallenge: { + winCount: 3, + loseCount: 0, + maxWinCount: 3, + maxLoseCount: 3, + state: "SUCCEEDED", + isPromo: true, + isUdemaeUp: true, + udemaeAfter: "A-", + earnedUdemaePoint: null, + }, + historyDetails: { + nodes: [{ + id: genId(1), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }, { + id: genId(0), + udemae: "B+", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: null, + }, + }], + }, + }]); + + assertEquals(finalState, { + gameId: await gameId(genId(1)), + rank: "A-", + rankPoint: 200, + }); +}); + Deno.test("RankTracker tracks promotion, ignoring INPROGRESS", async () => { const INIT_STATE = { gameId: await gameId(genId(0)), @@ -80,14 +166,14 @@ Deno.test("RankTracker tracks promotion, ignoring INPROGRESS", async () => { }, historyDetails: { nodes: [{ - id: await genId(1), + id: genId(1), udemae: "B+", judgement: "WIN", bankaraMatch: { earnedUdemaePoint: null, }, }, { - id: await genId(0), + id: genId(0), udemae: "B+", judgement: "WIN", bankaraMatch: { @@ -131,21 +217,21 @@ Deno.test("RankTracker tracks promotion", async () => { }, historyDetails: { nodes: [{ - id: await genId(2), + id: genId(2), udemae: "B+", judgement: "WIN", bankaraMatch: { earnedUdemaePoint: null, }, }, { - id: await genId(1), + id: genId(1), udemae: "B+", judgement: "WIN", bankaraMatch: { earnedUdemaePoint: null, }, }, { - id: await genId(0), + id: genId(0), udemae: "B+", judgement: "WIN", bankaraMatch: { diff --git a/src/RankTracker.ts b/src/RankTracker.ts index 747ffb7..ca1e446 100644 --- a/src/RankTracker.ts +++ b/src/RankTracker.ts @@ -1,5 +1,10 @@ import { RankState } from "./state.ts"; -import { BattleListNode, HistoryGroups, RankParam } from "./types.ts"; +import { + BankaraMatchChallenge, + BattleListNode, + HistoryGroups, + RankParam, +} from "./types.ts"; import { gameId, parseHistoryDetailId } from "./utils.ts"; const splusParams = () => { @@ -79,13 +84,14 @@ type Delta = { rankAfter?: string; rankPoint: number; isPromotion: boolean; + isRankUp: boolean; isChallengeFirst: boolean; }; // TODO: auto rank up using rank params and delta. function addRank(state: RankState, delta: Delta): RankState { const { rank, rankPoint } = state; - const { gameId, rankAfter, isPromotion, isChallengeFirst } = delta; + const { gameId, rankAfter, isPromotion, isRankUp, isChallengeFirst } = delta; const rankIndex = RANK_PARAMS.findIndex((r) => r.rank === rank); @@ -112,7 +118,7 @@ function addRank(state: RankState, delta: Delta): RankState { }; } - if (isPromotion) { + if (isPromotion && isRankUp) { const nextRankParam = RANK_PARAMS[rankIndex + 1]; return { @@ -140,6 +146,92 @@ const battleTime = (id: string) => { return new Date(dateStr); }; +type FlattenItem = { + gameId: string; + time: Date; + bankaraMatchChallenge: BankaraMatchChallenge | null; + index: number; + groupLength: number; + detail: BattleListNode; +}; + +function generateDeltaList( + state: RankState, + flatten: FlattenItem[], +) { + const index = flatten.findIndex((i) => i.gameId === state.gameId); + + if (index === -1) { + return; + } + + const unProcessed = flatten.slice(index); + const deltaList: Delta[] = []; + let beforeGameId = state.gameId; + + for (const i of unProcessed.slice(1)) { + if (!i.detail.bankaraMatch) { + throw new TypeError("bankaraMatch must be defined"); + } + + let delta: Delta = { + beforeGameId, + gameId: i.gameId, + rankPoint: 0, + isPromotion: false, + isRankUp: false, + isChallengeFirst: false, + }; + beforeGameId = i.gameId; + if (i.bankaraMatchChallenge) { + // challenge + if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") { + // last battle in challenge + delta = { + ...delta, + 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 deltaList; +} + +function getRankState(i: FlattenItem): RankState { + const rank = i.detail.udemae; + const param = RANK_PARAMS.find((i) => i.rank === rank); + + if (!param) { + throw new Error(`Rank not found: ${rank}`); + } + return { + gameId: i.gameId, + rank, + rankPoint: -1, + }; +} + /** * if state is empty, it will not track rank. */ @@ -179,14 +271,11 @@ export class RankTracker { } async updateState( - hisotry: HistoryGroups["nodes"], + history: HistoryGroups["nodes"], ) { - if (!this.state) { - return; - } - - const flatten = await Promise.all( - hisotry + // history order by time. 0 is the oldest. + const flatten: FlattenItem[] = await Promise.all( + history .flatMap( ({ historyDetails, bankaraMatchChallenge }) => { return historyDetails.nodes.map((j, index) => ({ @@ -203,62 +292,51 @@ export class RankTracker { .map((i) => i.gameId.then((gameId) => ({ ...i, gameId }))), ); - const index = flatten.findIndex((i) => i.gameId === this.state!.gameId); + const gameIdTime = new Map( + flatten.map((i) => [i.gameId, i.time]), + ); - if (index === -1) { + let curState: RankState | undefined; + const oldestPromotion = flatten.find((i) => + i.bankaraMatchChallenge?.isPromo && i.bankaraMatchChallenge.isUdemaeUp + ); + + /* + * There are 4 cases: + * 1. state === undefined, oldestPromotion === undefined + * 2. state === undefined, oldestPromotion !== undefined + * 3. state !== undefined, oldestPromotion === undefined + * 4. state !== undefined, oldestPromotion !== undefined + * + * In case 1, we can't track rank. So we do nothing. + * In case 2, 3, we track rank by the non-undefined state. + * In case 4, we can track by the elder game. + */ + const thisStateTime = gameIdTime.get(this.state?.gameId); + if (!thisStateTime && !oldestPromotion) { + return; + } else if (thisStateTime && !oldestPromotion) { + curState = this.state; + } else if (!thisStateTime && oldestPromotion) { + curState = getRankState(oldestPromotion); + } else if (thisStateTime && oldestPromotion) { + if (thisStateTime <= oldestPromotion.time) { + curState = this.state; + } else { + curState = getRankState(oldestPromotion); + } + } + + if (!curState) { return; } - const unProcessed = flatten.slice(index); - const deltaList: Delta[] = []; - let beforeGameId = this.state.gameId; + const deltaList = generateDeltaList(curState, flatten); - for (const i of unProcessed.slice(1)) { - if (!i.detail.bankaraMatch) { - throw new TypeError("bankaraMatch must be defined"); - } - - let delta: Delta = { - beforeGameId, - gameId: i.gameId, - rankPoint: 0, - isPromotion: false, - isChallengeFirst: false, - }; - beforeGameId = i.gameId; - if (i.bankaraMatchChallenge) { - // challenge - if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") { - // last battle in challenge - delta = { - ...delta, - rankAfter: i.bankaraMatchChallenge.udemaeAfter ?? undefined, - rankPoint: i.bankaraMatchChallenge.earnedUdemaePoint ?? 0, - isPromotion: i.bankaraMatchChallenge.isPromo ?? 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); + if (!deltaList) { + return; } - let curState = this.state; - for (const delta of deltaList) { this.deltaMap.set(delta.beforeGameId, delta); curState = addRank(curState, delta); diff --git a/src/constant.ts b/src/constant.ts index bff7dfd..a546657 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.1.12"; +export const S3SI_VERSION = "0.1.13"; export const NSOAPP_VERSION = "2.3.1"; export const WEB_VIEW_VERSION = "1.0.0-5644e7a2"; export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts";