diff --git a/CHANGELOG.md b/CHANGELOG.md index 8794acd..ed364e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.3.0 + +feat: clear state when season change + +feat: update `WEB_VIEW_VERSION` + ## 0.2.9 feat: update `WEB_VIEW_VERSION` diff --git a/deno.json b/deno.json index 38c8080..7e1ce52 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,8 @@ "fmt": "deno fmt", "fmt:check": "deno fmt --check", "lint": "deno lint", - "run": "deno run -A" + "run": "deno run -A", + "gen": "deno run -A ./scripts/update-constant.ts" }, "fmt": { "files": { diff --git a/src/RankTracker.test.ts b/src/RankTracker.test.ts index ead7185..0cdd799 100644 --- a/src/RankTracker.test.ts +++ b/src/RankTracker.test.ts @@ -3,8 +3,9 @@ import { assertEquals } from "../dev_deps.ts"; import { BattleListNode } from "./types.ts"; import { base64 } from "../deps.ts"; import { gameId } from "./utils.ts"; +import { RankState } from "./state.ts"; -const INIT_STATE = { +const INIT_STATE: RankState = { gameId: await gameId(genId(0)), rank: "B-", rankPoint: 100, @@ -20,9 +21,9 @@ class TestRankTracker extends RankTracker { } } -function genId(id: number): string { +function genId(id: number, date = "20220101"): string { return base64.encode( - `VsHistoryDetail-asdf:asdf:20220101T${ + `VsHistoryDetail-asdf:asdf:${date}T${ id.toString().padStart(6, "0") }_------------------------------------`, ); @@ -213,20 +214,7 @@ Deno.test("RankTracker issue #36", async () => { }); assertEquals(await tracker.getRankStateById(genId(0)), undefined); - assertEquals(await tracker.getRankStateById(genId(1)), { - before: { - gameId: await gameId(genId(0)), - rank: "S+19", - rankPoint: 3750, - timestamp: 1640995200, - }, - after: { - gameId: await gameId(genId(1)), - rank: "S+19", - rankPoint: 3750, - timestamp: 1640995201, - }, - }); + assertEquals(await tracker.getRankStateById(genId(1)), undefined); assertEquals(await tracker.getRankStateById(genId(2)), { before: { @@ -491,3 +479,50 @@ Deno.test("RankTracker", async () => { timestamp: 1640995229, }); }); + +Deno.test("RankTracker clears state when season changes", async () => { + const firstDay = new Date("2022-09-09T00:00:00+00:00").getTime() / 1000; + const firstSeason = { + ...INIT_STATE, + timestamp: firstDay, + }; + const tracker = new TestRankTracker(firstSeason); + assertEquals(tracker.testGet(), { + state: firstSeason, + deltaMap: new Map(), + }); + + const afterState = await tracker.updateState([{ + xMatchMeasurement: null, + bankaraMatchChallenge: { + winCount: 3, + loseCount: 0, + maxWinCount: 3, + maxLoseCount: 3, + state: "SUCCEEDED", + isPromo: true, + isUdemaeUp: true, + udemaeAfter: "B-", + earnedUdemaePoint: 1, + }, + historyDetails: { + nodes: [{ + id: genId(1, "20221209"), + udemae: "B-", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: 8, + }, + }, { + id: genId(0), + udemae: "B-", + judgement: "WIN", + bankaraMatch: { + earnedUdemaePoint: 8, + }, + }], + }, + }]); + + assertEquals(afterState, undefined); +}); diff --git a/src/RankTracker.ts b/src/RankTracker.ts index cc33f00..ba95181 100644 --- a/src/RankTracker.ts +++ b/src/RankTracker.ts @@ -5,7 +5,8 @@ import { HistoryGroups, RankParam, } from "./types.ts"; -import { gameId, nonNullable, parseHistoryDetailId } from "./utils.ts"; +import { gameId, parseHistoryDetailId } from "./utils.ts"; +import { getSeason } from "./VersionData.ts"; const splusParams = () => { const out: RankParam[] = []; @@ -79,9 +80,13 @@ export const RANK_PARAMS: RankParam[] = [{ }, ...splusParams()]; type Delta = { - beforeGameId: string; + before: { + gameId: string; + timestamp: number; + }; gameId: string; timestamp: number; + rank?: string; rankAfter?: string; rankPoint: number; isPromotion: boolean; @@ -89,8 +94,24 @@ type Delta = { isChallengeFirst: boolean; }; -// TODO: auto rank up using rank params and delta. -function addRank(state: RankState, delta: Delta): RankState { +// 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, @@ -101,6 +122,16 @@ function addRank(state: RankState, delta: Delta): RankState { 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) { @@ -111,20 +142,29 @@ function addRank(state: RankState, delta: Delta): RankState { if (isChallengeFirst) { return { - gameId, - timestamp, - rank, - rankPoint: rankPoint - rankParam.charge, + before: state, + after: { + gameId, + timestamp, + rank, + rankPoint: rankPoint - rankParam.charge, + }, }; } // S+50 is the highest rank if (rankIndex === RANK_PARAMS.length - 1) { return { - timestamp, - gameId, - rank, - rankPoint: Math.min(rankPoint + delta.rankPoint, rankParam.pointRange[1]), + before: state, + after: { + timestamp, + gameId, + rank, + rankPoint: Math.min( + rankPoint + delta.rankPoint, + rankParam.pointRange[1], + ), + }, }; } @@ -132,18 +172,24 @@ function addRank(state: RankState, delta: Delta): RankState { const nextRankParam = RANK_PARAMS[rankIndex + 1]; return { - gameId, - timestamp, - rank: nextRankParam.rank, - rankPoint: nextRankParam.pointRange[0], + before: state, + after: { + gameId, + timestamp, + rank: nextRankParam.rank, + rankPoint: nextRankParam.pointRange[0], + }, }; } return { - gameId, - timestamp, - rank: rankAfter ?? rank, - rankPoint: rankPoint + delta.rankPoint, + before: state, + after: { + gameId, + timestamp, + rank: rankAfter ?? rank, + rankPoint: rankPoint + delta.rankPoint, + }, }; } @@ -168,19 +214,39 @@ type FlattenItem = { detail: BattleListNode; }; -function generateDeltaList( - state: RankState, +function beginPoint( + state: RankState | undefined, flatten: FlattenItem[], -) { - const index = flatten.findIndex((i) => i.gameId === state.gameId); +): [firstItem: FlattenItem, unProcessed: FlattenItem[]] { + if (state) { + const index = flatten.findIndex((i) => i.gameId === state.gameId); - if (index === -1) { - return; + if (index !== -1) { + return [flatten[index], flatten.slice(index)]; + } } - const unProcessed = 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 beforeGameId = state.gameId; + let before = { + gameId: firstItem.gameId, + timestamp: getTimestamp(firstItem.time), + }; for (const i of unProcessed.slice(1)) { if (!i.detail.bankaraMatch) { @@ -188,21 +254,25 @@ function generateDeltaList( } let delta: Delta = { - beforeGameId, + before, gameId: i.gameId, - timestamp: Math.floor(i.time.getTime() / 1000), + timestamp: getTimestamp(i.time), rankPoint: 0, isPromotion: false, isRankUp: false, isChallengeFirst: false, }; - beforeGameId = i.gameId; + 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, @@ -229,19 +299,20 @@ function generateDeltaList( deltaList.push(delta); } - return deltaList; + return { + firstItem, + deltaList, + }; } -function getRankState(i: FlattenItem): RankState { - const rank = i.detail.udemae; - const nextRank = i.bankaraMatchChallenge?.udemaeAfter; - const earnedUdemaePoint = i.bankaraMatchChallenge?.earnedUdemaePoint; - if (!nonNullable(earnedUdemaePoint)) { - throw new TypeError("earnedUdemaePoint must be defined"); - } +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); @@ -253,8 +324,8 @@ function getRankState(i: FlattenItem): RankState { earnedUdemaePoint; return { - gameId: i.gameId, - timestamp: Math.floor(i.time.getTime() / 1000), + gameId: i.before.gameId, + timestamp: i.before.timestamp, rank, rankPoint: oldRankPoint, }; @@ -266,37 +337,18 @@ function getRankState(i: FlattenItem): RankState { 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> { - if (!this.state) { - return; - } const gid = await gameId(id); - let cur = this.state; - let before = cur; - - // there is no delta for first game - if (cur.gameId === gid) { - return; - } - while (cur.gameId !== gid) { - const delta = this.deltaMap.get(cur.gameId); - if (!delta) { - return; - } - before = cur; - cur = addRank(cur, delta); - } - - return { - before, - after: cur, - }; + return this.stateMap.get(gid); } setState(state: RankState | undefined) { @@ -326,55 +378,22 @@ export class RankTracker { .map((i) => i.gameId.then((gameId) => ({ ...i, gameId }))), ); - const gameIdTime = new Map( - flatten.map((i) => [i.gameId, i.time]), - ); + let curState: RankState | undefined = this.state; - let curState: RankState | undefined; - const oldestPromotion = flatten.find((i) => - i.bankaraMatchChallenge?.isPromo && i.bankaraMatchChallenge.isUdemaeUp - ); + const { firstItem, deltaList } = generateDeltaList(curState, flatten); - /* - * 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; - } - - this.state = curState; - const deltaList = generateDeltaList(curState, flatten); - - if (!deltaList) { + // 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.beforeGameId, delta); - curState = addRank(curState, delta); + 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; diff --git a/src/VersionData.test.ts b/src/VersionData.test.ts new file mode 100644 index 0000000..93ec3c0 --- /dev/null +++ b/src/VersionData.test.ts @@ -0,0 +1,28 @@ +import { getSeason, SEASONS } from "./VersionData.ts"; +import { assertEquals } from "../dev_deps.ts"; + +Deno.test("Seasons are continuous", () => { + let curDate: Date | undefined; + for (const season of SEASONS) { + if (!curDate) { + curDate = season.end; + } else { + assertEquals(curDate, season.start); + curDate = season.end; + } + } +}); + +Deno.test("getSeason", () => { + const season1 = getSeason(new Date("2022-09-09T00:00:00+00:00")); + + assertEquals(season1?.id, "season202209"); + + const season2 = getSeason(new Date("2022-12-09T00:00:00+00:00")); + + assertEquals(season2?.id, "season202212"); + + const nonExist = getSeason(new Date("2022-06-09T00:00:00+00:00")); + + assertEquals(nonExist, undefined); +}); diff --git a/src/VersionData.ts b/src/VersionData.ts new file mode 100644 index 0000000..138cc7c --- /dev/null +++ b/src/VersionData.ts @@ -0,0 +1,25 @@ +export type Season = { + id: string; + name: string; + start: Date; + end: Date; +}; + +export const SEASONS: Season[] = [ + { + id: "season202209", + name: "Drizzle Season 2022", + start: new Date("2022-09-01T00:00:00+00:00"), + end: new Date("2022-12-01T00:00:00+00:00"), + }, + { + id: "season202212", + name: "Chill Season 2022", + start: new Date("2022-12-01T00:00:00+00:00"), + end: new Date("2023-03-01T00:00:00+00:00"), + }, +]; + +export const getSeason = (date: Date): Season | undefined => { + return SEASONS.find((s) => s.start <= date && date < s.end); +}; diff --git a/src/constant.ts b/src/constant.ts index 2ac0f9e..c815032 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,9 +1,9 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; export const AGENT_NAME = "s3si.ts"; -export const S3SI_VERSION = "0.2.9"; +export const S3SI_VERSION = "0.3.0"; export const NSOAPP_VERSION = "2.4.0"; -export const WEB_VIEW_VERSION = "2.0.0-7070f95e"; +export const WEB_VIEW_VERSION = "3.0.0-2857bc50"; export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"; export const USERAGENT = `${AGENT_NAME}/${S3SI_VERSION} (${S3SI_LINK})`; diff --git a/src/types.ts b/src/types.ts index 0eb7433..3dfd0aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import { RankState } from "./state.ts"; export enum Queries { - HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", + HomeQuery = "22e2fa8294168003c21b00c333c35384", LatestBattleHistoriesQuery = "4f5f26e64bca394b45345a65a2f383bd", RegularBattleHistoriesQuery = "d5b795d09e67ce153e622a184b7e7dfa", BankaraBattleHistoriesQuery = "de4754588109b77dbcb90fbe44b612ee", @@ -9,7 +9,7 @@ export enum Queries { PrivateBattleHistoriesQuery = "1d6ed57dc8b801863126ad4f351dfb9a", VsHistoryDetailQuery = "291295ad311b99a6288fc95a5c4cb2d2", CoopHistoryQuery = "6ed02537e4a65bbb5e7f4f23092f6154", - CoopHistoryDetailQuery = "3cc5f826a6646b85f3ae45db51bd0707", + CoopHistoryDetailQuery = "379f0d9b78b531be53044bcac031b34b", myOutfitCommonDataFilteringConditionQuery = "d02ab22c9dccc440076055c8baa0fa7a", myOutfitCommonDataEquipmentsQuery = "d29cd0c2b5e6bac90dd5b817914832f8",