feat: add RankTracker
parent
297e73e197
commit
9f383ae76c
|
|
@ -80,7 +80,7 @@ if (battleList.length === 0) {
|
||||||
const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]);
|
const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Your latest battle is played at ${
|
`Your latest anarchy battle is played at ${
|
||||||
new Date(detail.playedTime).toLocaleString()
|
new Date(detail.playedTime).toLocaleString()
|
||||||
}. Please enter your rank after this battle(format: RANK,POINT. S+0,300):`,
|
}. Please enter your rank after this battle(format: RANK,POINT. S+0,300):`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Mutex } from "../deps.ts";
|
import { Mutex } from "../deps.ts";
|
||||||
import { State } from "./state.ts";
|
import { RankState, State } from "./state.ts";
|
||||||
import {
|
import {
|
||||||
getBankaraBattleHistories,
|
getBankaraBattleHistories,
|
||||||
getBattleDetail,
|
getBattleDetail,
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { Cache, MemoryCache } from "./cache.ts";
|
import { Cache, MemoryCache } from "./cache.ts";
|
||||||
import { gameId } from "./utils.ts";
|
import { gameId } from "./utils.ts";
|
||||||
|
import { RankTracker } from "./RankTracker.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch game and cache it. It also fetches bankara match challenge info.
|
* Fetch game and cache it. It also fetches bankara match challenge info.
|
||||||
|
|
@ -24,6 +25,8 @@ import { gameId } from "./utils.ts";
|
||||||
export class GameFetcher {
|
export class GameFetcher {
|
||||||
state: State;
|
state: State;
|
||||||
cache: Cache;
|
cache: Cache;
|
||||||
|
rankTracker: RankTracker;
|
||||||
|
|
||||||
lock: Record<string, Mutex | undefined> = {};
|
lock: Record<string, Mutex | undefined> = {};
|
||||||
bankaraLock = new Mutex();
|
bankaraLock = new Mutex();
|
||||||
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
|
bankaraHistory?: HistoryGroups<BattleListNode>["nodes"];
|
||||||
|
|
@ -35,6 +38,7 @@ export class GameFetcher {
|
||||||
) {
|
) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
|
this.rankTracker = new RankTracker(state.rankState);
|
||||||
}
|
}
|
||||||
private getLock(id: string): Mutex {
|
private getLock(id: string): Mutex {
|
||||||
let cur = this.lock[id];
|
let cur = this.lock[id];
|
||||||
|
|
@ -47,6 +51,21 @@ export class GameFetcher {
|
||||||
return 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): Promise<RankState | undefined> {
|
||||||
|
return this.rankTracker.getRankStateById(id);
|
||||||
|
}
|
||||||
|
|
||||||
getBankaraHistory() {
|
getBankaraHistory() {
|
||||||
return this.bankaraLock.use(async () => {
|
return this.bankaraLock.use(async () => {
|
||||||
if (this.bankaraHistory) {
|
if (this.bankaraHistory) {
|
||||||
|
|
@ -120,6 +139,7 @@ export class GameFetcher {
|
||||||
challengeProgress: null,
|
challengeProgress: null,
|
||||||
bankaraMatchChallenge: null,
|
bankaraMatchChallenge: null,
|
||||||
listNode: null,
|
listNode: null,
|
||||||
|
rankState: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,6 +169,7 @@ export class GameFetcher {
|
||||||
bankaraMatchChallenge,
|
bankaraMatchChallenge,
|
||||||
listNode,
|
listNode,
|
||||||
challengeProgress,
|
challengeProgress,
|
||||||
|
rankState: await this.rankTracker.getRankStateById(id) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
cacheDetail<T>(
|
cacheDetail<T>(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import { RankState } from "./state.ts";
|
import { RankState } from "./state.ts";
|
||||||
import { GameFetcher } from "./GameFetcher.ts";
|
import { BattleListNode, HistoryGroups, RankParam } from "./types.ts";
|
||||||
|
import { gameId, parseHistoryDetailId } from "./utils.ts";
|
||||||
type RankParam = {
|
|
||||||
rank: string;
|
|
||||||
pointRange: [number, number];
|
|
||||||
entrance: number;
|
|
||||||
openWin: number;
|
|
||||||
openLose: number;
|
|
||||||
rankUp?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const splusParams = () => {
|
const splusParams = () => {
|
||||||
const out: RankParam[] = [];
|
const out: RankParam[] = [];
|
||||||
|
|
@ -18,12 +10,10 @@ const splusParams = () => {
|
||||||
const item: RankParam = {
|
const item: RankParam = {
|
||||||
rank: `S+${i}`,
|
rank: `S+${i}`,
|
||||||
pointRange: [300 + level * 350, 300 + (level + 1) * 350],
|
pointRange: [300 + level * 350, 300 + (level + 1) * 350],
|
||||||
entrance: 160,
|
charge: 160,
|
||||||
openWin: 8,
|
|
||||||
openLose: 5,
|
|
||||||
};
|
};
|
||||||
if (level === 9) {
|
if (level === 9) {
|
||||||
item.rankUp = true;
|
item.promotion = true;
|
||||||
}
|
}
|
||||||
out.push(item);
|
out.push(item);
|
||||||
}
|
}
|
||||||
|
|
@ -31,9 +21,7 @@ const splusParams = () => {
|
||||||
out.push({
|
out.push({
|
||||||
rank: "S+50",
|
rank: "S+50",
|
||||||
pointRange: [0, 9999],
|
pointRange: [0, 9999],
|
||||||
entrance: 160,
|
charge: 160,
|
||||||
openWin: 8,
|
|
||||||
openLose: 5,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -42,72 +30,227 @@ const splusParams = () => {
|
||||||
export const RANK_PARAMS: RankParam[] = [{
|
export const RANK_PARAMS: RankParam[] = [{
|
||||||
rank: "C-",
|
rank: "C-",
|
||||||
pointRange: [0, 200],
|
pointRange: [0, 200],
|
||||||
entrance: 0,
|
charge: 0,
|
||||||
openWin: 8,
|
|
||||||
openLose: 1,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "C",
|
rank: "C",
|
||||||
pointRange: [200, 400],
|
pointRange: [200, 400],
|
||||||
entrance: 20,
|
charge: 20,
|
||||||
openWin: 8,
|
|
||||||
openLose: 1,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "C+",
|
rank: "C+",
|
||||||
pointRange: [400, 600],
|
pointRange: [400, 600],
|
||||||
entrance: 40,
|
charge: 40,
|
||||||
openWin: 8,
|
promotion: true,
|
||||||
openLose: 1,
|
|
||||||
rankUp: true,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "B-",
|
rank: "B-",
|
||||||
pointRange: [100, 350],
|
pointRange: [100, 350],
|
||||||
entrance: 55,
|
charge: 55,
|
||||||
openWin: 8,
|
|
||||||
openLose: 2,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "B",
|
rank: "B",
|
||||||
pointRange: [350, 600],
|
pointRange: [350, 600],
|
||||||
entrance: 70,
|
charge: 70,
|
||||||
openWin: 8,
|
|
||||||
openLose: 2,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "B+",
|
rank: "B+",
|
||||||
pointRange: [600, 850],
|
pointRange: [600, 850],
|
||||||
entrance: 85,
|
charge: 85,
|
||||||
openWin: 8,
|
promotion: true,
|
||||||
openLose: 2,
|
|
||||||
rankUp: true,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "A-",
|
rank: "A-",
|
||||||
pointRange: [200, 500],
|
pointRange: [200, 500],
|
||||||
entrance: 100,
|
charge: 100,
|
||||||
openWin: 8,
|
|
||||||
openLose: 3,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "A",
|
rank: "A",
|
||||||
pointRange: [500, 800],
|
pointRange: [500, 800],
|
||||||
entrance: 110,
|
charge: 110,
|
||||||
openWin: 8,
|
|
||||||
openLose: 3,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "A+",
|
rank: "A+",
|
||||||
pointRange: [800, 1100],
|
pointRange: [800, 1100],
|
||||||
entrance: 120,
|
charge: 120,
|
||||||
openWin: 8,
|
promotion: true,
|
||||||
openLose: 3,
|
|
||||||
rankUp: true,
|
|
||||||
}, {
|
}, {
|
||||||
rank: "S",
|
rank: "S",
|
||||||
pointRange: [300, 1000],
|
pointRange: [300, 1000],
|
||||||
entrance: 150,
|
charge: 150,
|
||||||
openWin: 8,
|
promotion: true,
|
||||||
openLose: 4,
|
|
||||||
rankUp: true,
|
|
||||||
}, ...splusParams()];
|
}, ...splusParams()];
|
||||||
|
|
||||||
|
type Delta = {
|
||||||
|
beforeGameId: string;
|
||||||
|
gameId: string;
|
||||||
|
rankPoint: number;
|
||||||
|
isRankUp: boolean;
|
||||||
|
isChallengeFirst: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function addRank(state: RankState, delta: Delta): RankState {
|
||||||
|
const { rank, rankPoint } = state;
|
||||||
|
const { gameId, isRankUp, isChallengeFirst } = delta;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
gameId,
|
||||||
|
rank,
|
||||||
|
rankPoint: rankPoint - rankParam.charge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// S+50 is the highest rank
|
||||||
|
if (rankIndex === RANK_PARAMS.length - 1) {
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
rank,
|
||||||
|
rankPoint: Math.min(rankPoint + delta.rankPoint, rankParam.pointRange[1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRankUp) {
|
||||||
|
const nextRankParam = RANK_PARAMS[rankIndex + 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
rank: nextRankParam.rank,
|
||||||
|
rankPoint: nextRankParam.pointRange[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if state is empty, it will not track rank.
|
* if state is empty, it will not track rank.
|
||||||
*/
|
*/
|
||||||
export class RankTracker {
|
export class RankTracker {
|
||||||
constructor(private state?: RankState) {}
|
// key: privous game id
|
||||||
|
deltaMap: Map<string, Delta> = new Map();
|
||||||
|
|
||||||
|
constructor(private state: RankState | undefined) {}
|
||||||
|
|
||||||
|
async getRankStateById(id: string): Promise<RankState | undefined> {
|
||||||
|
if (!this.state) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const gid = await gameId(id);
|
||||||
|
|
||||||
|
let cur = this.state;
|
||||||
|
while (cur.gameId !== gid) {
|
||||||
|
const delta = this.deltaMap.get(cur.gameId);
|
||||||
|
if (!delta) {
|
||||||
|
throw new Error("Delta not found");
|
||||||
|
}
|
||||||
|
cur = addRank(cur, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(state: RankState | undefined) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateState(
|
||||||
|
hisotry: HistoryGroups<BattleListNode>["nodes"],
|
||||||
|
) {
|
||||||
|
if (!this.state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatten = await Promise.all(
|
||||||
|
hisotry
|
||||||
|
.flatMap(
|
||||||
|
({ historyDetails, bankaraMatchChallenge }) => {
|
||||||
|
return historyDetails.nodes.map((j, index) => ({
|
||||||
|
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 }))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = flatten.findIndex((i) => i.gameId === this.state!.gameId);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unProcessed = flatten.slice(index);
|
||||||
|
const deltaList: Delta[] = [];
|
||||||
|
let beforeGameId = this.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,
|
||||||
|
isRankUp: false,
|
||||||
|
isChallengeFirst: false,
|
||||||
|
};
|
||||||
|
beforeGameId = i.gameId;
|
||||||
|
// challenge
|
||||||
|
if (i.bankaraMatchChallenge) {
|
||||||
|
if (i.index === 0 && i.bankaraMatchChallenge.state !== "INPROGRESS") {
|
||||||
|
// last battle in challenge
|
||||||
|
delta = {
|
||||||
|
...delta,
|
||||||
|
rankPoint: i.bankaraMatchChallenge.earnedUdemaePoint ?? 0,
|
||||||
|
isRankUp: i.bankaraMatchChallenge.isUdemaeUp ?? false,
|
||||||
|
isChallengeFirst: i.index === 0,
|
||||||
|
};
|
||||||
|
} else if (i.index === i.groupLength - 1) {
|
||||||
|
// first battle in challenge
|
||||||
|
delta = {
|
||||||
|
...delta,
|
||||||
|
isChallengeFirst: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delta = {
|
||||||
|
...delta,
|
||||||
|
rankPoint: i.detail.bankaraMatch?.earnedUdemaePoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaList.push(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
let curState = this.state;
|
||||||
|
|
||||||
|
for (const delta of deltaList) {
|
||||||
|
this.deltaMap.set(delta.beforeGameId, delta);
|
||||||
|
curState = addRank(curState, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return curState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,8 @@ export class App {
|
||||||
state: this.state,
|
state: this.state,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const finalRankState = await fetcher.updateRank();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
exporters.map((e) =>
|
exporters.map((e) =>
|
||||||
showError(
|
showError(
|
||||||
|
|
@ -189,6 +191,13 @@ export class App {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// save rankState only if all exporters succeeded
|
||||||
|
fetcher.setRankState(finalRankState);
|
||||||
|
await this.writeState({
|
||||||
|
...this.state,
|
||||||
|
rankState: finalRankState,
|
||||||
|
});
|
||||||
|
|
||||||
endBar();
|
endBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ export type LoginState = {
|
||||||
};
|
};
|
||||||
export type RankState = {
|
export type RankState = {
|
||||||
// generated by gameId(battle.id)
|
// generated by gameId(battle.id)
|
||||||
// If the gameId does not exist, the tracker will assume that the user has
|
// TODO: If the gameId does not exist, the tracker will assume that the user has
|
||||||
// not played a bankara match. And it will start tracking from the first match
|
// not played a bankara match. And it will start tracking from the first match
|
||||||
gameId?: string;
|
gameId: string;
|
||||||
// C-, B, A+, S, S+0, S+12
|
// C-, B, A+, S, S+0, S+12
|
||||||
rank: string;
|
rank: string;
|
||||||
rankPoint: number;
|
rankPoint: number;
|
||||||
|
|
|
||||||
31
src/types.ts
31
src/types.ts
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { RankState } from "./state.ts";
|
||||||
|
|
||||||
export enum Queries {
|
export enum Queries {
|
||||||
HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3",
|
HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3",
|
||||||
LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00",
|
LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00",
|
||||||
|
|
@ -43,6 +45,15 @@ export type BattleListNode = {
|
||||||
id: string;
|
id: string;
|
||||||
udemae: string;
|
udemae: string;
|
||||||
judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW";
|
judgement: "LOSE" | "WIN" | "DEEMED_LOSE" | "EXEMPTED_LOSE" | "DRAW";
|
||||||
|
nextHistoryDetail: null | {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
previousHistoryDetail: null | {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
bankaraMatch: null | {
|
||||||
|
earnedUdemaePoint: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type CoopListNode = {
|
export type CoopListNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -103,6 +114,7 @@ export type VsInfo = {
|
||||||
listNode: null | BattleListNode;
|
listNode: null | BattleListNode;
|
||||||
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
bankaraMatchChallenge: null | BankaraMatchChallenge;
|
||||||
challengeProgress: null | ChallengeProgress;
|
challengeProgress: null | ChallengeProgress;
|
||||||
|
rankState: null | RankState;
|
||||||
detail: VsHistoryDetail;
|
detail: VsHistoryDetail;
|
||||||
};
|
};
|
||||||
// Salmon run
|
// Salmon run
|
||||||
|
|
@ -164,6 +176,12 @@ export type GameExporter<
|
||||||
exportGame: (game: T) => Promise<{ url?: string }>;
|
exportGame: (game: T) => Promise<{ url?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BankaraBattleHistories = {
|
||||||
|
bankaraBattleHistories: {
|
||||||
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type RespMap = {
|
export type RespMap = {
|
||||||
[Queries.HomeQuery]: {
|
[Queries.HomeQuery]: {
|
||||||
currentPlayer: {
|
currentPlayer: {
|
||||||
|
|
@ -193,11 +211,7 @@ export type RespMap = {
|
||||||
historyGroups: HistoryGroups<BattleListNode>;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
[Queries.BankaraBattleHistoriesQuery]: {
|
[Queries.BankaraBattleHistoriesQuery]: BankaraBattleHistories;
|
||||||
bankaraBattleHistories: {
|
|
||||||
historyGroups: HistoryGroups<BattleListNode>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
[Queries.PrivateBattleHistoriesQuery]: {
|
[Queries.PrivateBattleHistoriesQuery]: {
|
||||||
privateBattleHistories: {
|
privateBattleHistories: {
|
||||||
historyGroups: HistoryGroups<BattleListNode>;
|
historyGroups: HistoryGroups<BattleListNode>;
|
||||||
|
|
@ -330,3 +344,10 @@ export type StatInkPostResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RankParam = {
|
||||||
|
rank: string;
|
||||||
|
pointRange: [number, number];
|
||||||
|
charge: number;
|
||||||
|
promotion?: boolean;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue