diff --git a/.env b/.env new file mode 100644 index 0000000..6ded5fa --- /dev/null +++ b/.env @@ -0,0 +1 @@ +TWHELP_API_BASE_URL=https://tribalwarshelp.com diff --git a/.gitignore b/.gitignore index 3fbe1a1..3df602f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,7 +74,6 @@ web_modules/ .yarn-integrity # dotenv environment variable files -.env .env.development.local .env.test.local .env.production.local diff --git a/.terserrc.js b/.terserrc.js index fc9a2c9..eb02c3d 100644 --- a/.terserrc.js +++ b/.terserrc.js @@ -2,11 +2,11 @@ const preambles = { 'extended-player-profile': `// ==UserScript== // @name Extended player profile // @version 1.0.0 -// @description Adds additional info to player profile. +// @description Adds additional info and actions to a player's profile. // @author Dawid Wysokiński - Kichiyaki - contact@dwysokinski.me // @match https://*/game.php?*screen=info_player* -// @downloadURL ${process.env.PUBLIC_URL}/extendedPlayerProfile.js -// @updateURL ${process.env.PUBLIC_URL}/extendedPlayerProfile.js +// @downloadURL ${process.env.PUBLIC_URL}/userscripts/extendedPlayerProfile.js +// @updateURL ${process.env.PUBLIC_URL}/userscripts/extendedPlayerProfile.js // @icon https://www.google.com/s2/favicons?domain=plemiona.pl // @grant none // @run-at document-end diff --git a/package.json b/package.json index 76c8d7e..8745269 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "0.1.0", "license": "MIT", "scripts": { - "build-single": "PUBLIC_URL=https://scripts.tribalwarshelp.com parcel build --dist-dir dist", - "build:extended-player-profile": "PREAMBLE=extended-player-profile yarn build-single src/extendedPlayerProfile.ts", + "build-userscript": "PUBLIC_URL=https://scripts.tribalwarshelp.com parcel build --dist-dir dist/userscripts", + "build-quickbar": "PUBLIC_URL=https://scripts.tribalwarshelp.com parcel build --dist-dir dist/quickbar", + "build:userscript:extended-player-profile": "PREAMBLE=extended-player-profile yarn build-userscript src/extendedPlayerProfile.ts", + "build:quickbar:extended-player-profile": "yarn build-quickbar src/extendedPlayerProfile.ts", + "build:userscript": "npm-run-all build:userscript:*", + "build:quickbar": "npm-run-all build:quickbar:*", "build": "npm-run-all build:*" }, "author": { @@ -17,13 +21,17 @@ ], "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", "parcel": "^2.8.1", "prettier": "^2.8.1", "typescript": "^4.9.4" }, "dependencies": { - "date-fns": "^2.29.3" + "axios": "^1.2.2", + "date-fns": "^2.29.3", + "lodash": "^4.17.21" } } diff --git a/src/common/DialogTable.ts b/src/common/DialogTable.ts new file mode 100644 index 0000000..c962f8a --- /dev/null +++ b/src/common/DialogTable.ts @@ -0,0 +1,142 @@ +import { createTranslationFunc } from '../utils'; + +const t = createTranslationFunc({ + pl_PL: { + Loading: 'Wczytywanie', + 'Previous page': 'Poprzednia strona', + 'Next page': 'Następna strona', + 'Something went wrong while loading the data': + 'Coś poszło nie tak podczas wczytywania danych', + }, +}); + +export type DialogTableColumn = { + header: string; + accessor: (row: T, index: number, rows: T[]) => string; +}; + +export type LoadDataResult = { + data: T[]; + total: number; +}; + +export class DialogTable { + prevPageId: string; + selectId: string; + nextPageId: string; + constructor( + private readonly id: string, + private readonly columns: DialogTableColumn[], + private readonly limit: number, + private readonly loadData: ( + page: number, + limit: number + ) => Promise> + ) { + this.prevPageId = `${this.id}_page_prev`; + this.selectId = `${this.id}_page_select`; + this.nextPageId = `${this.id}_page_next`; + } + + public async render() { + await this.renderPage(1); + } + + private async renderPage(page: number) { + window.Dialog.show(`${this.id}_loading`, `

