add SREBOT, SHARED, TSSBOT contents (fixup for #1223)

PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
FURRO404
2026-05-13 23:17:02 -07:00
commit 2b399fdb81
186 changed files with 96596 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
// API Client with automatic token handling and caching
class APIClient {
constructor() {
this.apiKey = null;
this.apiKeyExpiresAt = 0;
this.apiKeyPromise = null;
this.baseURL = '';
this.cache = new Map();
this.pendingRequests = new Map();
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes client-side cache for general requests
this.SEARCH_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes for search results (longer because less frequently changing)
}
// Initialize the API client by fetching the API key
async init(forceRefresh = false) {
if (!forceRefresh && this.apiKey && this.apiKeyExpiresAt && Date.now() < this.apiKeyExpiresAt) {
return;
}
if (this.apiKeyPromise) {
return this.apiKeyPromise;
}
this.apiKeyPromise = (async () => {
const response = await fetch('/api-key', {
method: 'GET',
cache: 'no-cache',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch API key: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.apiKey = data.apiKey;
const expiresAt = Date.parse(data.expires);
this.apiKeyExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 24 * 60 * 60 * 1000;
return this.apiKey;
})();
try {
return await this.apiKeyPromise;
} finally {
this.apiKeyPromise = null;
}
}
async ensureValidApiKey() {
if (!this.apiKey || !this.apiKeyExpiresAt || Date.now() >= this.apiKeyExpiresAt) {
await this.init(true);
}
}
// Generate signature for request (matches server-side algorithm)
generateSignature(method, path, queryString, timestamp, apiSecret) {
const data = `${method}${path}${queryString}`;
// Match server-side simple hashing algorithm exactly
let hash = 0;
const combined = `${data}-${timestamp}-${apiSecret}`;
for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
// Make authenticated API requests with caching and in-flight dedup
async request(endpoint, options = {}) {
const method = options.method || 'GET';
const authEnabled = options.auth !== false;
const cacheKey = `${method}:${endpoint}`;
const useCache = options.cache !== false && method === 'GET'; // Only cache GET requests
// Determine cache duration based on endpoint type
const isSearchRequest = endpoint.includes('/api/search/');
const cacheDuration = isSearchRequest ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
// Check cache for GET requests
if (useCache && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
const age = Date.now() - cached.timestamp;
if (age < cacheDuration) {
console.log(`[API Client] Cache hit for ${endpoint} (${Math.floor(age / 1000)}s old)`);
return cached.data;
} else {
console.log(`[API Client] Cache expired for ${endpoint}`);
this.cache.delete(cacheKey);
}
}
// In-flight dedup: if an identical GET is already in flight, share its promise
if (useCache && this.pendingRequests.has(cacheKey)) {
console.log(`[API Client] In-flight dedup hit for ${endpoint}`);
return this.pendingRequests.get(cacheKey);
}
const promise = this._performRequest(endpoint, options, { method, authEnabled, cacheKey, useCache });
if (useCache) {
this.pendingRequests.set(cacheKey, promise);
promise.finally(() => {
if (this.pendingRequests.get(cacheKey) === promise) {
this.pendingRequests.delete(cacheKey);
}
});
}
return promise;
}
async _performRequest(endpoint, options, ctx) {
const { method, authEnabled, cacheKey, useCache } = ctx;
console.log('[API Client] request called for:', endpoint);
// Auto-initialize if needed
if (authEnabled) {
await this.ensureValidApiKey();
}
if (authEnabled && !this.apiKey) {
throw new Error('Failed to get API key after initialization');
}
const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const timestamp = Date.now().toString();
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (authEnabled) {
headers['X-API-Key'] = this.apiKey;
headers['X-Request-Timestamp'] = timestamp;
}
const finalOptions = {
...options,
method,
headers
};
console.log('[API Client] Making request to:', url);
let response = await fetch(url, finalOptions);
console.log('[API Client] Response status:', response.status);
if (authEnabled && (response.status === 401 || response.status === 403)) {
console.warn(`[API Client] Auth failed for ${endpoint}; refreshing API key and retrying once`);
this.apiKey = null;
this.apiKeyExpiresAt = 0;
await this.init(true);
finalOptions.headers['X-API-Key'] = this.apiKey;
finalOptions.headers['X-Request-Timestamp'] = Date.now().toString();
response = await fetch(url, finalOptions);
console.log('[API Client] Retry response status:', response.status);
}
if (!response.ok) {
const errorText = await response.text();
console.error('[API Client] Request failed:', response.status, errorText);
throw new Error(`API request failed: ${response.status} - ${errorText}`);
}
const data = await response.json();
// Cache successful GET responses
if (useCache) {
this.cache.set(cacheKey, {
data: data,
timestamp: Date.now()
});
console.log(`[API Client] Cached response for ${endpoint}`);
}
return data;
}
// Clear cache manually if needed
clearCache() {
this.cache.clear();
this.pendingRequests.clear();
console.log('[API Client] Cache cleared');
}
// Get cache stats
getCacheStats() {
const now = Date.now();
const stats = {
total: this.cache.size,
fresh: 0,
stale: 0,
searchEntries: 0
};
for (const [key, value] of this.cache.entries()) {
const age = now - value.timestamp;
const isSearch = key.includes('/api/search/');
const maxAge = isSearch ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
if (isSearch) stats.searchEntries++;
if (age < maxAge) {
stats.fresh++;
} else {
stats.stale++;
}
}
return stats;
}
// Convenience methods
async searchPlayers(nickname) {
return this.request(`/api/search/${encodeURIComponent(nickname)}`);
}
async getPlayer(uid) {
return this.request(`/api/player/${uid}`);
}
async getPlayerGames(uid) {
return this.request(`/api/player/${uid}/games`);
}
async getStats() {
return this.request('/api/stats', { auth: false });
}
// Leaderboard methods with stale-while-revalidate option
async getPlayerLeaderboard(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/players', useStaleWhileRevalidate);
}
async getVehicleLeaderboard(vehicle = null, useStaleWhileRevalidate = true) {
const endpoint = vehicle
? `/api/leaderboard/vehicles?vehicle=${encodeURIComponent(vehicle)}`
: '/api/leaderboard/vehicles';
return this.requestWithSWR(endpoint, useStaleWhileRevalidate);
}
async getSquadronLeaderboard(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/squadrons', useStaleWhileRevalidate);
}
async getSquadronDetails(squadronName, startDate, endDate) {
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}`;
if (startDate || endDate) {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate.toISOString());
if (endDate) params.append('end_date', endDate.toISOString());
endpoint += '?' + params.toString();
}
return this.request(endpoint);
}
async getSquadronGames(squadronName, startDate, endDate) {
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}/games`;
if (startDate || endDate) {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate.toISOString());
if (endDate) params.append('end_date', endDate.toISOString());
endpoint += '?' + params.toString();
}
return this.request(endpoint);
}
async getLeaderboardStats(useStaleWhileRevalidate = true) {
return this.requestWithSWR('/api/leaderboard/stats', useStaleWhileRevalidate);
}
// Stale-While-Revalidate: Return cached data immediately, fetch fresh data in background
async requestWithSWR(endpoint, useSWR = true) {
const cacheKey = `GET:${endpoint}`;
if (useSWR && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
const age = Date.now() - cached.timestamp;
// If data is stale (older than cache duration) but not ancient (< 10 minutes)
if (age >= this.CACHE_DURATION && age < 10 * 60 * 1000) {
console.log(`[API Client] SWR: Returning stale data and revalidating ${endpoint}`);
// Fetch fresh data in background (don't await)
this.request(endpoint).catch(err => {
console.error('[API Client] SWR background fetch failed:', err);
});
// Return stale data immediately
return cached.data;
}
}
// Normal request (will use cache if fresh)
return this.request(endpoint);
}
async getVehicleIcons() {
return this.request('/api/vehicle-icons');
}
async getMatch(sessionId) {
return this.request(`/api/match/${sessionId}`);
}
async getMatchReplay(sessionId) {
return this.request(`/api/match/${sessionId}/replay`);
}
async getReplayCanvas(sessionId) {
return this.request(`/api/match/${sessionId}/replay-canvas`);
}
async searchGames(params = {}) {
const queryParams = new URLSearchParams();
if (params.player) queryParams.append('player', params.player);
if (params.map) queryParams.append('map', params.map);
if (params.squadron) queryParams.append('squadron', params.squadron);
if (params.time_from) queryParams.append('time_from', params.time_from);
if (params.time_to) queryParams.append('time_to', params.time_to);
if (params.limit) queryParams.append('limit', params.limit);
return this.request(`/api/games/search?${queryParams}`);
}
async getMaps() {
return this.request('/api/maps');
}
}
// Global API client instance
window.apiClient = new APIClient();
// Pre-initialize when DOM is ready (but don't block)
document.addEventListener('DOMContentLoaded', () => {
// Initialize in background without blocking
window.apiClient.init().catch(() => {
// Silently fail, will retry on first request
});
});
// Quick init function that doesn't throw
window.ensureAPIClient = async () => {
try {
if (!window.apiClient) {
console.error('[API Client] window.apiClient is not defined');
return false;
}
if (!window.apiClient.apiKey) {
await window.apiClient.init();
}
return true;
} catch (error) {
console.error('[API Client] Error in ensureAPIClient:', error);
return false;
}
};