feat: extended map popup (#8)
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details

Reviewed-on: #8
This commit is contained in:
Dawid Wysokiński 2023-02-04 11:40:38 +00:00
parent 513dbadb00
commit 8a2990b248
14 changed files with 512 additions and 71 deletions

View File

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

View File

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

BIN
docs/extended-map-popup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

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

View File

@ -0,0 +1,2 @@
// Parcel doesn't have an option to rename output files
import './extended-map-popup.user';

View File

@ -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<Ennoblement | null>(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 = `
<td>${t('Can send a nobleman')}:</td>
<td>${dist <= this.config.snob.maxDist ? t('Yes') : t('No')}</td>
`;
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 = `
<td>${title}:</td>
<td>${value}</td>
`;
});
}
}
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);
});
})();

View File

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

5
src/global.d.ts vendored
View File

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

65
src/lib/cache.ts Normal file
View File

@ -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<string, string>();
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<T>(key: string, ttl: number, func: () => Promise<T>): Promise<T> {
const data = this.get<T>(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<T>(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;
}
}
}

2
src/lib/tw/constants.ts Normal file
View File

@ -0,0 +1,2 @@
export const LOYALTY_AFTER_CONQUER = 25;
export const MAX_LOYALTY = 100;

View File

@ -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: {

3
src/lib/tw/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './in-a-day-client';
export * from './utils';
export * from './constants';

22
src/lib/tw/utils.ts Normal file
View File

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

View File

@ -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<T> = {
data: T[];
total: number;
@ -146,6 +174,23 @@ export class TWHelpClient {
};
}
public async serverConfig(
version: string,
server: string
): Promise<ServerConfig> {
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<UnitInfo> {
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<ListResult<TribeChange>> {
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<ListResult<Ennoblement>> {
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<ListResult<PlayerSnapshot>> {
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<ListResult<PlayerWithServer>> {
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<ListResult<Ennoblement>> {
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, string | boolean | number | Date | string[]>
): 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 {