${t('Loading')}...

`); + + try { + const { data, total } = await this.loadData(page, this.limit); + + window.Dialog.show( + this.id, + ` + ${this.buildPagination(page, total)} + ${this.buildTable(data)} + ` + ); + + this.addEventListeners(page); + } catch (err) { + console.error(err); + window.Dialog.close(); + window.UI.ErrorMessage(t('Something went wrong while loading the data')); + } + } + + private buildPagination(page: number, total: number): string { + const maxPage = Math.ceil(total / this.limit); + + const pageOpts = []; + for (let i = 1; i <= (page > maxPage ? page : maxPage); i++) { + pageOpts.push( + `${i}` + ); + } + + return ` +
+ + + +
+ `; + } + + private buildTable(data: T[]) { + return ` + + + + ${this.columns.map((col) => ``).join('')} + + ${data + .map( + (r, index) => ` + + ${this.columns + .map((col) => ``) + .join('')} + + ` + ) + .join('')} + +
${col.header}
${col.accessor(r, index, data)}
+ `; + } + + private addEventListeners(page: number) { + document + .querySelector('#' + this.prevPageId) + ?.addEventListener('click', () => { + this.renderPage(page - 1); + }); + + document + .querySelector('#' + this.selectId) + ?.addEventListener('change', (e: Event) => { + if (!(e.currentTarget instanceof HTMLSelectElement)) { + return; + } + + this.renderPage(parseInt(e.currentTarget.value)); + }); + + document + .querySelector('#' + this.nextPageId) + ?.addEventListener('click', () => { + this.renderPage(page + 1); + }); + } +} diff --git a/src/extendedPlayerProfile.ts b/src/extendedPlayerProfile.ts index 3cd9b0e..564ddfa 100644 --- a/src/extendedPlayerProfile.ts +++ b/src/extendedPlayerProfile.ts @@ -1 +1,566 @@ // Extended player profile + +import { Player, PlayerSnapshot, TWHelpClient } from './lib/twhelp'; +import { DialogTable } from './common/DialogTable'; +import { InADayClient } from './lib/tw'; +import { createTranslationFunc } from './utils'; + +const t = createTranslationFunc({ + pl_PL: { + 'Joined at': 'Dołączył o', + 'Last activity at': 'Ostatnio aktywny o', + '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', + 'Old tribe': 'Stare plemię', + 'New tribe': 'Nowe plemię', + 'Date/time': 'Data/czas', + Village: 'Wioska', + 'Old owner': 'Stary właściciel', + 'New owner': 'Nowy właściciel', + Rank: 'Ranking', + Points: 'Punkty', + Barbarian: 'Barbarzyńska', + Unknown: 'Nieznany', + Server: 'Serwer', + Deleted: 'Usunięty', + Yes: 'Tak', + No: 'Nie', + Date: 'Data', + Tribe: 'Plemię', + Villages: 'Wioski', + OD: 'Pokonani ogólnie', + 'OD - rank': 'Pokonani ogólnie - ranking', + ODA: 'Pokonani agresor', + 'ODA - rank': 'Pokonani agresor - ranking', + ODD: 'Pokonani obrońca', + 'ODD - rank': 'Pokonani obrońca - ranking', + ODS: 'Pokonani wspierający', + 'ODS - rank': 'Pokonani wspierający - ranking', + 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', + Changes: 'Zmiany', + }, +}); + +class TWHelpConnector { + constructor( + private readonly client: TWHelpClient, + private readonly version: string, + private readonly server: string, + private readonly id: number + ) {} + + player() { + return this.client.player(this.version, this.server, this.id); + } + + async latestSnapshot() { + const history = await this.playerHistory(1, 1); + return history.data.length > 0 ? history.data[0] : null; + } + + playerTribeChanges(page: number, limit: number) { + return this.client.playerTribeChanges(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['createdAt:desc', 'id:asc'], + }); + } + + playerEnnoblements(page: number, limit: number) { + return this.client.playerEnnoblements(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['createdAt:desc'], + }); + } + + playerHistory(page: number, limit: number) { + return this.client.playerHistory(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['date:desc'], + }); + } +} + +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', +} + +class UI { + constructor( + private readonly player: Player, + private readonly latestSnapshot: PlayerSnapshot | null, + private readonly twhelpConnector: TWHelpConnector, + private readonly inADayConnector: InADayConnector + ) {} + + public render() { + this.renderAdditionalInfo(); + this.renderStats(); + this.renderActions(); + } + + private renderAdditionalInfo() { + const tbody = document.querySelector('#player_info tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + tbody.insertAdjacentHTML( + 'beforeend', + ` + + ${t('Joined at')}: + ${new Date(this.player.createdAt).toLocaleString()} + + + ${t('Last activity at')}: + ${new Date(this.player.lastActivityAt).toLocaleString()} + + + ${t('Best rank')}: + ${this.player.bestRank} (${new Date( + this.player.bestRankAt + ).toLocaleString()}) + + + ${t('Most points')}: + ${this.player.mostPoints.toLocaleString()} (${new Date( + this.player.mostPointsAt + ).toLocaleString()}) + + + ${t('Most villages')}: + ${this.player.mostVillages.toLocaleString()} (${new Date( + this.player.mostVillagesAt + ).toLocaleString()}) + + ` + ); + } + + private renderStats() { + const td = document.querySelector( + '#content_value td[valign="top"]:nth-child(2)' + ); + if (!(td instanceof HTMLTableCellElement)) { + return; + } + + const rows = [ + { + header: t('Points'), + value: this.player.points - (this.latestSnapshot?.points ?? 0), + }, + { + header: t('Rank'), + value: this.player.rank - (this.latestSnapshot?.rank ?? 0), + rank: true, + }, + { + header: t('Villages'), + value: + this.player.numVillages - (this.latestSnapshot?.numVillages ?? 0), + }, + { + header: t('ODA'), + value: this.player.scoreAtt - (this.latestSnapshot?.scoreAtt ?? 0), + }, + { + header: t('ODA - rank'), + value: this.player.rankAtt - (this.latestSnapshot?.rankAtt ?? 0), + rank: true, + }, + { + header: t('ODD'), + value: this.player.scoreDef - (this.latestSnapshot?.scoreDef ?? 0), + }, + { + header: t('ODD - rank'), + value: this.player.rankDef - (this.latestSnapshot?.rankDef ?? 0), + rank: true, + }, + { + header: t('ODS'), + value: this.player.scoreSup - (this.latestSnapshot?.scoreSup ?? 0), + }, + { + header: t('ODS - rank'), + value: this.player.rankSup - (this.latestSnapshot?.rankSup ?? 0), + rank: true, + }, + { + header: t('OD'), + value: this.player.scoreTotal - (this.latestSnapshot?.scoreTotal ?? 0), + }, + { + header: t('OD - rank'), + value: this.player.rankTotal - (this.latestSnapshot?.rankTotal ?? 0), + rank: true, + }, + ]; + + td.insertAdjacentHTML( + 'afterbegin', + ` + + + + + + ${rows + .map( + (r) => ` + + + + + ` + ) + .join('')} + +
+ ${t('Changes')} +
${r.header}${Math.abs(r.value).toLocaleString()}
+ ` + ); + } + + private getStatBgColor(val: number, rank?: boolean): string { + if ((val > 0 && !rank) || (val < 0 && rank)) { + return '#0f0'; + } + if ((val < 0 && !rank) || (val > 0 && rank)) { + return '#f00'; + } + return '#808080'; + } + + private renderActions() { + const tbody = document + .querySelector('#content_value a[href*="twstats"]') + ?.closest('tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + [ + { + name: t('Show in a day ranks'), + handler: this.showInADayRanks.bind(this), + }, + { + name: t('Show tribe changes'), + handler: this.showTribeChanges.bind(this), + }, + { name: t('Show history'), handler: this.showHistory.bind(this) }, + { + name: t('Show ennoblements'), + handler: this.showEnnoblements.bind(this), + }, + ].forEach(({ name, handler }) => { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 2; + const a = document.createElement('a'); + a.innerText = name; + a.href = '#'; + a.addEventListener('click', handler); + a.setAttribute('data-player-id', this.player.id.toString()); + td.appendChild(a); + tr.appendChild(td); + tbody.appendChild(tr); + }); + } + + 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(); + + await new DialogTable( + DialogId.TRIBE_CHANGES, + [ + { + header: t('Old tribe'), + accessor: (tc) => + tc.player.tribe + ? `${tc.player.tribe.tag}` + : '-', + }, + { + header: t('New tribe'), + accessor: (tc) => + tc.newTribe + ? `${tc.newTribe.tag}` + : '-', + }, + { + header: t('Date/time'), + accessor: (tc) => new Date(tc.createdAt).toLocaleString(), + }, + ], + 30, + (page: number, limit: number) => { + return this.twhelpConnector.playerTribeChanges(page, limit); + } + ).render(); + } + + private async showHistory(e: Event) { + e.preventDefault(); + + await new DialogTable( + DialogId.HISTORY, + [ + { + header: t('Date'), + accessor: (s) => s.date, + }, + { + header: t('Tribe'), + accessor: (s) => + s.tribe + ? `${s.tribe.tag}` + : '-', + }, + { + header: t('Points'), + accessor: (s) => + `${s.points.toLocaleString()} (${s.rank})`, + }, + { + header: t('Villages'), + accessor: (s) => `${s.numVillages.toLocaleString()}`, + }, + { + header: t('OD'), + accessor: (s) => + `${s.scoreTotal.toLocaleString()} (${ + s.rankTotal + })`, + }, + { + header: t('ODA'), + accessor: (s) => + `${s.scoreAtt.toLocaleString()} (${s.rankAtt})`, + }, + { + header: t('ODD'), + accessor: (s) => + `${s.scoreDef.toLocaleString()} (${s.rankDef})`, + }, + { + header: t('ODS'), + accessor: (s) => + `${s.scoreSup.toLocaleString()} (${s.rankSup})`, + }, + ], + 30, + (page: number, limit: number) => { + return this.twhelpConnector.playerHistory(page, limit); + } + ).render(); + } + + private async showEnnoblements(e: Event) { + e.preventDefault(); + + await new DialogTable( + DialogId.ENNOBLEMENTS, + [ + { + header: t('Village'), + accessor: (e) => + `${e.village.fullName}`, + }, + { + header: t('Points'), + accessor: (e) => e.points.toLocaleString(), + }, + { + header: t('Old owner'), + accessor: ({ village: { player } }) => { + if (!player) { + return t('Barbarian'); + } + return `${player.name}${ + player.tribe + ? ` (${player.tribe.tag})` + : '' + }`; + }, + }, + { + header: t('New owner'), + accessor: ({ newOwner }) => { + if (!newOwner) { + return t('Unknown'); + } + return `${newOwner.name}${ + newOwner.tribe + ? ` (${newOwner.tribe.tag})` + : '' + }`; + }, + }, + { + header: t('Date/time'), + accessor: (e) => new Date(e.createdAt).toLocaleString(), + }, + ], + 30, + (page: number, limit: number) => { + return this.twhelpConnector.playerEnnoblements(page, limit); + } + ).render(); + } +} + +class ExtendedPlayerProfile { + 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.twhelpConnector.player(); + const latestSnapshot = await this.twhelpConnector.latestSnapshot(); + + new UI( + player, + latestSnapshot, + this.twhelpConnector, + this.inADayConnector + ).render(); + } + + private getPlayerId() { + const str = new URLSearchParams(window.location.search).get('id'); + if (!str) { + return window.game_data.player.id; + } + 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 () => { + if ( + window.game_data.screen !== 'info_player' || + window.game_data.mode !== null + ) { + return; + } + + await new ExtendedPlayerProfile( + new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? ''), + new InADayClient() + ) + .render() + .catch((err) => { + console.log(err); + }); +})(); diff --git a/src/global.d.ts b/src/global.d.ts index eec5751..813f2ab 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -6,20 +6,32 @@ declare global { group_id: number; player: { id: number; + name: string; }; village: { id: number; }; + market: string; locale: string; screen: string; mode: string | null; + world: string; }; TribalWars: { redirect: (queryParams: Record) => void; + buildURL: ( + method: string, + screen: string, + params: Record + ) => string; }; UI: { InfoMessage: (s: string) => void; ErrorMessage: (s: string) => void; }; + Dialog: { + show: (id: string, html: string) => void; + close: () => void; + }; } } diff --git a/src/lib/tw.ts b/src/lib/tw.ts new file mode 100644 index 0000000..d70d627 --- /dev/null +++ b/src/lib/tw.ts @@ -0,0 +1,254 @@ +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, '')); + } +} + +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(timeout?: number) { + this.client = axios.create({ + timeout: 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, + }); + } + + if (key !== urlsAndKeys[urlsAndKeys.length - 1].key) { + 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/twhelp.ts b/src/lib/twhelp.ts new file mode 100644 index 0000000..db41dd8 --- /dev/null +++ b/src/lib/twhelp.ts @@ -0,0 +1,236 @@ +import axios, { AxiosInstance } from 'axios'; + +const DEFAULT_TIMEOUT = 10000; +const X_TOTAL_COUNT_HEADER = 'x-total-count'; + +export type Version = { + code: string; + host: string; + name: string; + timezone: string; +}; + +export type Player = { + id: number; + points: number; + rank: number; + numVillages: number; + scoreAtt: number; + rankAtt: number; + scoreDef: number; + rankDef: number; + scoreSup: number; + rankSup: number; + scoreTotal: number; + rankTotal: number; + bestRank: number; + bestRankAt: string; + mostPoints: number; + mostPointsAt: string; + mostVillages: number; + mostVillagesAt: string; + lastActivityAt: string; + createdAt: string; +}; + +export type TribeMeta = { + id: number; + name: string; + tag: string; + profileUrl: string; +}; + +export type PlayerMeta = { + id: number; + name: string; + profileUrl: string; + tribe: TribeMeta | null; +}; + +export type VillageMeta = { + id: number; + fullName: string; + profileUrl: string; + x: number; + y: number; + continent: string; + player: PlayerMeta | null; +}; + +export type TribeChange = { + id: number; + newTribe: TribeMeta | null; + player: PlayerMeta; + createdAt: string; +}; + +export type Ennoblement = { + id: number; + points: number; + newOwner: PlayerMeta | null; + village: VillageMeta; + createdAt: string; +}; + +export type PlayerSnapshot = { + id: number; + tribe: TribeMeta | null; + points: number; + numVillages: number; + rank: number; + rankAtt: number; + rankDef: number; + rankSup: number; + rankTotal: number; + scoreAtt: number; + scoreDef: number; + scoreSup: number; + scoreTotal: number; + date: string; +}; + +export type ListResult = { + data: T[]; + total: number; +}; + +export type ListTribeChangesQueryParams = { + offset?: number; + limit?: number; + sort?: string[]; +}; + +export type ListEnnoblementsParams = { + offset?: number; + limit?: number; + sort?: string[]; +}; + +export type ListPlayerSnapshotsParams = { + offset?: number; + limit?: number; + sort?: string[]; +}; + +export class TWHelpClient { + client: AxiosInstance; + + constructor(url: string, timeout?: number) { + this.client = axios.create({ + baseURL: url, + timeout: timeout ?? DEFAULT_TIMEOUT, + }); + } + + public async versions(): Promise> { + const resp = await this.client.get('/api/v1/versions'); + return { + data: resp.data.data, + total: parseInt(resp.headers[X_TOTAL_COUNT_HEADER] ?? '0'), + }; + } + + public async player( + version: string, + server: string, + id: number + ): Promise { + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/players/${id}` + ); + return resp.data.data; + } + + public async playerTribeChanges( + version: string, + server: string, + id: number, + queryParams?: ListTribeChangesQueryParams + ): Promise> { + const params = new URLSearchParams(); + + if (queryParams?.limit) { + params.set('limit', queryParams.limit.toString()); + } + + if (queryParams?.offset) { + params.set('offset', queryParams.offset.toString()); + } + + if (Array.isArray(queryParams?.sort)) { + queryParams?.sort.forEach((s) => { + params.append('sort', s); + }); + } + + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/players/${id}/tribe-changes?${params.toString()}` + ); + return { + data: resp.data.data, + total: parseInt(resp.headers[X_TOTAL_COUNT_HEADER] ?? '0'), + }; + } + + public async playerEnnoblements( + version: string, + server: string, + id: number, + queryParams?: ListEnnoblementsParams + ): Promise> { + const params = new URLSearchParams(); + + if (queryParams?.limit) { + params.set('limit', queryParams.limit.toString()); + } + + if (queryParams?.offset) { + params.set('offset', queryParams.offset.toString()); + } + + if (Array.isArray(queryParams?.sort)) { + queryParams?.sort.forEach((s) => { + params.append('sort', s); + }); + } + + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/players/${id}/ennoblements?${params.toString()}` + ); + return { + data: resp.data.data, + total: parseInt(resp.headers[X_TOTAL_COUNT_HEADER] ?? '0'), + }; + } + + public async playerHistory( + version: string, + server: string, + id: number, + queryParams?: ListPlayerSnapshotsParams + ): Promise> { + const params = new URLSearchParams(); + + if (queryParams?.limit) { + params.set('limit', queryParams.limit.toString()); + } + + if (queryParams?.offset) { + params.set('offset', queryParams.offset.toString()); + } + + if (Array.isArray(queryParams?.sort)) { + queryParams?.sort.forEach((s) => { + params.append('sort', s); + }); + } + + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/players/${id}/history?${params.toString()}` + ); + return { + data: resp.data.data, + total: parseInt(resp.headers[X_TOTAL_COUNT_HEADER] ?? '0'), + }; + } +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 0000000..0a6cd9b --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,7 @@ +export const createTranslationFunc = ( + translations: Record> +) => { + return (s: string) => { + return translations[window.game_data.locale]?.[s] ?? s; + }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..a737461 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './i18n'; +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 16f09fc..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" @@ -761,6 +766,20 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.2.2.tgz#72681724c6e6a43a9fea860fc558127dbe32f9f1" + integrity sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -773,6 +792,11 @@ base-x@^3.0.8: dependencies: safe-buffer "^5.0.1" +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -801,6 +825,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -870,6 +902,13 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -951,6 +990,11 @@ define-properties@^1.1.3, define-properties@^1.1.4: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -1068,6 +1112,20 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -1194,6 +1252,11 @@ htmlparser2@^7.1.1: domutils "^2.8.0" entities "^3.0.1" +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -1417,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" @@ -1427,6 +1495,18 @@ memorystream@^0.3.1: resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + minimatch@^3.0.4: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -1668,6 +1748,11 @@ prettier@^2.8.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.1.tgz#4e1fd11c34e2421bc1da9aea9bd8127cd0a35efc" integrity sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + react-error-overlay@6.0.9: version "6.0.9" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"