ddnet
Version:
A typescript npm package for interacting with data from ddnet.org
356 lines • 11.3 kB
JavaScript
import axios from 'axios';
import { _Schema_maps_json } from '../../schemas/maps/json.js';
import { _Schema_maps_query } from '../../schemas/maps/query.js';
import { DDNetError, RankAvailableRegion, Tile, Type, dePythonifyTime, splitMappers, timeString } from '../../util.js';
import { CacheManager } from '../other/CacheManager.js';
import { Finish } from '../other/Finish.js';
import { Player } from '../players/Player.js';
import { Mapper } from './Mapper.js';
import { MaxFinish } from './MaxFinish.js';
/**
* Represents a DDNet map.
*
* @example
* ```ts
* const myFavMap = await Map.new('Kobra 4');
*
* console.log(myFavMap.webPreviewUrl); // "https://ddnet.org/mappreview/?map=Kobra+4"
* console.log(myFavMap.difficulty); // 4
* console.log(myFavMap.maxFinishes[0]);
* // MapMaxFinish {
* // rank: 1,
* // player: 'nameless tee',
* // count: 659,
* // timeSeconds: 1754617.6977539062,
* // timeString: '487:23:37',
* // minTimestamp: 1438545584000,
* // maxTimestamp: 1714287869000
* // }
* ```
*/
export class Map {
//#region Declarations
/**
* Raw data for this map.
*/
#rawData; // Marked private with vanilla JS syntax for better logging.
/**
* Map responses cache. (24h default TTL)
*/
static cache = new CacheManager('map-cache', 24 * 60 * 60 * 1000); // 24h
/**
* Sets the TTL (Time-To-Live) for objects in cache.
*/
static setTTL = this.cache.setTTL;
/**
* Clears the {@link Map.cache}.
*/
static clearCache = this.cache.clearCache;
/**
* The name of this map.
*/
name;
/**
* The url of this map on ddnet.org
*/
url;
/**
* The direct url of this map's thumbnail image.
*/
thumbnailUrl;
/**
* The url to the interactive web preview of this map.
*/
webPreviewUrl;
/**
* The type of this map.
*/
type;
/**
* Amount of points awarded for completing this map.
*/
points;
/**
* Star difficulty of this map.
*/
difficulty;
/**
* Authors of this map.
*/
mappers;
/**
* Release timestamp of this map.
*/
releasedTimestamp;
/**
* Biggest team to ever finish this map.
*/
biggestTeam;
/**
* The width of this map.
*/
width;
/**
* The height of this map.
*/
height;
/**
* Array of tiles used in this map.
*/
tiles;
/**
* The region from which ranks are pulled. `null` for global ranks.
*/
rankSource;
/**
* Team finishes for this map.
*/
teamFinishes;
/**
* Ranks for this map.
*/
finishes;
/**
* Top of most amount of finishes on this map.
*/
maxFinishes;
/**
* The average finish time of this map in seconds.
*/
medianTimeSeconds;
/**
* String formatted average finish time.
*
* @example "03:23"
*/
medianTimeString;
/**
* Timestamp for the first recorded finish on this map.
*/
firstFinishTimestamp;
/**
* Timestamp for the last recorded finish on this map.
*/
lastFinishTimestamp;
/**
* The total amount of times this map has been finished by any player.
*/
finishCount;
/**
* The total amount of players that have ever finished this map.
*/
finishersCount;
//#endregion
/**
* Create a new instance of {@link Map} from API data.
* Not intended to be used, use {@link new Map.new} instead.
*/
constructor(
/**
* The raw data for this map.
*/
rawData,
/**
* The region to pull ranks from. `null` for global ranks.
*/
rankSource) {
this.populate(rawData, rankSource);
}
/**
* Fetch, parse and construct a new {@link Map} instance.
*/
static async new(
/**
* The name or ddnet.org url of this map.
*/
nameOrUrl,
/**
* The region to pull ranks from. Omit for global ranks.
*
* @remarks
* Ignored if map url is used instead of map name.
*/
rankSource,
/**
* Wether to bypass the map data cache.
*/
bypassCache = false) {
const response = await this.makeRequest(nameOrUrl, rankSource, bypassCache);
if (response instanceof DDNetError)
throw response;
const parsed = this.parseObject(response.data);
if (!parsed.success)
throw parsed.error;
return new this(parsed.data, rankSource ?? null);
}
/**
* Parse an object using the {@link _Schema_maps_json map raw data zod schema}.
*/
static parseObject(
/**
* The object to be parsed.
*/
data) {
const parsed = _Schema_maps_json.safeParse(data);
if (parsed.success)
return { success: true, data: parsed.data };
return { success: false, error: new DDNetError(parsed.error.message, parsed.error) };
}
/**
* Fetch the map data from the API.
*/
static async makeRequest(
/**
* The name or url of the map.
*/
nameOrUrl,
/**
* The region to pull ranks from. Omit for global ranks.
*
* @remarks
* Ignored if map url is used instead of map name.
*/
rankSource,
/**
* Wether to bypass the cache.
*/
force = false) {
let url = nameOrUrl.startsWith('https://ddnet.org/maps/') ? nameOrUrl : `https://ddnet.org/maps/?json=${encodeURIComponent(nameOrUrl)}`;
if (rankSource && nameOrUrl !== url) {
url += `&country=${rankSource}`;
}
if (!force) {
if (await this.cache.has(url)) {
const data = await this.cache.get(url);
if (data)
return { data, fromCache: true };
}
}
const response = await axios.get(url).catch((err) => new DDNetError(err.cause?.message, err));
if (response instanceof DDNetError)
return response;
const data = response.data;
if (typeof data === 'string')
return new DDNetError(`Invalid response!`, data);
await this.cache.set(url, data);
return { data, fromCache: false };
}
/**
* Populate the object with the raw map data.
*/
populate(
/**
* The raw map data.
*/
rawData,
/**
* The region to pull ranks from. `null` for global ranks.
*/
rankSource) {
this.#rawData = rawData;
this.rankSource = rankSource;
this.url = this.#rawData.website;
if (this.rankSource) {
this.url = this.url.replace('/maps/', `/maps/${this.rankSource.toLowerCase()}/`);
}
this.name = this.#rawData.name;
this.thumbnailUrl = this.#rawData.thumbnail;
this.webPreviewUrl = this.#rawData.web_preview;
this.type = !Object.values(Type).includes(this.#rawData.type) ? Type.unknown : this.#rawData.type;
this.points = this.#rawData.points;
this.difficulty = this.#rawData.difficulty;
this.mappers = splitMappers(this.#rawData.mapper).map(mapperName => new Mapper({ name: mapperName }));
this.releasedTimestamp = this.#rawData.release ? dePythonifyTime(this.#rawData.release) : null;
this.biggestTeam = this.#rawData.biggest_team;
this.width = this.#rawData.width;
this.height = this.#rawData.height;
this.tiles = this.#rawData.tiles.map(tile => {
const values = Object.values(Tile).filter(t => t !== Tile.UNKNOWN_TILE);
return !values.includes(tile) ? Tile.UNKNOWN_TILE : tile;
});
this.teamFinishes = this.#rawData.team_ranks.map(rank => new Finish({
region: !Object.values(RankAvailableRegion).includes(rank.country) ? RankAvailableRegion.UNK : rank.country,
mapName: this.name,
players: rank.players,
rank: {
placement: rank.rank,
points: this.points
},
timeSeconds: rank.time,
timestamp: dePythonifyTime(rank.timestamp)
}));
this.finishes = this.#rawData.ranks.map(rank => new Finish({
region: !Object.values(RankAvailableRegion).includes(rank.country) ? RankAvailableRegion.UNK : rank.country,
mapName: this.name,
players: [rank.player],
rank: {
placement: rank.rank,
points: this.points
},
timeSeconds: rank.time,
timestamp: dePythonifyTime(rank.timestamp)
}));
this.maxFinishes = this.#rawData.max_finishes.map(mf => new MaxFinish({ maxTimestamp: dePythonifyTime(mf.max_timestamp), minTimestamp: dePythonifyTime(mf.min_timestamp), count: mf.num, player: mf.player, rank: mf.rank, time: mf.time }));
this.medianTimeSeconds = this.#rawData.median_time ?? -1;
this.medianTimeString = timeString(this.medianTimeSeconds);
this.firstFinishTimestamp = this.#rawData.first_finish ? dePythonifyTime(this.#rawData.first_finish) : null;
this.lastFinishTimestamp = this.#rawData.last_finish ? dePythonifyTime(this.#rawData.last_finish) : null;
this.finishCount = this.#rawData.finishes ?? 0;
this.finishersCount = this.#rawData.finishers ?? 0;
return this;
}
/**
* Refresh the data for this map.
*/
async refresh() {
const data = await Map.makeRequest(this.name, this.rankSource, true);
if (data instanceof DDNetError)
throw new DDNetError(`Failed to refresh ${this}`, data);
const parsed = Map.parseObject(data.data);
if (!parsed.success)
throw new DDNetError(`Failed to refresh ${this}`, parsed.error);
return this.populate(parsed.data, this.rankSource);
}
/**
* Returns the name and url of this map in markdown format.
*/
toString() {
return `[${this.name}](${this.url})`;
}
/**
* Search for a map.
*/
static async search(
/**
* The value to search for.
*/
value,
/**
* The region to pull ranks from in the `toMap` function from the returned value. Omit for global ranks.
*/
rankSource,
/**
* Wether to bypass the cache.
*/
force = false) {
const data = await Map.makeRequest(`https://ddnet.org/maps/?query=${encodeURIComponent(value)}`, null, force);
if (data instanceof DDNetError)
throw data;
const parsed = _Schema_maps_query.safeParse(data.data);
if (!parsed.success)
throw new DDNetError(`Failed to parse received data.`, parsed.error);
if (parsed.data.length === 0)
return null;
return parsed.data.map(map => ({
name: map.name,
mappers: splitMappers(map.mapper).map(mapperName => ({
name: mapperName.trim(),
toMapper: () => new Mapper({ name: mapperName.trim() }),
toPlayer: async () => await Player.new(mapperName.trim())
})),
type: !Object.values(Type).includes(map.type) ? Type.unknown : map.type,
toMap: async () => await Map.new(map.name, rankSource)
}));
}
}
//# sourceMappingURL=Map.js.map