(Feat): Initial Commit
This commit is contained in:
@@ -1,45 +1,127 @@
|
||||
/**
|
||||
* PikaNetwork API Cache System
|
||||
* Implements TTL-based caching for API responses
|
||||
* Advanced TTL-based caching with LRU eviction, statistics, and persistence support
|
||||
*/
|
||||
|
||||
import type { ProfileResponse, ClanResponse, LeaderboardResponse } from './types.ts';
|
||||
import type {
|
||||
ProfileResponse,
|
||||
ClanResponse,
|
||||
LeaderboardResponse,
|
||||
StaffList,
|
||||
VoteLeaderboard,
|
||||
ServerStatus,
|
||||
Punishment,
|
||||
} from './types.ts';
|
||||
|
||||
// ============================================================================
|
||||
// Cache Entry Types
|
||||
// ============================================================================
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expires: number;
|
||||
createdAt: number;
|
||||
hits: number;
|
||||
lastAccess: number;
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
size: number;
|
||||
evictions: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
interface CacheOptions {
|
||||
maxSize?: number;
|
||||
defaultTTL?: number;
|
||||
enableLRU?: boolean;
|
||||
onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Advanced TTL Cache with LRU Eviction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic cache with TTL support
|
||||
* Generic cache with TTL support and LRU eviction
|
||||
*/
|
||||
class TTLCache<T> {
|
||||
class AdvancedCache<T> {
|
||||
private cache = new Map<string, CacheEntry<T>>();
|
||||
private readonly defaultTTL: number;
|
||||
private readonly maxSize: number;
|
||||
private readonly enableLRU: boolean;
|
||||
private readonly onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
|
||||
|
||||
constructor(defaultTTL: number) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
// Statistics
|
||||
private stats = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
evictions: 0,
|
||||
};
|
||||
|
||||
constructor(options: CacheOptions = {}) {
|
||||
this.defaultTTL = options.defaultTTL ?? 3600000; // 1 hour default
|
||||
this.maxSize = options.maxSize ?? 1000;
|
||||
this.enableLRU = options.enableLRU ?? true;
|
||||
this.onEvict = options.onEvict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry has expired
|
||||
*/
|
||||
private isExpired(expires: number): boolean {
|
||||
return Date.now() > expires;
|
||||
private isExpired(entry: CacheEntry<T>): boolean {
|
||||
return Date.now() > entry.expires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the least recently used entry
|
||||
*/
|
||||
private evictLRU(): void {
|
||||
if (!this.enableLRU || this.cache.size === 0) return;
|
||||
|
||||
let oldestKey: string | null = null;
|
||||
let oldestAccess = Infinity;
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (entry.lastAccess < oldestAccess) {
|
||||
oldestAccess = entry.lastAccess;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
this.stats.evictions++;
|
||||
this.onEvict?.(oldestKey, 'lru');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache
|
||||
*/
|
||||
get(key: string): T | null {
|
||||
const entry = this.cache.get(key.toLowerCase());
|
||||
if (!entry) return null;
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const entry = this.cache.get(normalizedKey);
|
||||
|
||||
if (this.isExpired(entry.expires)) {
|
||||
this.cache.delete(key.toLowerCase());
|
||||
if (!entry) {
|
||||
this.stats.misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(normalizedKey);
|
||||
this.onEvict?.(normalizedKey, 'expired');
|
||||
this.stats.misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update access stats
|
||||
entry.hits++;
|
||||
entry.lastAccess = Date.now();
|
||||
this.stats.hits++;
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
@@ -47,9 +129,20 @@ class TTLCache<T> {
|
||||
* Set a value in the cache
|
||||
*/
|
||||
set(key: string, data: T, ttl?: number): void {
|
||||
this.cache.set(key.toLowerCase(), {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
|
||||
// Evict if at max capacity
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(normalizedKey)) {
|
||||
this.evictLRU();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.cache.set(normalizedKey, {
|
||||
data,
|
||||
expires: Date.now() + (ttl ?? this.defaultTTL),
|
||||
expires: now + (ttl ?? this.defaultTTL),
|
||||
createdAt: now,
|
||||
hits: 0,
|
||||
lastAccess: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,11 +150,14 @@ class TTLCache<T> {
|
||||
* Check if a key exists and is not expired
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
const entry = this.cache.get(key.toLowerCase());
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const entry = this.cache.get(normalizedKey);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
if (this.isExpired(entry.expires)) {
|
||||
this.cache.delete(key.toLowerCase());
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(normalizedKey);
|
||||
this.onEvict?.(normalizedKey, 'expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -72,7 +168,13 @@ class TTLCache<T> {
|
||||
* Delete a specific key
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
return this.cache.delete(key.toLowerCase());
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const existed = this.cache.has(normalizedKey);
|
||||
if (existed) {
|
||||
this.cache.delete(normalizedKey);
|
||||
this.onEvict?.(normalizedKey, 'manual');
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +182,7 @@ class TTLCache<T> {
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.stats = { hits: 0, misses: 0, evictions: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,90 +202,200 @@ class TTLCache<T> {
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now > entry.expires) {
|
||||
this.cache.delete(key);
|
||||
this.onEvict?.(key, 'expired');
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats(): CacheStats {
|
||||
const total = this.stats.hits + this.stats.misses;
|
||||
return {
|
||||
hits: this.stats.hits,
|
||||
misses: this.stats.misses,
|
||||
size: this.cache.size,
|
||||
evictions: this.stats.evictions,
|
||||
hitRate: total > 0 ? this.stats.hits / total : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys
|
||||
*/
|
||||
keys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entry metadata (without data)
|
||||
*/
|
||||
getMetadata(key: string): Omit<CacheEntry<T>, 'data'> | null {
|
||||
const entry = this.cache.get(key.toLowerCase());
|
||||
if (!entry) return null;
|
||||
const { data, ...metadata } = entry;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TTL for an existing entry
|
||||
*/
|
||||
touch(key: string, ttl?: number): boolean {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const entry = this.cache.get(normalizedKey);
|
||||
if (!entry || this.isExpired(entry)) return false;
|
||||
|
||||
entry.expires = Date.now() + (ttl ?? this.defaultTTL);
|
||||
entry.lastAccess = Date.now();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PikaNetwork API Cache Manager
|
||||
// ============================================================================
|
||||
|
||||
export interface PikaCacheOptions {
|
||||
profileTTL?: number;
|
||||
clanTTL?: number;
|
||||
leaderboardTTL?: number;
|
||||
staffTTL?: number;
|
||||
voteTTL?: number;
|
||||
serverTTL?: number;
|
||||
punishmentTTL?: number;
|
||||
maxProfileEntries?: number;
|
||||
maxClanEntries?: number;
|
||||
maxLeaderboardEntries?: number;
|
||||
cleanupIntervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PikaNetwork API Cache
|
||||
* Manages caching for profiles, clans, and leaderboards
|
||||
* Manages caching for all API responses with configurable TTLs
|
||||
*/
|
||||
export class PikaCache {
|
||||
private profiles: TTLCache<ProfileResponse>;
|
||||
private clans: TTLCache<ClanResponse>;
|
||||
private leaderboards: TTLCache<LeaderboardResponse>;
|
||||
private profiles: AdvancedCache<ProfileResponse>;
|
||||
private clans: AdvancedCache<ClanResponse>;
|
||||
private leaderboards: AdvancedCache<LeaderboardResponse>;
|
||||
private staff: AdvancedCache<StaffList>;
|
||||
private votes: AdvancedCache<VoteLeaderboard>;
|
||||
private server: AdvancedCache<ServerStatus>;
|
||||
private punishments: AdvancedCache<Punishment[]>;
|
||||
private generic: AdvancedCache<unknown>;
|
||||
|
||||
private cleanupInterval: number | undefined;
|
||||
private readonly options: Required<PikaCacheOptions>;
|
||||
|
||||
constructor(ttl: number = 3600000) {
|
||||
this.profiles = new TTLCache<ProfileResponse>(ttl);
|
||||
this.clans = new TTLCache<ClanResponse>(ttl);
|
||||
this.leaderboards = new TTLCache<LeaderboardResponse>(ttl);
|
||||
constructor(options: PikaCacheOptions = {}) {
|
||||
this.options = {
|
||||
profileTTL: options.profileTTL ?? 600000, // 10 minutes
|
||||
clanTTL: options.clanTTL ?? 900000, // 15 minutes
|
||||
leaderboardTTL: options.leaderboardTTL ?? 300000, // 5 minutes
|
||||
staffTTL: options.staffTTL ?? 3600000, // 1 hour
|
||||
voteTTL: options.voteTTL ?? 1800000, // 30 minutes
|
||||
serverTTL: options.serverTTL ?? 60000, // 1 minute
|
||||
punishmentTTL: options.punishmentTTL ?? 600000, // 10 minutes
|
||||
maxProfileEntries: options.maxProfileEntries ?? 500,
|
||||
maxClanEntries: options.maxClanEntries ?? 100,
|
||||
maxLeaderboardEntries: options.maxLeaderboardEntries ?? 2000,
|
||||
cleanupIntervalMs: options.cleanupIntervalMs ?? 300000, // 5 minutes
|
||||
};
|
||||
|
||||
// Run cleanup every 5 minutes
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 300000);
|
||||
this.profiles = new AdvancedCache<ProfileResponse>({
|
||||
defaultTTL: this.options.profileTTL,
|
||||
maxSize: this.options.maxProfileEntries,
|
||||
});
|
||||
|
||||
this.clans = new AdvancedCache<ClanResponse>({
|
||||
defaultTTL: this.options.clanTTL,
|
||||
maxSize: this.options.maxClanEntries,
|
||||
});
|
||||
|
||||
this.leaderboards = new AdvancedCache<LeaderboardResponse>({
|
||||
defaultTTL: this.options.leaderboardTTL,
|
||||
maxSize: this.options.maxLeaderboardEntries,
|
||||
});
|
||||
|
||||
this.staff = new AdvancedCache<StaffList>({
|
||||
defaultTTL: this.options.staffTTL,
|
||||
maxSize: 10,
|
||||
});
|
||||
|
||||
this.votes = new AdvancedCache<VoteLeaderboard>({
|
||||
defaultTTL: this.options.voteTTL,
|
||||
maxSize: 10,
|
||||
});
|
||||
|
||||
this.server = new AdvancedCache<ServerStatus>({
|
||||
defaultTTL: this.options.serverTTL,
|
||||
maxSize: 20,
|
||||
});
|
||||
|
||||
this.punishments = new AdvancedCache<Punishment[]>({
|
||||
defaultTTL: this.options.punishmentTTL,
|
||||
maxSize: 200,
|
||||
});
|
||||
|
||||
this.generic = new AdvancedCache<unknown>({
|
||||
defaultTTL: 300000,
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// Run cleanup periodically
|
||||
this.cleanupInterval = setInterval(
|
||||
() => this.cleanup(),
|
||||
this.options.cleanupIntervalMs
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile Cache
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get a cached profile
|
||||
*/
|
||||
getProfile(username: string): ProfileResponse | null {
|
||||
return this.profiles.get(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a profile
|
||||
*/
|
||||
setProfile(username: string, data: ProfileResponse, ttl?: number): void {
|
||||
this.profiles.set(username, data, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile is cached
|
||||
*/
|
||||
hasProfile(username: string): boolean {
|
||||
return this.profiles.has(username);
|
||||
}
|
||||
|
||||
deleteProfile(username: string): boolean {
|
||||
return this.profiles.delete(username);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clan Cache
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get a cached clan
|
||||
*/
|
||||
getClan(name: string): ClanResponse | null {
|
||||
return this.clans.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a clan
|
||||
*/
|
||||
setClan(name: string, data: ClanResponse, ttl?: number): void {
|
||||
this.clans.set(name, data, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a clan is cached
|
||||
*/
|
||||
hasClan(name: string): boolean {
|
||||
return this.clans.has(name);
|
||||
}
|
||||
|
||||
deleteClan(name: string): boolean {
|
||||
return this.clans.delete(name);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Leaderboard Cache
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate a cache key for leaderboard data
|
||||
*/
|
||||
private getLeaderboardKey(
|
||||
username: string,
|
||||
gamemode: string,
|
||||
@@ -192,9 +405,6 @@ export class PikaCache {
|
||||
return `${username}:${gamemode}:${mode}:${interval}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached leaderboard data
|
||||
*/
|
||||
getLeaderboard(
|
||||
username: string,
|
||||
gamemode: string,
|
||||
@@ -205,9 +415,6 @@ export class PikaCache {
|
||||
return this.leaderboards.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache leaderboard data
|
||||
*/
|
||||
setLeaderboard(
|
||||
username: string,
|
||||
gamemode: string,
|
||||
@@ -220,9 +427,6 @@ export class PikaCache {
|
||||
this.leaderboards.set(key, data, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if leaderboard data is cached
|
||||
*/
|
||||
hasLeaderboard(
|
||||
username: string,
|
||||
gamemode: string,
|
||||
@@ -233,6 +437,93 @@ export class PikaCache {
|
||||
return this.leaderboards.has(key);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Staff Cache
|
||||
// =========================================================================
|
||||
|
||||
getStaff(): StaffList | null {
|
||||
return this.staff.get('staff');
|
||||
}
|
||||
|
||||
setStaff(data: StaffList, ttl?: number): void {
|
||||
this.staff.set('staff', data, ttl);
|
||||
}
|
||||
|
||||
hasStaff(): boolean {
|
||||
return this.staff.has('staff');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Vote Leaderboard Cache
|
||||
// =========================================================================
|
||||
|
||||
getVotes(): VoteLeaderboard | null {
|
||||
return this.votes.get('votes');
|
||||
}
|
||||
|
||||
setVotes(data: VoteLeaderboard, ttl?: number): void {
|
||||
this.votes.set('votes', data, ttl);
|
||||
}
|
||||
|
||||
hasVotes(): boolean {
|
||||
return this.votes.has('votes');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Server Status Cache
|
||||
// =========================================================================
|
||||
|
||||
getServer(ip: string): ServerStatus | null {
|
||||
return this.server.get(ip);
|
||||
}
|
||||
|
||||
setServer(ip: string, data: ServerStatus, ttl?: number): void {
|
||||
this.server.set(ip, data, ttl);
|
||||
}
|
||||
|
||||
hasServer(ip: string): boolean {
|
||||
return this.server.has(ip);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Punishments Cache
|
||||
// =========================================================================
|
||||
|
||||
private getPunishmentKey(username: string, filter?: string): string {
|
||||
return `${username}:${filter ?? 'all'}`;
|
||||
}
|
||||
|
||||
getPunishments(username: string, filter?: string): Punishment[] | null {
|
||||
const key = this.getPunishmentKey(username, filter);
|
||||
return this.punishments.get(key);
|
||||
}
|
||||
|
||||
setPunishments(username: string, data: Punishment[], filter?: string, ttl?: number): void {
|
||||
const key = this.getPunishmentKey(username, filter);
|
||||
this.punishments.set(key, data, ttl);
|
||||
}
|
||||
|
||||
hasPunishments(username: string, filter?: string): boolean {
|
||||
const key = this.getPunishmentKey(username, filter);
|
||||
return this.punishments.has(key);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Generic Cache (for custom data)
|
||||
// =========================================================================
|
||||
|
||||
getGeneric<T>(key: string): T | null {
|
||||
return this.generic.get(key) as T | null;
|
||||
}
|
||||
|
||||
setGeneric<T>(key: string, data: T, ttl?: number): void {
|
||||
this.generic.set(key, data, ttl);
|
||||
}
|
||||
|
||||
hasGeneric(key: string): boolean {
|
||||
return this.generic.has(key);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// General Methods
|
||||
// =========================================================================
|
||||
@@ -244,23 +535,98 @@ export class PikaCache {
|
||||
this.profiles.clear();
|
||||
this.clans.clear();
|
||||
this.leaderboards.clear();
|
||||
this.staff.clear();
|
||||
this.votes.clear();
|
||||
this.server.clear();
|
||||
this.punishments.clear();
|
||||
this.generic.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific cache type
|
||||
*/
|
||||
clearType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments' | 'generic'): void {
|
||||
this[type].clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run cleanup on all caches
|
||||
*/
|
||||
cleanup(): { profiles: number; clans: number; leaderboards: number } {
|
||||
return {
|
||||
cleanup(): {
|
||||
profiles: number;
|
||||
clans: number;
|
||||
leaderboards: number;
|
||||
staff: number;
|
||||
votes: number;
|
||||
server: number;
|
||||
punishments: number;
|
||||
generic: number;
|
||||
total: number;
|
||||
} {
|
||||
const result = {
|
||||
profiles: this.profiles.cleanup(),
|
||||
clans: this.clans.cleanup(),
|
||||
leaderboards: this.leaderboards.cleanup(),
|
||||
staff: this.staff.cleanup(),
|
||||
votes: this.votes.cleanup(),
|
||||
server: this.server.cleanup(),
|
||||
punishments: this.punishments.cleanup(),
|
||||
generic: this.generic.cleanup(),
|
||||
total: 0,
|
||||
};
|
||||
result.total = Object.values(result).reduce((a, b) => a + b, 0) - result.total;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats(): { profiles: number; clans: number; leaderboards: number } {
|
||||
getStats(): {
|
||||
profiles: CacheStats;
|
||||
clans: CacheStats;
|
||||
leaderboards: CacheStats;
|
||||
staff: CacheStats;
|
||||
votes: CacheStats;
|
||||
server: CacheStats;
|
||||
punishments: CacheStats;
|
||||
generic: CacheStats;
|
||||
totalSize: number;
|
||||
totalHitRate: number;
|
||||
} {
|
||||
const profileStats = this.profiles.getStats();
|
||||
const clanStats = this.clans.getStats();
|
||||
const leaderboardStats = this.leaderboards.getStats();
|
||||
const staffStats = this.staff.getStats();
|
||||
const voteStats = this.votes.getStats();
|
||||
const serverStats = this.server.getStats();
|
||||
const punishmentStats = this.punishments.getStats();
|
||||
const genericStats = this.generic.getStats();
|
||||
|
||||
const totalHits = profileStats.hits + clanStats.hits + leaderboardStats.hits +
|
||||
staffStats.hits + voteStats.hits + serverStats.hits + punishmentStats.hits + genericStats.hits;
|
||||
const totalMisses = profileStats.misses + clanStats.misses + leaderboardStats.misses +
|
||||
staffStats.misses + voteStats.misses + serverStats.misses + punishmentStats.misses + genericStats.misses;
|
||||
const totalRequests = totalHits + totalMisses;
|
||||
|
||||
return {
|
||||
profiles: profileStats,
|
||||
clans: clanStats,
|
||||
leaderboards: leaderboardStats,
|
||||
staff: staffStats,
|
||||
votes: voteStats,
|
||||
server: serverStats,
|
||||
punishments: punishmentStats,
|
||||
generic: genericStats,
|
||||
totalSize: profileStats.size + clanStats.size + leaderboardStats.size +
|
||||
staffStats.size + voteStats.size + serverStats.size + punishmentStats.size + genericStats.size,
|
||||
totalHitRate: totalRequests > 0 ? totalHits / totalRequests : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simple stats (backwards compatibility)
|
||||
*/
|
||||
getSimpleStats(): { profiles: number; clans: number; leaderboards: number } {
|
||||
return {
|
||||
profiles: this.profiles.size,
|
||||
clans: this.clans.size,
|
||||
@@ -274,6 +640,7 @@ export class PikaCache {
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* PikaNetwork API Client
|
||||
* Modern TypeScript implementation without proxy support
|
||||
* Based on pikanetwork.js but rewritten with improvements
|
||||
* Full TypeScript implementation based on pikanetwork.js
|
||||
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
|
||||
*/
|
||||
|
||||
import { PikaCache } from './cache.ts';
|
||||
import { PikaCache, type PikaCacheOptions } from './cache.ts';
|
||||
import type {
|
||||
ProfileResponse,
|
||||
ClanResponse,
|
||||
LeaderboardResponse,
|
||||
GameMode,
|
||||
Interval,
|
||||
PikaAPIOptions,
|
||||
BedWarsStats,
|
||||
SkyWarsStats,
|
||||
MinimalLeaderboardData,
|
||||
@@ -25,12 +24,50 @@ import type {
|
||||
TotalLeaderboardOptions,
|
||||
JoinInfo,
|
||||
MiscInfo,
|
||||
PlayerRank,
|
||||
Rank,
|
||||
ClanInfo,
|
||||
} from './types.ts';
|
||||
|
||||
// ============================================================================
|
||||
// API Options
|
||||
// ============================================================================
|
||||
|
||||
export interface PikaAPIOptions {
|
||||
/** Request timeout in milliseconds (default: 10000) */
|
||||
timeout?: number;
|
||||
/** User agent string for requests */
|
||||
userAgent?: string;
|
||||
/** Rate limit delay between batch requests in ms (default: 200) */
|
||||
rateLimitDelay?: number;
|
||||
/** Batch size for bulk operations (default: 5) */
|
||||
batchSize?: number;
|
||||
/** Cache options */
|
||||
cache?: PikaCacheOptions;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ratio Data Types
|
||||
// ============================================================================
|
||||
|
||||
export interface RatioData {
|
||||
killDeathRatio: number;
|
||||
kdrInfo: string;
|
||||
winLossRatio: number;
|
||||
wlrInfo: string;
|
||||
winPlayRatio: number;
|
||||
wprInfo: string;
|
||||
arrowsHitShotRatio: number;
|
||||
ahsrInfo: string;
|
||||
finalKillDeathRatio?: number;
|
||||
fkdrInfo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PikaNetwork API Client
|
||||
* Provides methods to fetch player profiles, clan information, leaderboard data,
|
||||
* punishments, staff lists, vote leaderboards, and server status
|
||||
* Full-featured client for PikaNetwork stats API and forum scraping
|
||||
*/
|
||||
export class PikaNetworkAPI {
|
||||
private readonly baseUrl = 'https://stats.pika-network.net/api';
|
||||
@@ -38,6 +75,9 @@ export class PikaNetworkAPI {
|
||||
private readonly cache: PikaCache;
|
||||
private readonly timeout: number;
|
||||
private readonly userAgent: string;
|
||||
private readonly rateLimitDelay: number;
|
||||
private readonly batchSize: number;
|
||||
private readonly debug: boolean;
|
||||
|
||||
// Staff roles for scraping
|
||||
private readonly staffRoles = new Set([
|
||||
@@ -53,10 +93,30 @@ export class PikaNetworkAPI {
|
||||
ban: 'bans',
|
||||
};
|
||||
|
||||
// Request statistics
|
||||
private stats = {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
totalLatency: 0,
|
||||
};
|
||||
|
||||
constructor(options: PikaAPIOptions = {}) {
|
||||
this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default
|
||||
this.timeout = options.timeout ?? 10000; // 10 seconds default
|
||||
this.cache = new PikaCache(options.cache ?? {});
|
||||
this.timeout = options.timeout ?? 10000;
|
||||
this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0';
|
||||
this.rateLimitDelay = options.rateLimitDelay ?? 200;
|
||||
this.batchSize = options.batchSize ?? 5;
|
||||
this.debug = options.debug ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message
|
||||
*/
|
||||
private log(message: string, ...args: unknown[]): void {
|
||||
if (this.debug) {
|
||||
console.log(`[PikaAPI] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -66,12 +126,18 @@ export class PikaNetworkAPI {
|
||||
/**
|
||||
* Make an HTTP request with timeout and error handling
|
||||
*/
|
||||
private async request<T>(endpoint: string): Promise<T | null> {
|
||||
private async request<T>(endpoint: string, baseUrl?: string): Promise<T | null> {
|
||||
const url = `${baseUrl ?? this.baseUrl}${endpoint}`;
|
||||
const startTime = Date.now();
|
||||
this.stats.totalRequests++;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
this.log(`Requesting: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': this.userAgent,
|
||||
@@ -80,9 +146,12 @@ export class PikaNetworkAPI {
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const latency = Date.now() - startTime;
|
||||
this.stats.totalLatency += latency;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[PikaAPI] Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
|
||||
this.stats.failedRequests++;
|
||||
this.log(`Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,23 +160,28 @@ export class PikaNetworkAPI {
|
||||
|
||||
// Handle empty responses
|
||||
if (!text || text.trim() === '') {
|
||||
console.warn(`[PikaAPI] Empty response for ${endpoint}`);
|
||||
this.stats.failedRequests++;
|
||||
this.log(`Empty response for ${endpoint}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse JSON
|
||||
try {
|
||||
this.stats.successfulRequests++;
|
||||
this.log(`Request successful (${latency}ms): ${endpoint}`);
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
console.error(`[PikaAPI] Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
|
||||
this.stats.failedRequests++;
|
||||
this.log(`Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.stats.failedRequests++;
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error(`[PikaAPI] Request timeout for ${endpoint}`);
|
||||
this.log(`Request timeout for ${endpoint}`);
|
||||
} else {
|
||||
console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`);
|
||||
this.log(`Request error for ${endpoint}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -131,7 +205,10 @@ export class PikaNetworkAPI {
|
||||
async getProfile(username: string): Promise<ProfileResponse | null> {
|
||||
// Check cache first
|
||||
const cached = this.cache.getProfile(username);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
this.log(`Cache hit for profile: ${username}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
|
||||
@@ -152,6 +229,100 @@ export class PikaNetworkAPI {
|
||||
return profile !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friend list for a player
|
||||
*/
|
||||
async getFriendList(username: string): Promise<string[]> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return [];
|
||||
return profile.friends.map((f) => f.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get levelling info for a player
|
||||
*/
|
||||
async getLevellingInfo(username: string): Promise<PlayerRank | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return profile.rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild/clan info for a player
|
||||
*/
|
||||
async getGuildInfo(username: string): Promise<ClanInfo | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return profile.clan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rank info for a player (display ranks like VIP, MVP, etc.)
|
||||
*/
|
||||
async getRankInfo(username: string): Promise<Rank[] | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return profile.ranks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get miscellaneous info for a player
|
||||
*/
|
||||
async getMiscInfo(username: string): Promise<MiscInfo | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
discordBoosting: profile.discord_boosting,
|
||||
discordVerified: profile.discord_verified,
|
||||
emailVerified: profile.email_verified,
|
||||
username: profile.username,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get join info for a player
|
||||
*/
|
||||
async getJoinInfo(username: string): Promise<JoinInfo | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
|
||||
const lastJoinDate = new Date(profile.lastSeen);
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
};
|
||||
|
||||
// Try to get oldest punishment date for estimated first join
|
||||
let estimatedFirstJoin: Date | null = null;
|
||||
let estimatedFirstJoinFormatted = 'N/A';
|
||||
|
||||
try {
|
||||
const punishments = await this.getPunishments(username);
|
||||
if (punishments.length > 0) {
|
||||
const dates = punishments
|
||||
.map((p) => new Date(p.date))
|
||||
.filter((d) => !isNaN(d.getTime()));
|
||||
if (dates.length > 0) {
|
||||
estimatedFirstJoin = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||
estimatedFirstJoinFormatted = estimatedFirstJoin.toLocaleString('en-US', formatOptions);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore punishment fetch errors
|
||||
}
|
||||
|
||||
return {
|
||||
lastJoin: profile.lastSeen,
|
||||
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
|
||||
estimatedFirstJoin,
|
||||
estimatedFirstJoinFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clan Methods
|
||||
// =========================================================================
|
||||
@@ -200,7 +371,10 @@ export class PikaNetworkAPI {
|
||||
): Promise<LeaderboardResponse | null> {
|
||||
// Check cache first
|
||||
const cached = this.cache.getLeaderboard(username, gamemode, mode, interval);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
this.log(`Cache hit for leaderboard: ${username}/${gamemode}/${mode}/${interval}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
const params = new URLSearchParams({
|
||||
@@ -221,6 +395,51 @@ export class PikaNetworkAPI {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ratio data for a player (like pikanetwork.js getRatioData)
|
||||
*/
|
||||
async getRatioData(
|
||||
username: string,
|
||||
gamemode: GameMode,
|
||||
interval: Interval = 'lifetime',
|
||||
mode: string = 'all_modes'
|
||||
): Promise<RatioData | null> {
|
||||
const data = await this.getLeaderboard(username, gamemode, interval, mode);
|
||||
if (!data) return null;
|
||||
|
||||
const ratios: RatioData = {
|
||||
killDeathRatio: this.calculateRatioFromData(data, 'Kills', 'Deaths'),
|
||||
kdrInfo: 'Kills/Deaths',
|
||||
winLossRatio: this.calculateRatioFromData(data, 'Wins', 'Losses'),
|
||||
wlrInfo: 'Wins/Losses',
|
||||
winPlayRatio: this.calculateRatioFromData(data, 'Wins', 'Games played'),
|
||||
wprInfo: 'Wins/Games Played',
|
||||
arrowsHitShotRatio: this.calculateRatioFromData(data, 'Arrows hit', 'Arrows shot'),
|
||||
ahsrInfo: 'Arrows Hit/Arrows Shot',
|
||||
};
|
||||
|
||||
if (gamemode === 'bedwars') {
|
||||
ratios.finalKillDeathRatio = this.calculateRatioFromData(data, 'Final kills', 'Final deaths');
|
||||
ratios.fkdrInfo = 'Final Kills/Final Deaths';
|
||||
}
|
||||
|
||||
return ratios;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ratio from leaderboard data
|
||||
*/
|
||||
private calculateRatioFromData(data: LeaderboardResponse, key1: string, key2: string): number {
|
||||
const val1 = this.getStatValue(data, key1);
|
||||
const val2 = this.getStatValue(data, key2);
|
||||
|
||||
if (val1 === 0 && val2 === 0) return NaN;
|
||||
if (val2 === 0) return val1;
|
||||
if (val1 === 0) return 0;
|
||||
|
||||
return Math.round((val1 / val2) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parsed BedWars stats for a player
|
||||
*/
|
||||
@@ -464,77 +683,121 @@ export class PikaNetworkAPI {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile Extended Methods
|
||||
// Staff List Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get friend list for a player
|
||||
* Get the staff list from the forum
|
||||
*/
|
||||
async getFriendList(username: string): Promise<string[]> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return [];
|
||||
return profile.friends.map((f) => f.username);
|
||||
async getStaffList(): Promise<StaffList | null> {
|
||||
// Check cache first
|
||||
const cached = this.cache.getStaff();
|
||||
if (cached) {
|
||||
this.log('Cache hit for staff list');
|
||||
return cached;
|
||||
}
|
||||
|
||||
const html = await this.fetchHtml(`${this.forumUrl}/staff/`);
|
||||
if (!html) return null;
|
||||
|
||||
const staff: StaffList = {
|
||||
owner: [],
|
||||
manager: [],
|
||||
leaddeveloper: [],
|
||||
developer: [],
|
||||
admin: [],
|
||||
srmod: [],
|
||||
moderator: [],
|
||||
helper: [],
|
||||
trial: [],
|
||||
};
|
||||
|
||||
// Parse staff from HTML using regex
|
||||
// Look for patterns like: <span>Username</span><span>Role</span>
|
||||
const spanPairRegex = /<span[^>]*>([^<]+)<\/span>\s*<span[^>]*>([^<]+)<\/span>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = spanPairRegex.exec(html)) !== null) {
|
||||
const username = match[1].trim().replace(/\s/g, '');
|
||||
const roleText = match[2].trim().toLowerCase();
|
||||
|
||||
if (this.staffRoles.has(roleText)) {
|
||||
const role = roleText.replace(/\s/g, '') as keyof StaffList;
|
||||
if (staff[role] && !staff[role].includes(username)) {
|
||||
staff[role].push(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.setStaff(staff);
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guild info for a player
|
||||
* Check if a player is staff
|
||||
*/
|
||||
async getPlayerGuild(username: string): Promise<ClanResponse | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile?.clan) return null;
|
||||
return profile.clan as ClanResponse;
|
||||
async isStaff(username: string): Promise<{ isStaff: boolean; role?: string }> {
|
||||
const staffList = await this.getStaffList();
|
||||
if (!staffList) return { isStaff: false };
|
||||
|
||||
const normalizedUsername = username.toLowerCase();
|
||||
for (const [role, members] of Object.entries(staffList)) {
|
||||
if (members.some((m: string) => m.toLowerCase() === normalizedUsername)) {
|
||||
return { isStaff: true, role };
|
||||
}
|
||||
}
|
||||
|
||||
return { isStaff: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rank info for a player
|
||||
*/
|
||||
async getRankInfo(username: string): Promise<{ level: number; percentage: number; display: string } | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
level: profile.rank.level,
|
||||
percentage: profile.rank.percentage,
|
||||
display: profile.rank.rankDisplay,
|
||||
};
|
||||
}
|
||||
// =========================================================================
|
||||
// Vote Leaderboard Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get miscellaneous info for a player
|
||||
* Get the vote leaderboard
|
||||
*/
|
||||
async getMiscInfo(username: string): Promise<MiscInfo | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
discordBoosting: profile.discord_boosting,
|
||||
discordVerified: profile.discord_verified,
|
||||
emailVerified: profile.email_verified,
|
||||
username: profile.username,
|
||||
};
|
||||
}
|
||||
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
|
||||
// Check cache first
|
||||
const cached = this.cache.getVotes();
|
||||
if (cached) {
|
||||
this.log('Cache hit for vote leaderboard');
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get join info for a player
|
||||
*/
|
||||
async getJoinInfo(username: string): Promise<JoinInfo | null> {
|
||||
const profile = await this.getProfile(username);
|
||||
if (!profile) return null;
|
||||
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
|
||||
if (!html) return null;
|
||||
|
||||
const lastJoinDate = new Date(profile.lastSeen);
|
||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
};
|
||||
const voters: VoteEntry[] = [];
|
||||
const runnerUps: VoteEntry[] = [];
|
||||
|
||||
return {
|
||||
lastJoin: profile.lastSeen,
|
||||
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
|
||||
estimatedFirstJoin: null, // Would require punishment scraping
|
||||
estimatedFirstJoinFormatted: 'N/A',
|
||||
};
|
||||
// Parse winning voters
|
||||
const winningSection = html.match(/block-voters[\s\S]*?(?=block\.runners-up|$)/i)?.[0] ?? '';
|
||||
const runnerUpSection = html.match(/block\.runners-up[\s\S]*/i)?.[0] ?? '';
|
||||
|
||||
// Extract voter data
|
||||
const voterRegex = /class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi;
|
||||
|
||||
let match;
|
||||
while ((match = voterRegex.exec(winningSection)) !== null) {
|
||||
voters.push({
|
||||
position: parseInt(match[1]) || voters.length + 1,
|
||||
username: match[2].trim(),
|
||||
votes: parseInt(match[3]) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
while ((match = voterRegex.exec(runnerUpSection)) !== null) {
|
||||
runnerUps.push({
|
||||
position: parseInt(match[1]) || runnerUps.length + 1,
|
||||
username: match[2].trim(),
|
||||
votes: parseInt(match[3]) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const result = { voters, runnerUps };
|
||||
this.cache.setVotes(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -719,39 +982,6 @@ export class PikaNetworkAPI {
|
||||
return punishments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vote leaderboard (basic implementation)
|
||||
*/
|
||||
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
|
||||
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
|
||||
if (!html) return null;
|
||||
|
||||
const voters: VoteEntry[] = [];
|
||||
const runnerUps: VoteEntry[] = [];
|
||||
|
||||
// Extract voters using regex (simplified)
|
||||
const voterRegex = /class="voter[^"]*"[^>]*>[\s\S]*?class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi;
|
||||
let match;
|
||||
let position = 1;
|
||||
|
||||
while ((match = voterRegex.exec(html)) !== null) {
|
||||
const entry: VoteEntry = {
|
||||
position: parseInt(match[1]) || position,
|
||||
username: match[2].trim(),
|
||||
votes: parseInt(match[3]) || 0,
|
||||
};
|
||||
|
||||
if (html.indexOf(match[0]) < html.indexOf('runners-up')) {
|
||||
voters.push(entry);
|
||||
} else {
|
||||
runnerUps.push(entry);
|
||||
}
|
||||
position++;
|
||||
}
|
||||
|
||||
return { voters, runnerUps };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cache Management
|
||||
// =========================================================================
|
||||
@@ -763,17 +993,84 @@ export class PikaNetworkAPI {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific cache type
|
||||
*/
|
||||
clearCacheType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments'): void {
|
||||
this.cache.clearType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats(): { profiles: number; clans: number; leaderboards: number } {
|
||||
getCacheStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get simple cache stats (backwards compatibility)
|
||||
*/
|
||||
getSimpleCacheStats(): { profiles: number; clans: number; leaderboards: number } {
|
||||
return this.cache.getSimpleStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request statistics
|
||||
*/
|
||||
getRequestStats(): {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageLatency: number;
|
||||
successRate: number;
|
||||
} {
|
||||
const avgLatency = this.stats.totalRequests > 0
|
||||
? Math.round(this.stats.totalLatency / this.stats.totalRequests)
|
||||
: 0;
|
||||
const successRate = this.stats.totalRequests > 0
|
||||
? this.stats.successfulRequests / this.stats.totalRequests
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests: this.stats.totalRequests,
|
||||
successfulRequests: this.stats.successfulRequests,
|
||||
failedRequests: this.stats.failedRequests,
|
||||
averageLatency: avgLatency,
|
||||
successRate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset request statistics
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
totalLatency: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the client and cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cache.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the API is reachable
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/profile/Technoblade`, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* PikaNetwork API Module
|
||||
* Exports all API-related types and classes
|
||||
* Based on pikanetwork.js but rewritten in TypeScript with improvements
|
||||
* Full TypeScript implementation based on pikanetwork.js
|
||||
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
|
||||
*/
|
||||
|
||||
export { PikaNetworkAPI } from './client.ts';
|
||||
export { PikaCache } from './cache.ts';
|
||||
// Main exports
|
||||
export { PikaNetworkAPI, type PikaAPIOptions, type RatioData } from './client.ts';
|
||||
export { PikaCache, type PikaCacheOptions } from './cache.ts';
|
||||
|
||||
// Type exports from types.ts
|
||||
export type {
|
||||
// Profile types
|
||||
ProfileResponse,
|
||||
@@ -31,7 +34,6 @@ export type {
|
||||
Interval,
|
||||
BedWarsMode,
|
||||
SkyWarsMode,
|
||||
PikaAPIOptions,
|
||||
// Batch types
|
||||
BatchLeaderboardResult,
|
||||
MinimalLeaderboardData,
|
||||
@@ -56,6 +58,7 @@ export type {
|
||||
MiscInfo,
|
||||
} from './types.ts';
|
||||
|
||||
// Type guard exports
|
||||
export {
|
||||
isProfileResponse,
|
||||
isClanResponse,
|
||||
|
||||
@@ -123,8 +123,11 @@ export class EllyClient extends Client {
|
||||
});
|
||||
|
||||
this.pikaAPI = new PikaNetworkAPI({
|
||||
cacheTTL: config.api.pika_cache_ttl,
|
||||
timeout: config.api.pika_request_timeout,
|
||||
cache: {
|
||||
profileTTL: config.api.pika_cache_ttl,
|
||||
leaderboardTTL: Math.floor(config.api.pika_cache_ttl / 2), // Shorter TTL for leaderboards
|
||||
},
|
||||
});
|
||||
|
||||
this.database = new JsonDatabase(config.database.path.replace('.db', '.json'));
|
||||
|
||||
Reference in New Issue
Block a user