diff --git a/package.json b/package.json index 6f0c741..3e2a4b3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ ], "devDependencies": { "@types/jquery": "^3.5.14", + "@types/lodash": "^4.14.191", "@types/node": "^18.11.17", "buffer": "^5.5.0", "npm-run-all": "^4.1.5", @@ -26,6 +27,7 @@ }, "dependencies": { "axios": "^1.2.2", - "date-fns": "^2.29.3" + "date-fns": "^2.29.3", + "lodash": "^4.17.21" } } diff --git a/src/extendedPlayerProfile.ts b/src/extendedPlayerProfile.ts index 31af4be..9776965 100644 --- a/src/extendedPlayerProfile.ts +++ b/src/extendedPlayerProfile.ts @@ -2,6 +2,7 @@ import { Player, TWHelpClient } from './lib/twhelp'; import { DialogTable } from './common/DialogTable'; +import { InADayClient, InADayPlayerScore } from './lib/tw'; const SCREEN = 'info_player'; const MODE = null; @@ -13,6 +14,7 @@ const translations: Record> = { 'Best rank': 'Najlepszy ranking', 'Most points': 'Najwięcej punktów', 'Most villages': 'Najwięcej wiosek', + 'Show in a day ranks': 'Pokaż dzienne rankingi', 'Show tribe changes': 'Pokaż zmiany plemion', 'Show history': 'Pokaż historię', 'Show ennoblements': 'Pokaż przejęcia', @@ -36,6 +38,15 @@ const translations: Record> = { ODA: 'Pokonani agresor', ODD: 'Pokonani obrońca', ODS: 'Pokonani wspierający', + Type: 'Typ', + Score: 'Wynik', + 'Units defeated while attacking': 'Jednostki pokonane w ataku', + 'Units defeated while defending': 'Jednostki pokonane w obronie', + 'Units defeated while supporting': 'Jednostki pokonane jako wspierający', + 'Resources plundered': 'Zrabowane surowce', + 'Villages plundered': 'Splądrowane wioski', + 'Resources gathered': 'Zebrane surowce', + 'Villages conquered': 'Przejęte wioski', }, }; @@ -80,7 +91,19 @@ class TWHelpConnector { } } +class InADayConnector { + constructor( + private readonly client: InADayClient, + private readonly name: string + ) {} + + player() { + return this.client.player(this.name); + } +} + enum DialogId { + IN_A_DAY_RANKS = 'kichiyaki_in_a_day_ranks', TRIBE_CHANGES = 'kichiyaki_tribe_changes', ENNOBLEMENTS = 'kichiyaki_ennoblements', HISTORY = 'kichiyaki_history', @@ -89,7 +112,8 @@ enum DialogId { class UI { constructor( private readonly player: Player, - private readonly connector: TWHelpConnector + private readonly twhelpConnector: TWHelpConnector, + private readonly inADayConnector: InADayConnector ) {} public render() { @@ -145,6 +169,10 @@ class UI { } [ + { + name: t('Show in a day ranks'), + handler: this.showInADayRanks.bind(this), + }, { name: t('Show tribe changes'), handler: this.showTribeChanges.bind(this), @@ -169,6 +197,60 @@ class UI { }); } + private async showInADayRanks(e: Event) { + e.preventDefault(); + + const translationKeyByType: Record = { + killAtt: 'Units defeated while attacking', + killDef: 'Units defeated while defending', + killSup: 'Units defeated while supporting', + lootRes: 'Resources plundered', + lootVil: 'Villages plundered', + scavenge: 'Resources gathered', + conquer: 'Villages conquered', + }; + + await new DialogTable( + DialogId.IN_A_DAY_RANKS, + [ + { + header: t('Type'), + accessor: (s) => + s.type in translationKeyByType + ? t(translationKeyByType[s.type]) + : s.type, + }, + { + header: t('Score'), + accessor: (s) => (s.score ? s.score.toLocaleString() : '0'), + }, + { + header: t('Date'), + accessor: (s) => (s.date ? s.date : '-'), + }, + ], + 30, + async () => { + const player = await this.inADayConnector.player(); + if (!player) { + return { + data: [], + total: 0, + }; + } + return { + data: Object.entries(player?.scores) + .map(([type, sc]) => ({ + type, + ...(sc ? sc : {}), + })) + .sort((a, b) => (a.type > b.type ? 1 : -1)), + total: Object.entries(player?.scores).length, + }; + } + ).render(); + } + private async showTribeChanges(e: Event) { e.preventDefault(); @@ -196,7 +278,7 @@ class UI { ], 30, (page: number, limit: number) => { - return this.connector.playerTribeChanges(page, limit); + return this.twhelpConnector.playerTribeChanges(page, limit); } ).render(); } @@ -252,7 +334,7 @@ class UI { ], 30, (page: number, limit: number) => { - return this.connector.playerHistory(page, limit); + return this.twhelpConnector.playerHistory(page, limit); } ).render(); } @@ -305,26 +387,32 @@ class UI { ], 30, (page: number, limit: number) => { - return this.connector.playerEnnoblements(page, limit); + return this.twhelpConnector.playerEnnoblements(page, limit); } ).render(); } } class ExtendedPlayerProfile { - connector: TWHelpConnector; - constructor(client: TWHelpClient) { - this.connector = new TWHelpConnector( - client, + private readonly twhelpConnector: TWHelpConnector; + private readonly inADayConnector: InADayConnector; + constructor(twhelpClient: TWHelpClient, inADayClient: InADayClient) { + this.twhelpConnector = new TWHelpConnector( + twhelpClient, window.game_data.market, window.game_data.world, this.getPlayerId() ); + + this.inADayConnector = new InADayConnector( + inADayClient, + this.getPlayerName() + ); } async render() { - const player = await this.connector.player(); - new UI(player, this.connector).render(); + const player = await this.twhelpConnector.player(); + new UI(player, this.twhelpConnector, this.inADayConnector).render(); } private getPlayerId() { @@ -334,6 +422,14 @@ class ExtendedPlayerProfile { } return parseInt(str); } + + private getPlayerName() { + const header = document.querySelector('#map_toggler')?.closest('th'); + if (!(header instanceof HTMLTableCellElement)) { + return window.game_data.player.name; + } + return header.innerText.trim(); + } } (async () => { @@ -342,7 +438,8 @@ class ExtendedPlayerProfile { } await new ExtendedPlayerProfile( - new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? '') + new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? ''), + new InADayClient() ) .render() .catch((err) => { diff --git a/src/global.d.ts b/src/global.d.ts index bb37dbf..813f2ab 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -6,6 +6,7 @@ declare global { group_id: number; player: { id: number; + name: string; }; village: { id: number; diff --git a/src/lib/tw.ts b/src/lib/tw.ts new file mode 100644 index 0000000..6eceedd --- /dev/null +++ b/src/lib/tw.ts @@ -0,0 +1,256 @@ +import axios, { AxiosInstance } from 'axios'; +import random from 'lodash/random'; +import { wait } from '../utils'; + +const DEFAULT_TIMEOUT = 10000; + +type InADayScore = { + player: { + id: number; + name: string; + profileUrl: string; + }; + tribe: { + id: number; + tag: string; + profileUrl: string; + } | null; + rank: number; + score: number; + date: string; +}; + +class InADayParser { + private readonly doc: Document; + constructor(html: string) { + this.doc = new DOMParser().parseFromString(html, 'text/html'); + } + + parse() { + const results: InADayScore[] = []; + + for (const row of this.doc.querySelectorAll( + '#in_a_day_ranking_table tbody tr' + )) { + if (!(row instanceof HTMLTableRowElement)) { + continue; + } + + const res = this.parseRow(row); + if (!res) { + continue; + } + + results.push(res); + } + + return results; + } + + private parseRow(row: HTMLTableRowElement): InADayScore | null { + const cells = [...row.children].filter( + (cell): cell is HTMLTableCellElement => + cell instanceof HTMLTableCellElement + ); + if (cells.length !== 5) { + return null; + } + + const playerProfileUrl = cells[1].querySelector('a')?.href; + if (!playerProfileUrl) { + return null; + } + + const tribeProfileUrl = cells[2].querySelector('a')?.href; + + return { + player: { + id: this.getIdFromUrl(playerProfileUrl), + name: cells[1].innerText.trim(), + profileUrl: playerProfileUrl, + }, + ...(tribeProfileUrl + ? { + tribe: { + id: this.getIdFromUrl(tribeProfileUrl), + tag: cells[2].innerText.trim(), + profileUrl: tribeProfileUrl, + }, + } + : { + tribe: null, + }), + rank: parseInt(cells[0].innerText.trim()), + score: this.parseScore(cells[3].innerText.trim()), + date: cells[4].innerText.trim(), + }; + } + + private getIdFromUrl(u: string) { + return parseInt(new URL(u).searchParams.get('id') ?? ''); + } + + private parseScore(s: string): number { + return parseInt(s.trim().replace(/\./g, '')); + } +} + +export type InADayClientOptions = { + timeout?: number; +}; + +type InADayParams = { + name?: string; + page?: number; +}; + +export type InADayPlayerScore = { + score: number; + date: string; +}; + +export type InADayPlayer = { + id: number; + name: string; + profileUrl: string; + tribe: { + id: number; + tag: string; + profileUrl: string; + } | null; + scores: { + killAtt: InADayPlayerScore | null; + killDef: InADayPlayerScore | null; + killSup: InADayPlayerScore | null; + lootRes: InADayPlayerScore | null; + lootVil: InADayPlayerScore | null; + scavenge: InADayPlayerScore | null; + conquer: InADayPlayerScore | null; + }; +}; + +export class InADayClient { + client: AxiosInstance; + constructor(opts?: InADayClientOptions) { + this.client = axios.create({ + timeout: opts?.timeout ?? DEFAULT_TIMEOUT, + }); + } + + async player(name: string): Promise { + const params: InADayParams = { + name, + }; + + const urlsAndKeys: { + url: string; + key: keyof InADayPlayer['scores']; + }[] = [ + { + url: this.buildUrl('kill_att', params), + key: 'killAtt', + }, + { + url: this.buildUrl('kill_def', params), + key: 'killDef', + }, + { + url: this.buildUrl('kill_sup', params), + key: 'killSup', + }, + { + url: this.buildUrl('loot_res', params), + key: 'lootRes', + }, + { + url: this.buildUrl('loot_vil', params), + key: 'lootVil', + }, + { + url: this.buildUrl('scavenge', params), + key: 'scavenge', + }, + { + url: this.buildUrl('conquer', params), + key: 'conquer', + }, + ]; + const scores: { + key: keyof InADayPlayer['scores']; + score: InADayScore; + }[] = []; + for (const { url, key } of urlsAndKeys) { + const resp = await this.client.get(url); + + const score = new InADayParser(resp.data).parse()[0]; + if (score && score.player.name === name) { + scores.push({ + key, + score, + }); + } + + await wait(random(200, 400)); + } + + if (scores.length === 0) { + return null; + } + + const player: InADayPlayer = { + id: 0, + name: '', + profileUrl: '', + tribe: null, + scores: { + conquer: null, + killAtt: null, + killDef: null, + killSup: null, + lootRes: null, + lootVil: null, + scavenge: null, + }, + }; + scores.forEach(({ score, key }) => { + if (!player.id) { + player.id = score.player.id; + } + if (!player.name) { + player.name = score.player.name; + } + if (!player.profileUrl) { + player.profileUrl = score.player.profileUrl; + } + if (!player.tribe && score.tribe) { + player.tribe = score.tribe; + } + + player.scores[key] = { + score: score.score, + date: score.date, + }; + }); + + return player; + } + + private buildUrl(type: string, params?: InADayParams) { + return window.TribalWars.buildURL('GET', 'ranking', { + mode: 'in_a_day', + type, + ...(params?.name + ? { + name: params.name, + } + : {}), + ...(params?.page + ? { + offset: ((params.page - 1) * 25).toString(), + } + : { + offset: '0', + }), + }); + } +} diff --git a/src/lib/twstats.ts b/src/lib/twstats.ts deleted file mode 100644 index 998c437..0000000 --- a/src/lib/twstats.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type BuildURLParams = { - entity: 'player'; - id: number; - server: string; -}; - -export const buildURL = (params: BuildURLParams) => { - switch (params.entity) { - case 'player': - return `https://www.twstats.com/in/${params.server}/player/${params.id}`; - default: - throw new Error(`Unknown entity: ${params.entity}`); - } -}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..8c5f7e7 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './wait'; diff --git a/src/utils/wait.ts b/src/utils/wait.ts new file mode 100644 index 0000000..c7a6d15 --- /dev/null +++ b/src/utils/wait.ts @@ -0,0 +1,2 @@ +export const wait = (timeout: number) => + new Promise((resolve) => setTimeout(resolve, timeout)); diff --git a/tsconfig.json b/tsconfig.json index e1339e0..7178200 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "strictNullChecks": true, "strictFunctionTypes": true, "skipLibCheck": true, - "noImplicitAny": true + "noImplicitAny": true, + "allowSyntheticDefaultImports": true } } diff --git a/yarn.lock b/yarn.lock index 3a94b3d..f07a8c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -722,6 +722,11 @@ dependencies: "@types/sizzle" "*" +"@types/lodash@^4.14.191": + version "4.14.191" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" + integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== + "@types/node@^18.11.17": version "18.11.17" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.17.tgz#5c009e1d9c38f4a2a9d45c0b0c493fe6cdb4bcb5" @@ -1475,6 +1480,11 @@ load-json-file@^4.0.0: pify "^3.0.0" strip-bom "^3.0.0" +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"