diff --git a/.terserrc.js b/.terserrc.js index dd1b704..b1779aa 100644 --- a/.terserrc.js +++ b/.terserrc.js @@ -1,7 +1,7 @@ const preambles = { 'extended-player-profile': `// ==UserScript== // @name Extended player profile -// @version 1.1.0 +// @version 1.1.1 // @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* @@ -10,6 +10,18 @@ const preambles = { // @icon https://www.google.com/s2/favicons?domain=plemiona.pl // @grant none // @run-at document-end +// ==/UserScript==`, + 'extended-map-popup': `// ==UserScript== +// @name Extended map popup +// @version 1.0.0 +// @description Extends the map popup with additional info. +// @author Dawid Wysokiński - Kichiyaki - contact@dwysokinski.me +// @match https://*/game.php?*screen=map* +// @downloadURL ${process.env.PUBLIC_URL}/extended-map-popup.user.js +// @updateURL ${process.env.PUBLIC_URL}/extended-map-popup.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 489eca2..90e9cd1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,22 @@ javascript: $.getScript('https://scripts.tribalwarshelp.com/extended-player-profile.quickbar.js') ``` +### Extended map popup + +This script extends the map popup with additional info. + +![img.png](docs/extended-map-popup.png) + +#### Installation + +[User script](https://scripts.tribalwarshelp.com/extended-map-popup.user.js) + +Quick bar: +```javascript +javascript: + $.getScript('https://scripts.tribalwarshelp.com/extended-map-popup.quickbar.js') +``` + ## License Distributed under the MIT License. See ``LICENSE`` for more information. diff --git a/docs/extended-map-popup.png b/docs/extended-map-popup.png new file mode 100644 index 0000000..5cf4f28 Binary files /dev/null and b/docs/extended-map-popup.png differ diff --git a/package.json b/package.json index bf26089..6a04261 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "build-single": "PUBLIC_URL=https://scripts.tribalwarshelp.com parcel build", "build:extended-player-profile-user": "PREAMBLE=extended-player-profile yarn build-single ./src/extended-player-profile.user.ts", "build:extended-player-profile-quickbar": "yarn build-single ./src/extended-player-profile.quickbar.ts", + "build:extended-map-popup-user": "PREAMBLE=extended-map-popup yarn build-single ./src/extended-map-popup.user.ts", + "build:extended-map-popup-quickbar": "yarn build-single ./src/extended-map-popup.quickbar.ts", "build": "npm-run-all build:*", "lint": "eslint src/**/*.ts" }, diff --git a/src/extended-map-popup.quickbar.ts b/src/extended-map-popup.quickbar.ts new file mode 100644 index 0000000..82fa6ab --- /dev/null +++ b/src/extended-map-popup.quickbar.ts @@ -0,0 +1,2 @@ +// Parcel doesn't have an option to rename output files +import './extended-map-popup.user'; diff --git a/src/extended-map-popup.user.ts b/src/extended-map-popup.user.ts new file mode 100644 index 0000000..1718bd2 --- /dev/null +++ b/src/extended-map-popup.user.ts @@ -0,0 +1,281 @@ +import addSeconds from 'date-fns/addSeconds'; +import { + Ennoblement, + ServerConfig, + TWHelpClient, + UnitInfo, +} from './lib/twhelp'; +import { Cache, InMemoryStorage } from './lib/cache'; +import { calcDistance, calcLoyalty } from './lib/tw'; +import { createTranslationFunc } from './utils'; + +declare global { + interface Window { + TWMap: { + popup: { + loadVillage: (villageId: string) => void; + _loadVillage: (villageId: string) => void; + displayForVillage: ( + village: { id: string }, + x: number, + y: number + ) => void; + _displayForVillage: ( + village: { id: string }, + x: number, + y: number + ) => void; + }; + }; + } +} + +const t = createTranslationFunc({ + pl_PL: { + 'Ennobled at': 'Ostatnio przejęta', + Loyalty: 'Poparcie', + 'Can send a nobleman': 'Można wysłać szlachica', + Yes: 'Tak', + No: 'Nie', + Never: 'Nigdy', + }, +}); + +class TWHelpConnector { + private static readonly SERVER_CONFIG_CACHE_KEY = + 'extended_map_popup_server_config'; + private static readonly UNIT_INFO_CACHE_KEY = 'extended_map_popup_unit_info'; + private static readonly VILLAGE_CACHE_KEY_PREFIX = + 'extended_map_popup_village_'; + + private readonly localStorageCache = new Cache(localStorage); + private readonly inMemoryCache = new Cache(new InMemoryStorage()); + constructor( + private readonly client: TWHelpClient, + private readonly version: string, + private readonly server: string + ) {} + + serverConfig() { + return this.localStorageCache.load( + TWHelpConnector.SERVER_CONFIG_CACHE_KEY, + 3600, + () => { + return this.client.serverConfig(this.version, this.server); + } + ); + } + + unitInfo() { + return this.localStorageCache.load( + TWHelpConnector.UNIT_INFO_CACHE_KEY, + 3600, + () => { + return this.client.unitInfo(this.version, this.server); + } + ); + } + + async latestEnnoblement(id: number, cacheOnly?: boolean) { + const key = TWHelpConnector.VILLAGE_CACHE_KEY_PREFIX + id.toString(); + + if (cacheOnly) { + return this.inMemoryCache.get(key); + } + + return this.inMemoryCache.load(key, 86400, async () => { + const ennoblements = await this.client.villageEnnoblements( + this.version, + this.server, + id, + { + limit: 1, + sort: ['createdAt:DESC'], + } + ); + return ennoblements.data.length > 0 ? ennoblements.data[0] : null; + }); + } +} + +class Popup { + private static readonly ID_ENNOBLED_AT = 'extended_map_popup_ennobled_at'; + private static readonly ID_LOYALTY = 'extended_map_popup_loyalty'; + + constructor( + private readonly config: ServerConfig, + private readonly unitInfo: UnitInfo, + private readonly currentVillage: Window['game_data']['village'], + private readonly connector: TWHelpConnector + ) {} + + addHandlers() { + window.TWMap.popup._loadVillage = window.TWMap.popup.loadVillage; + window.TWMap.popup.loadVillage = this.loadVillage.bind(this); + window.TWMap.popup._displayForVillage = + window.TWMap.popup.displayForVillage; + window.TWMap.popup.displayForVillage = this.display.bind(this); + } + + private async loadVillage(villageId: string) { + window.TWMap.popup._loadVillage(villageId); + + await this.displayLatestEnnoblementAndLoyalty(parseInt(villageId), false); + } + + private async display(village: { id: string }, x: number, y: number) { + window.TWMap.popup._displayForVillage(village, x, y); + + this.displayArrivalTimes(x, y); + this.displayCanSendNobleman(x, y); + await this.displayLatestEnnoblementAndLoyalty(parseInt(village.id), true); + } + + private displayArrivalTimes(x: number, y: number) { + const dist = calcDistance({ x, y }, this.currentVillage); + if (dist <= 0) { + return; + } + + const imgs = document.querySelectorAll( + '#map_popup #info_content tbody img[src*="unit/unit_"]' + ); + if (imgs.length === 0) { + return; + } + + const tbody = imgs[0].closest('tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + const tr = document.createElement('tr'); + tr.classList.add('center'); + + imgs.forEach((img, idx) => { + if (!(img instanceof HTMLImageElement)) { + return; + } + + for (const [unit, config] of Object.entries(this.unitInfo)) { + if (!img.src.includes(unit)) { + continue; + } + + const td = document.createElement('td'); + td.style.padding = '2px'; + td.style.backgroundColor = idx % 2 === 0 ? '#F8F4E8' : '#DED3B9'; + td.style.maxWidth = '70px'; + td.innerText = addSeconds( + window.Timing.getCurrentServerTime(), + Math.round(dist * config.speed * 60) + ).toLocaleString(); + tr.appendChild(td); + + break; + } + }); + + tbody.appendChild(tr); + } + + private displayCanSendNobleman(x: number, y: number) { + const dist = calcDistance({ x, y }, this.currentVillage); + if (dist <= 0) { + return; + } + + const tbody = document.querySelector('#map_popup #info_content tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + const tr = document.createElement('tr'); + + tr.innerHTML = ` + ${t('Can send a nobleman')}: + ${dist <= this.config.snob.maxDist ? t('Yes') : t('No')} + `; + + tbody.appendChild(tr); + } + + private async displayLatestEnnoblementAndLoyalty( + id: number, + cacheOnly: boolean + ) { + const ennoblement = await this.connector.latestEnnoblement(id, cacheOnly); + + const tbody = document.querySelector('#map_popup #info_content tbody'); + if (!(tbody instanceof HTMLTableSectionElement)) { + return; + } + + [ + { + id: Popup.ID_ENNOBLED_AT, + title: t('Ennobled at'), + value: ennoblement + ? new Date(ennoblement.createdAt).toLocaleString() + : t('Never'), + }, + { + id: Popup.ID_LOYALTY, + title: t('Loyalty'), + value: calcLoyalty( + ennoblement?.createdAt ?? new Date(0), + this.config.speed + ), + }, + ].forEach(({ id, title, value }) => { + let tr = tbody.querySelector('#' + id); + if (!tr) { + tr = document.createElement('tr'); + tr.id = id; + tbody.appendChild(tr); + } + + tr.innerHTML = ` + ${title}: + ${value} +`; + }); + } +} + +class ExtendedMapPopup { + connector: TWHelpConnector; + constructor(client: TWHelpClient) { + this.connector = new TWHelpConnector( + client, + window.game_data.market, + window.game_data.world + ); + } + + async run() { + const config = await this.connector.serverConfig(); + const unitInfo = await this.connector.unitInfo(); + + new Popup( + config, + unitInfo, + window.game_data.village, + this.connector + ).addHandlers(); + } +} + +(async () => { + if (window.game_data.screen !== 'map' || window.game_data.mode !== null) { + return; + } + + await new ExtendedMapPopup( + new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? '') + ) + .run() + .catch((err) => { + console.log(err); + }); +})(); diff --git a/src/extended-player-profile.user.ts b/src/extended-player-profile.user.ts index 79e5ff8..a5f3756 100644 --- a/src/extended-player-profile.user.ts +++ b/src/extended-player-profile.user.ts @@ -119,11 +119,11 @@ class InADayConnector { } enum DialogId { - OTHER_SERVERS = 'kichiyaki_other_servers', - IN_A_DAY_RANKS = 'kichiyaki_in_a_day_ranks', - TRIBE_CHANGES = 'kichiyaki_tribe_changes', - ENNOBLEMENTS = 'kichiyaki_ennoblements', - HISTORY = 'kichiyaki_history', + OTHER_SERVERS = 'extended_player_profile_other_servers', + IN_A_DAY_RANKS = 'extended_player_profile_in_a_day_ranks', + TRIBE_CHANGES = 'extended_player_profile_tribe_changes', + ENNOBLEMENTS = 'extended_player_profile_ennoblements', + HISTORY = 'extended_player_profile_history', } class UI { @@ -589,7 +589,7 @@ class ExtendedPlayerProfile { ); } - async render() { + async run() { const player = await this.twhelpConnector.player(); const latestSnapshot = await this.twhelpConnector.latestSnapshot(); @@ -630,7 +630,7 @@ class ExtendedPlayerProfile { new TWHelpClient(process.env.TWHELP_API_BASE_URL ?? ''), new InADayClient() ) - .render() + .run() .catch((err) => { console.log(err); }); diff --git a/src/global.d.ts b/src/global.d.ts index 813f2ab..1f98d55 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,6 +10,8 @@ declare global { }; village: { id: number; + x: number; + y: number; }; market: string; locale: string; @@ -33,5 +35,8 @@ declare global { show: (id: string, html: string) => void; close: () => void; }; + Timing: { + getCurrentServerTime: () => number; + }; } } diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..019852f --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,65 @@ +import addSeconds from 'date-fns/addSeconds'; +import isAfter from 'date-fns/isAfter'; + +export interface Storage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export class InMemoryStorage { + private readonly data = new Map(); + + getItem(key: string): string | null { + const d = this.data.get(key); + return d ? d : null; + } + + setItem(key: string, value: string) { + this.data.set(key, value); + } + + removeItem(key: string) { + this.data.delete(key); + } +} + +export class Cache { + constructor(private readonly storage: Storage) {} + + // load first tries to load data from storage, if there is no data or it is expired then it executes the given function and caches the result + async load(key: string, ttl: number, func: () => Promise): Promise { + const data = this.get(key); + if (data) { + return data; + } + + const res = await func(); + + this.storage.setItem( + key, + JSON.stringify({ + data: res, + exp: addSeconds(new Date(), ttl), + }) + ); + + return res; + } + + get(key: string): T | null { + try { + const fromStorage = JSON.parse(this.storage.getItem(key) ?? ''); + if (!fromStorage.exp || !fromStorage.data) { + return null; + } + if (isAfter(new Date(), new Date(fromStorage.exp))) { + this.storage.removeItem(key); + return null; + } + return fromStorage.data; + } catch (err) { + return null; + } + } +} diff --git a/src/lib/tw/constants.ts b/src/lib/tw/constants.ts new file mode 100644 index 0000000..825eaf0 --- /dev/null +++ b/src/lib/tw/constants.ts @@ -0,0 +1,2 @@ +export const LOYALTY_AFTER_CONQUER = 25; +export const MAX_LOYALTY = 100; diff --git a/src/lib/tw.ts b/src/lib/tw/in-a-day-client.ts similarity index 99% rename from src/lib/tw.ts rename to src/lib/tw/in-a-day-client.ts index 7754fa1..12204fc 100644 --- a/src/lib/tw.ts +++ b/src/lib/tw/in-a-day-client.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from 'axios'; import random from 'lodash/random'; -import { wait } from '../utils'; +import { wait } from '../../utils'; type InADayScore = { player: { diff --git a/src/lib/tw/index.ts b/src/lib/tw/index.ts new file mode 100644 index 0000000..badc3ce --- /dev/null +++ b/src/lib/tw/index.ts @@ -0,0 +1,3 @@ +export * from './in-a-day-client'; +export * from './utils'; +export * from './constants'; diff --git a/src/lib/tw/utils.ts b/src/lib/tw/utils.ts new file mode 100644 index 0000000..917c5e1 --- /dev/null +++ b/src/lib/tw/utils.ts @@ -0,0 +1,22 @@ +import differenceInMinutes from 'date-fns/differenceInMinutes'; +import { LOYALTY_AFTER_CONQUER, MAX_LOYALTY } from './constants'; + +export const calcLoyalty = ( + ennobledAt: Date | string | number, + speed: number +) => { + const loyalty = + LOYALTY_AFTER_CONQUER + + Math.abs(differenceInMinutes(new Date(ennobledAt), new Date())) * + (speed / 60); + return loyalty > MAX_LOYALTY ? MAX_LOYALTY : Math.floor(loyalty); +}; + +export type Coords = { + x: number; + y: number; +}; + +export const calcDistance = (coords1: Coords, coords2: Coords) => { + return Math.hypot(coords1.x - coords2.x, coords1.y - coords2.y); +}; diff --git a/src/lib/twhelp.ts b/src/lib/twhelp.ts index 5e506c1..b2a1175 100644 --- a/src/lib/twhelp.ts +++ b/src/lib/twhelp.ts @@ -100,6 +100,34 @@ export type PlayerSnapshot = { date: string; }; +export type ServerConfig = { + speed: number; + unitSpeed: number; + snob: { + maxDist: number; + }; +}; + +export type Unit = { + speed: number; +}; + +export type UnitInfo = { + archer: Unit; + axe: Unit; + catapult: Unit; + heavy: Unit; + knight: Unit; + light: Unit; + marcher: Unit; + militia: Unit; + ram: Unit; + snob: Unit; + spear: Unit; + spy: Unit; + sword: Unit; +}; + export type ListResult = { data: T[]; total: number; @@ -146,6 +174,23 @@ export class TWHelpClient { }; } + public async serverConfig( + version: string, + server: string + ): Promise { + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/config` + ); + return resp.data.data; + } + + public async unitInfo(version: string, server: string): Promise { + const resp = await this.client.get( + `/api/v1/versions/${version}/servers/${server}/unit-info` + ); + return resp.data.data; + } + public async player( version: string, server: string, @@ -163,24 +208,9 @@ export class TWHelpClient { 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 queryString = queryParams ? this.buildQueryString(queryParams) : ''; const resp = await this.client.get( - `/api/v1/versions/${version}/servers/${server}/players/${id}/tribe-changes?${params.toString()}` + `/api/v1/versions/${version}/servers/${server}/players/${id}/tribe-changes?${queryString}` ); return { data: resp.data.data, @@ -194,24 +224,9 @@ export class TWHelpClient { 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 queryString = queryParams ? this.buildQueryString(queryParams) : ''; const resp = await this.client.get( - `/api/v1/versions/${version}/servers/${server}/players/${id}/ennoblements?${params.toString()}` + `/api/v1/versions/${version}/servers/${server}/players/${id}/ennoblements?${queryString}` ); return { data: resp.data.data, @@ -225,24 +240,9 @@ export class TWHelpClient { 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 queryString = queryParams ? this.buildQueryString(queryParams) : ''; const resp = await this.client.get( - `/api/v1/versions/${version}/servers/${server}/players/${id}/history?${params.toString()}` + `/api/v1/versions/${version}/servers/${server}/players/${id}/history?${queryString}` ); return { data: resp.data.data, @@ -256,18 +256,9 @@ export class TWHelpClient { id: number, queryParams?: ListPlayerOtherServersParams ): Promise> { - const params = new URLSearchParams(); - - if (queryParams?.limit) { - params.set('limit', queryParams.limit.toString()); - } - - if (queryParams?.offset) { - params.set('offset', queryParams.offset.toString()); - } - + const queryString = queryParams ? this.buildQueryString(queryParams) : ''; const resp = await this.client.get( - `/api/v1/versions/${version}/servers/${server}/players/${id}/other-servers?${params.toString()}` + `/api/v1/versions/${version}/servers/${server}/players/${id}/other-servers?${queryString}` ); return { data: resp.data.data, @@ -275,6 +266,46 @@ export class TWHelpClient { }; } + public async villageEnnoblements( + 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}/villages/${id}/ennoblements?${queryString}` + ); + return { + data: resp.data.data, + total: this.parseTotal(resp.headers), + }; + } + + private buildQueryString( + queryParams: Record + ): string { + const params = new URLSearchParams(); + + for (const [name, val] of Object.entries(queryParams)) { + if (Array.isArray(val)) { + val.forEach((s) => { + params.append(name, s); + }); + continue; + } + + if (val instanceof Date) { + params.set(name, val.toISOString()); + continue; + } + + params.set(name, val.toString()); + } + + return params.toString(); + } + private parseTotal( headers: RawAxiosResponseHeaders | AxiosResponseHeaders ): number {