diff --git a/.terserrc.js b/.terserrc.js index bd1d0db..5ede474 100644 --- a/.terserrc.js +++ b/.terserrc.js @@ -34,6 +34,18 @@ const preambles = { // @icon https://www.google.com/s2/favicons?domain=plemiona.pl // @grant none // @run-at document-end +// ==/UserScript==`, + 'extended-tribe-profile': `// ==UserScript== +// @name Extended tribe profile +// @version 1.0.0 +// @description Adds additional info and actions on a tribe overview. +// @author Dawid Wysokiński - Kichiyaki - contact@dwysokinski.me +// @match https://*/game.php?*screen=info_ally* +// @downloadURL ${process.env.PUBLIC_URL}/extended-tribe-profile.user.js +// @updateURL ${process.env.PUBLIC_URL}/extended-tribe-profile.user.js +// @icon https://www.google.com/s2/favicons?domain=plemiona.pl +// @grant none +// @run-at document-end // ==/UserScript==`, }; diff --git a/README.md b/README.md index 4966173..3dfee92 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,22 @@ javascript: $.getScript('https://scripts.tribalwarshelp.com/extended-player-profile.quickbar.js') ``` +### Extended tribe profile + +This script adds additional info and actions on a tribe overview. + +![img.png](docs/extended-tribe-profile.png) + +#### Installation + +[User script](https://scripts.tribalwarshelp.com/extended-tribe-profile.user.js) + +Quick bar: +```javascript +javascript: + $.getScript('https://scripts.tribalwarshelp.com/extended-tribe-profile.quickbar.js') +``` + ### Extended map popup This script extends the map popup with additional info. diff --git a/docs/extended-tribe-profile.png b/docs/extended-tribe-profile.png new file mode 100644 index 0000000..e0ae74a Binary files /dev/null and b/docs/extended-tribe-profile.png differ diff --git a/package.json b/package.json index 937a10b..edea941 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "build:extended-map-popup-quickbar": "yarn build-single ./src/extended-map-popup.quickbar.ts", "build:extended-village-profile-user": "PREAMBLE=extended-village-profile yarn build-single ./src/extended-village-profile.user.ts", "build:extended-village-profile-quickbar": "yarn build-single ./src/extended-village-profile.quickbar.ts", + "build:extended-tribe-profile-user": "PREAMBLE=extended-tribe-profile yarn build-single ./src/extended-tribe-profile.user.ts", + "build:extended-tribe-profile-quickbar": "yarn build-single ./src/extended-tribe-profile.quickbar.ts", "build": "npm-run-all build:*", "lint": "eslint src/**/*.ts" }, diff --git a/src/extended-tribe-profile.quickbar.ts b/src/extended-tribe-profile.quickbar.ts new file mode 100644 index 0000000..84ca9a0 --- /dev/null +++ b/src/extended-tribe-profile.quickbar.ts @@ -0,0 +1,2 @@ +// Parcel doesn't have an option to rename output files +import './extended-tribe-profile.user'; diff --git a/src/extended-tribe-profile.user.ts b/src/extended-tribe-profile.user.ts new file mode 100644 index 0000000..ce9a528 --- /dev/null +++ b/src/extended-tribe-profile.user.ts @@ -0,0 +1,553 @@ +import { Player, Tribe, TribeSnapshot, TWHelpClient } from './lib/twhelp'; +import { createTranslationFunc } from './utils'; +import { DialogTable } from './common/dialog-table'; +import { buildURL } from './lib/twstats'; + +const t = createTranslationFunc({ + pl_PL: { + 'Created at': 'Data/czas utworzenia', + Dominance: 'Dominacja', + 'Best rank': 'Najwyższy ranking', + 'Most points': 'Najwięcej punktów', + 'Most villages': 'Najwięcej wiosek', + 'Show tribe changes': 'Pokaż zmiany plemion', + 'Show history': 'Pokaż historię', + 'Show ennoblements': 'Pokaż przejęcia', + Changes: 'Zmiany', + Members: 'Członkowie', + Points: 'Punkty', + Rank: 'Ranking', + Villages: 'Wioski', + Village: 'Wioska', + 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', + Player: 'Gracz', + 'New owner': 'Nowy właściciel', + 'New tribe': 'Nowe plemię', + 'Old owner': 'Stary własciciel', + 'Old tribe': 'Stare plemię', + 'Date/time': 'Data/czas', + Action: 'Akcja', + Joined: 'Dołączył', + Left: 'Opuścił', + Barbarian: 'Barbarzyńska', + Unknown: 'Nieznany', + 'Last activity at': 'Ostatnia aktywność', + Actions: 'Akcje', + }, +}); + +class TWHelpConnector { + constructor( + private readonly client: TWHelpClient, + private readonly version: string, + private readonly server: string, + private readonly id: number + ) {} + + tribe() { + return this.client.tribe(this.version, this.server, this.id); + } + + tribeMembers() { + return this.client.tribeMembers(this.version, this.server, this.id); + } + + async latestSnapshot() { + const history = await this.tribeHistory(1, 1); + return history.data.length > 0 ? history.data[0] : null; + } + + tribeHistory(page: number, limit: number) { + return this.client.tribeHistory(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['date:desc'], + }); + } + + tribeTribeChanges(page: number, limit: number) { + return this.client.tribeTribeChanges(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['createdAt:desc'], + }); + } + + tribeEnnoblements(page: number, limit: number) { + return this.client.tribeEnnoblements(this.version, this.server, this.id, { + offset: (page - 1) * limit, + limit, + sort: ['createdAt:desc'], + }); + } +} + +enum DialogId { + HISTORY = 'extended_tribe_profile_history', + TRIBE_CHANGES = 'extended_tribe_profile_tribe_changes', + ENNOBLEMENTS = 'extended_tribe_profile_ennoblements', +} + +class UI { + constructor( + private readonly serverKey: string, + private readonly tribe: Tribe, + private readonly latestSnapshot: TribeSnapshot | null, + private readonly members: Player[], + private readonly twhelpConnector: TWHelpConnector + ) {} + + public render() { + this.renderAdditionalInfo(); + this.renderStats(); + this.renderActions(); + this.extendMemberTable(); + } + + private renderAdditionalInfo() { + const tbody = document + .querySelector('#content_value a[href*="twstats"]') + ?.closest('tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + tbody.insertAdjacentHTML( + 'beforeend', + ` + + ${t('Created at')}: + ${new Date(this.tribe.createdAt).toLocaleString()} + + + ${t('Dominance')}: + ${this.tribe.dominance.toFixed(2)}% + + + ${t('Best rank')}: + ${this.tribe.bestRank} (${new Date( + this.tribe.bestRankAt + ).toLocaleString()}) + + + ${t('Most points')}: + ${this.tribe.mostPoints.toLocaleString()} (${new Date( + this.tribe.mostPointsAt + ).toLocaleString()}) + + + ${t('Most villages')}: + ${this.tribe.mostVillages.toLocaleString()} (${new Date( + this.tribe.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.tribe.allPoints - (this.latestSnapshot?.allPoints ?? 0), + }, + { + header: t('Dominance'), + value: this.tribe.dominance - (this.latestSnapshot?.dominance ?? 0), + customFormat: (v: number) => `${v.toFixed(4)}%`, + }, + { + header: t('Members'), + value: this.tribe.numMembers - (this.latestSnapshot?.numMembers ?? 0), + }, + { + header: t('Rank'), + value: this.tribe.rank - (this.latestSnapshot?.rank ?? 0), + rank: true, + }, + { + header: t('Villages'), + value: this.tribe.numVillages - (this.latestSnapshot?.numVillages ?? 0), + }, + { + header: t('ODA'), + value: this.tribe.scoreAtt - (this.latestSnapshot?.scoreAtt ?? 0), + }, + { + header: t('ODA - rank'), + value: this.tribe.rankAtt - (this.latestSnapshot?.rankAtt ?? 0), + rank: true, + }, + { + header: t('ODD'), + value: this.tribe.scoreDef - (this.latestSnapshot?.scoreDef ?? 0), + }, + { + header: t('ODD - rank'), + value: this.tribe.rankDef - (this.latestSnapshot?.rankDef ?? 0), + rank: true, + }, + { + header: t('OD'), + value: this.tribe.scoreTotal - (this.latestSnapshot?.scoreTotal ?? 0), + }, + { + header: t('OD - rank'), + value: this.tribe.rankTotal - (this.latestSnapshot?.rankTotal ?? 0), + rank: true, + }, + ]; + + td.insertAdjacentHTML( + 'afterbegin', + ` + + + + + + ${rows + .map( + (r) => ` + + + + + ` + ) + .join('')} + +
+ ${t('Changes')} +
${r.header}${ + r.customFormat + ? r.customFormat(r.value) + : 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 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); + td.appendChild(a); + tr.appendChild(td); + tbody.appendChild(tr); + }); + } + + private async showTribeChanges(e: Event) { + e.preventDefault(); + + await new DialogTable( + DialogId.TRIBE_CHANGES, + [ + { + header: t('Player'), + accessor: (tc) => + `${tc.player.name}`, + }, + { + header: t('Action'), + accessor: (tc) => + t(tc.newTribe?.id !== this.tribe.id ? 'Left' : 'Joined'), + }, + { + 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.tribeTribeChanges(page, limit); + } + ).render(); + } + + private async showHistory(e: Event) { + e.preventDefault(); + + await new DialogTable( + DialogId.HISTORY, + [ + { + header: t('Date'), + accessor: (s) => s.date, + }, + { + header: t('Points'), + accessor: (s) => + `${s.allPoints.toLocaleString()} (${s.rank})`, + }, + { + header: t('Villages'), + accessor: (s) => `${s.numVillages.toLocaleString()}`, + }, + { + header: t('Members'), + accessor: (s) => `${s.numMembers.toLocaleString()}`, + }, + { + header: t('Dominance'), + accessor: (s) => `${s.dominance.toFixed(2)}%`, + }, + { + 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})`, + }, + ], + 30, + (page: number, limit: number) => { + return this.twhelpConnector.tribeHistory(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.tribeEnnoblements(page, limit); + } + ).render(); + } + + private extendMemberTable() { + const table = document.querySelector('#content_value .vis:nth-child(3)'); + if ( + !(table instanceof HTMLTableElement) || + !table.querySelector('a[href*="info_player"]') + ) { + return; + } + + if (!(table.previousElementSibling instanceof HTMLHeadingElement)) { + return; + } + + const div = document.createElement('div'); + if (table.previousElementSibling instanceof HTMLHeadingElement) { + div.appendChild(table.previousElementSibling); + } + div.appendChild(table); + + document.querySelector('#content_value')?.appendChild(div); + + for (const el of table.querySelectorAll('tr')) { + const a = el.querySelector('a[href*="info_player"]'); + if (!(a instanceof HTMLAnchorElement)) { + [ + t('ODA'), + t('ODD'), + t('ODS'), + t('OD'), + t('Last activity at'), + t('Actions'), + ].forEach((s) => { + const th = document.createElement('th'); + th.innerHTML = s; + el.appendChild(th); + }); + continue; + } + + const id = parseInt(new URL(a.href).searchParams.get('id') ?? ''); + const member = this.members.find((m) => m.id === id); + + [ + (member?.rankAtt ?? 0).toString(), + (member?.rankDef ?? 0).toString(), + (member?.rankSup ?? 0).toString(), + (member?.rankTotal ?? 0).toString(), + member?.lastActivityAt + ? new Date(member.lastActivityAt).toLocaleString() + : '-', + `TWStats`, + ].forEach((v) => { + const td = document.createElement('td'); + td.innerHTML = v; + el.appendChild(td); + }); + } + } +} + +class ExtendedTribeProfile { + private readonly twhelpConnector: TWHelpConnector; + + constructor(twhelpClient: TWHelpClient) { + this.twhelpConnector = new TWHelpConnector( + twhelpClient, + window.game_data.market, + window.game_data.world, + this.getTribeId() + ); + } + + async run() { + const [tribe, latestSnapshot, { data: members }] = await Promise.all([ + this.twhelpConnector.tribe(), + this.twhelpConnector.latestSnapshot(), + this.twhelpConnector.tribeMembers(), + ]); + + await new UI( + window.game_data.world, + tribe, + latestSnapshot, + members, + this.twhelpConnector + ).render(); + } + + private getTribeId() { + const str = new URLSearchParams(window.location.search).get('id'); + if (!str) { + throw new Error(`couldn't extract tribe id`); + } + return parseInt(str); + } +} + +(async () => { + if ( + window.game_data.screen !== 'info_ally' || + window.game_data.mode !== null + ) { + return; + } + + await new ExtendedTribeProfile( + new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? '') + ) + .run() + .catch((err) => { + console.log(err); + }); +})(); diff --git a/src/lib/twhelp.ts b/src/lib/twhelp.ts index b2a1175..5a8d0a7 100644 --- a/src/lib/twhelp.ts +++ b/src/lib/twhelp.ts @@ -4,18 +4,38 @@ import axios, { RawAxiosResponseHeaders, } from 'axios'; -export type Version = { - code: string; - host: string; - name: string; - timezone: string; -}; - export type ServerMeta = { key: string; open: boolean; }; +export type Tribe = { + id: number; + name: string; + tag: string; + numMembers: number; + numVillages: number; + points: number; + allPoints: number; + rank: number; + dominance: number; + rankAtt: number; + scoreAtt: number; + rankDef: number; + scoreDef: number; + rankTotal: number; + scoreTotal: number; + profileUrl: string; + bestRank: number; + bestRankAt: string; + mostPoints: number; + mostPointsAt: string; + mostVillages: number; + mostVillagesAt: string; + createdAt: string; + deletedAt: string | null; +}; + export type TribeMeta = { id: number; name: string; @@ -83,6 +103,23 @@ export type Ennoblement = { createdAt: string; }; +export type TribeSnapshot = { + id: number; + numMembers: number; + numVillages: number; + points: number; + allPoints: number; + rank: number; + dominance: number; + rankAtt: number; + scoreAtt: number; + rankDef: number; + scoreDef: number; + rankTotal: number; + scoreTotal: number; + date: string; +}; + export type PlayerSnapshot = { id: number; tribe: TribeMeta | null; @@ -145,7 +182,13 @@ export type ListEnnoblementsParams = { sort?: string[]; }; -export type ListPlayerSnapshotsParams = { +export type ListTribeMembers = { + offset?: number; + limit?: number; + sort?: string[]; +}; + +export type ListSnapshotsParams = { offset?: number; limit?: number; sort?: string[]; @@ -166,14 +209,6 @@ export class TWHelpClient { }); } - public async versions(): Promise> { - const resp = await this.client.get('/api/v1/versions'); - return { - data: resp.data.data, - total: this.parseTotal(resp.headers), - }; - } - public async serverConfig( version: string, server: string @@ -191,6 +226,81 @@ export class TWHelpClient { return resp.data.data; } + public async tribe( + version: string, + server: string, + id: number + ): Promise { + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/tribes/${id}` + ); + return resp.data.data; + } + + public async tribeHistory( + version: string, + server: string, + id: number, + queryParams?: ListSnapshotsParams + ): Promise> { + const queryString = queryParams ? this.buildQueryString(queryParams) : ''; + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/tribes/${id}/history?${queryString}` + ); + return { + data: resp.data.data, + total: this.parseTotal(resp.headers), + }; + } + + public async tribeTribeChanges( + version: string, + server: string, + id: number, + queryParams?: ListTribeChangesQueryParams + ): Promise> { + const queryString = queryParams ? this.buildQueryString(queryParams) : ''; + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/tribes/${id}/tribe-changes?${queryString}` + ); + return { + data: resp.data.data, + total: this.parseTotal(resp.headers), + }; + } + + public async tribeEnnoblements( + version: string, + server: string, + id: number, + queryParams?: ListEnnoblementsParams + ): Promise> { + const queryString = queryParams ? this.buildQueryString(queryParams) : ''; + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/tribes/${id}/ennoblements?${queryString}` + ); + return { + data: resp.data.data, + total: this.parseTotal(resp.headers), + }; + } + + public async tribeMembers( + version: string, + server: string, + id: number, + queryParams?: ListTribeMembers + ): Promise> { + const queryString = queryParams ? this.buildQueryString(queryParams) : ''; + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/tribes/${id}/members?${queryString}` + ); + return { + data: resp.data.data, + total: this.parseTotal(resp.headers), + }; + } + public async player( version: string, server: string, @@ -238,7 +348,7 @@ export class TWHelpClient { version: string, server: string, id: number, - queryParams?: ListPlayerSnapshotsParams + queryParams?: ListSnapshotsParams ): Promise> { const queryString = queryParams ? this.buildQueryString(queryParams) : ''; const resp = await this.client.get(