feat: extended player profile

This commit is contained in:
Dawid Wysokiński 2023-01-24 07:57:24 +01:00
parent 0026822768
commit 66136ee097
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
9 changed files with 383 additions and 27 deletions

View File

@ -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"
}
}

View File

@ -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<string, Record<string, string>> = {
'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<string, Record<string, string>> = {
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<string, string> = {
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) => {

1
src/global.d.ts vendored
View File

@ -6,6 +6,7 @@ declare global {
group_id: number;
player: {
id: number;
name: string;
};
village: {
id: number;

256
src/lib/tw.ts Normal file
View File

@ -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<InADayPlayer | null> {
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',
}),
});
}
}

View File

@ -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}`);
}
};

1
src/utils/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './wait';

2
src/utils/wait.ts Normal file
View File

@ -0,0 +1,2 @@
export const wait = (timeout: number) =>
new Promise((resolve) => setTimeout(resolve, timeout));

View File

@ -6,6 +6,7 @@
"strictNullChecks": true,
"strictFunctionTypes": true,
"skipLibCheck": true,
"noImplicitAny": true
"noImplicitAny": true,
"allowSyntheticDefaultImports": true
}
}

View File

@ -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"