This commit is contained in:
2026-03-26 11:33:03 +01:00
commit 1f20359fa3
30 changed files with 7849 additions and 0 deletions

745
backend/references/api.js Normal file
View File

@@ -0,0 +1,745 @@
// Marathon API Client
// Provides API access for all database pages
// ─── Session Cache ────────────────────────────────────────────────
// Thin sessionStorage layer — caches GET responses with a 5-min TTL
// so repeat visits within the same tab session avoid redundant fetches.
const ApiCache = (function () {
const TTL = 5 * 60 * 1000; // 5 minutes
const PREFIX = 'mdb_';
function _key(url) { return PREFIX + url; }
return {
get(url) {
try {
const raw = sessionStorage.getItem(_key(url));
if (!raw) return null;
const entry = JSON.parse(raw);
if (Date.now() - entry.ts > TTL) {
sessionStorage.removeItem(_key(url));
return null;
}
return entry.data;
} catch { return null; }
},
set(url, data) {
try {
sessionStorage.setItem(_key(url), JSON.stringify({ ts: Date.now(), data }));
} catch { /* quota exceeded — silently skip */ }
},
clear() {
try {
Object.keys(sessionStorage)
.filter(k => k.startsWith(PREFIX))
.forEach(k => sessionStorage.removeItem(k));
} catch { /* ignore */ }
}
};
})();
const DISPLAY_NAME_KEYS = new Set([
'name',
'display_name',
'weapon_name',
'runner_name',
'collection_name',
'faction_name'
]);
function normalizeDisplayName(value) {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return value;
return trimmed
.split(/\s+/)
.map(token => token
.split(/([\-/'])/)
.map(part => {
if (!part || /[\-/']/.test(part)) return part;
if (!/[A-Za-z]/.test(part)) return part;
if (/^[ivxlcdm]+$/i.test(part) && part.length <= 6) return part.toUpperCase();
if (/^[A-Z0-9]+$/.test(part) && part.length <= 5) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join(''))
.join(' ');
}
function normalizeDisplayNamesInPayload(payload) {
if (Array.isArray(payload)) {
return payload.map(normalizeDisplayNamesInPayload);
}
if (!payload || typeof payload !== 'object') {
return payload;
}
const normalized = {};
for (const [key, value] of Object.entries(payload)) {
if (DISPLAY_NAME_KEYS.has(key) && typeof value === 'string') {
normalized[key] = normalizeDisplayName(value);
} else {
normalized[key] = normalizeDisplayNamesInPayload(value);
}
}
return normalized;
}
if (typeof window !== 'undefined') {
window.MarathonNameUtils = window.MarathonNameUtils || {};
window.MarathonNameUtils.normalizeDisplayName = normalizeDisplayName;
window.MarathonNameUtils.normalizeDisplayNamesInPayload = normalizeDisplayNamesInPayload;
}
const MarathonAPI = (function() {
const API_BASE = 'https://helpbot.marathondb.gg';
const CORES_API_BASE = 'https://cores.marathondb.gg';
const IMPLANTS_API_BASE = 'https://implants.marathondb.gg';
const MODS_API_BASE = 'https://mods.marathondb.gg';
const CONTRACTS_API_BASE = 'https://marathon-contracts-api.heymarathondb.workers.dev';
// Generic fetch wrapper (with session cache for GETs)
async function fetchAPI(endpoint, options = {}) {
try {
const { headers: extraHeaders, ...rest } = options;
const method = (rest.method || 'GET').toUpperCase();
const url = `${API_BASE}${endpoint}`;
// Cache hit — return immediately for safe methods
if (method === 'GET') {
const cached = ApiCache.get(url);
if (cached) return cached;
}
const needsContentType = method !== 'GET' && method !== 'HEAD';
const headers = {
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
...(extraHeaders || {}),
};
const response = await fetch(url, {
...rest,
headers,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (method === 'GET' && data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error; // Re-throw API errors with body attached
console.error('API fetch error:', error);
return null;
}
}
// Cores-specific fetch wrapper (uses cores.marathondb.gg exclusively)
async function fetchCoresAPI(endpoint, options = {}) {
try {
const { headers: extraHeaders, ...rest } = options;
const method = (rest.method || 'GET').toUpperCase();
const url = `${CORES_API_BASE}${endpoint}`;
if (method === 'GET') {
const cached = ApiCache.get(url);
if (cached) return cached;
}
const needsContentType = method !== 'GET' && method !== 'HEAD';
const headers = {
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
...(extraHeaders || {}),
};
const response = await fetch(url, {
...rest,
headers,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`Cores API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (method === 'GET' && data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error;
console.error('Cores API fetch error:', error);
return null;
}
}
// Implants-specific fetch wrapper (uses implants.marathondb.gg exclusively)
async function fetchImplantsAPI(endpoint, options = {}) {
try {
const { headers: extraHeaders, ...rest } = options;
const method = (rest.method || 'GET').toUpperCase();
const url = `${IMPLANTS_API_BASE}${endpoint}`;
if (method === 'GET') {
const cached = ApiCache.get(url);
if (cached) return cached;
}
const needsContentType = method !== 'GET' && method !== 'HEAD';
const headers = {
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
...(extraHeaders || {}),
};
const response = await fetch(url, {
...rest,
headers,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`Implants API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (method === 'GET' && data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error;
console.error('Implants API fetch error:', error);
return null;
}
}
// Mods-specific fetch wrapper (uses mods.marathondb.gg exclusively)
async function fetchModsAPI(endpoint, options = {}) {
try {
const { headers: extraHeaders, ...rest } = options;
const method = (rest.method || 'GET').toUpperCase();
const url = `${MODS_API_BASE}${endpoint}`;
if (method === 'GET') {
const cached = ApiCache.get(url);
if (cached) return cached;
}
const needsContentType = method !== 'GET' && method !== 'HEAD';
const headers = {
...(needsContentType ? { 'Content-Type': 'application/json' } : {}),
...(extraHeaders || {}),
};
const response = await fetch(url, {
...rest,
headers,
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`Mods API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (method === 'GET' && data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error;
console.error('Mods API fetch error:', error);
return null;
}
}
// Contracts-specific fetch wrapper (uses marathon-contracts-api.heymarathondb.workers.dev)
async function fetchContractsAPI(endpoint) {
try {
const url = `${CONTRACTS_API_BASE}${endpoint}`;
const cached = ApiCache.get(url);
if (cached) return cached;
const response = await fetch(url);
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`Contracts API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error;
console.error('Contracts API fetch error:', error);
return null;
}
}
return {
// ============ API BASE ============
getApiBase: function() {
return API_BASE;
},
// ============ GENERIC GET ============
get: async function(endpoint) {
const result = await fetchAPI(`/api${endpoint}`);
// API already returns { success, data } format
if (result && result.success !== undefined) {
return result;
}
// Wrap raw data in standard format
return { success: !!result, data: result };
},
// ============ WEAPONS ============
getWeapons: async function(category = null) {
const endpoint = category ? `/api/weapons?category=${category}` : '/api/weapons';
return await fetchAPI(endpoint);
},
getWeaponBySlug: async function(slug) {
return await fetchAPI(`/api/weapons/${slug}`);
},
getWeaponHistory: async function(slug) {
return await fetchAPI(`/api/weapons/${slug}/history`);
},
// ============ CATEGORIES ============
getCategories: async function() {
return await fetchAPI('/api/categories');
},
// ============ RUNNERS ============
getRunners: async function() {
return await fetchAPI('/api/runners');
},
getRunnerBySlug: async function(slug) {
return await fetchAPI(`/api/runners/${slug}`);
},
getRunnerHistory: async function(slug) {
return await fetchAPI(`/api/runners/${slug}/history`);
},
compareRunners: async function(season = null) {
const endpoint = season ? `/api/runners/compare?season=${season}` : '/api/runners/compare';
return await fetchAPI(endpoint);
},
// ============ ITEMS ============
// Unified endpoint - returns all items from all tables
getAllItems: async function() {
return await fetchAPI('/api/items/all');
},
getItems: async function(type = null, rarity = null) {
let endpoint = '/api/items';
const params = [];
if (type) params.push(`type=${type}`);
if (rarity) params.push(`rarity=${rarity}`);
if (params.length) endpoint += '?' + params.join('&');
return await fetchAPI(endpoint);
},
getItemsByType: async function(type) {
return await fetchAPI(`/api/items/types/${type}`);
},
getItemBySlug: async function(slug) {
return await fetchAPI(`/api/items/${slug}`);
},
// Get available item types for filter dropdowns (API v2.0.0)
getItemTypes: async function() {
return await fetchAPI('/api/items/types');
},
// ============ MODS ============
getMods: async function() {
return await fetchModsAPI('/api/mods');
},
getModBySlug: async function(slug) {
return await fetchModsAPI(`/api/mods/${slug}`);
},
getModsForWeapon: async function(weaponSlug) {
return await fetchModsAPI(`/api/weapons/${encodeURIComponent(weaponSlug)}/mods`);
},
getModSlotsForWeapon: async function(weaponSlug) {
return await fetchModsAPI(`/api/weapons/${encodeURIComponent(weaponSlug)}/slots`);
},
// ============ SEASONS ============
getSeasons: async function() {
return await fetchAPI('/api/seasons');
},
getCurrentSeason: async function() {
return await fetchAPI('/api/seasons/current');
},
// ============ LOADOUTS ============
createLoadout: async function(loadout) {
return await fetchAPI('/api/loadouts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadout),
});
},
getLoadout: async function(shareCode) {
return await fetchAPI(`/api/loadouts/${shareCode}`);
},
getLoadouts: async function(params = {}) {
const qs = Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
return await fetchAPI(`/api/loadouts${qs ? '?' + qs : ''}`);
},
deleteLoadout: async function(shareCode, adminKey) {
return await fetchAPI(`/api/loadouts/${shareCode}`, {
method: 'DELETE',
headers: { 'X-Admin-Key': adminKey },
});
},
// ============ STAT RANGES ============
getWeaponStatRanges: async function() {
return await fetchAPI('/api/weapons/stat-ranges');
},
getRunnerStatRanges: async function() {
return await fetchAPI('/api/runners/stat-ranges');
},
// ============ ASSET URLs ============
getWeaponIconUrl: function(iconPath, resolution = 'low') {
if (!iconPath) return `${API_BASE}/assets/weapons/placeholder.png`;
const filename = iconPath.split('/').pop();
return `${API_BASE}/assets/weapons/${encodeURIComponent(filename)}`;
},
// Get weapon icon with resolution option ('low' = 180x135, 'high' = 800x600)
getWeaponIconUrlBySlug: function(slug, resolution = 'low') {
if (!slug) return `${API_BASE}/assets/weapons/placeholder.png`;
const suffix = resolution === 'high' ? '800x600' : '180x135';
return `${API_BASE}/assets/weapons/${slug}-${suffix}.png`;
},
getRunnerIconUrl: function(iconPath) {
if (!iconPath) return `${API_BASE}/assets/runners/placeholder.png`;
// If it's already a full path from API (e.g., "assets/runners/thief-300x460.png")
if (iconPath.startsWith('assets/')) {
return `${API_BASE}/${iconPath}`;
}
// If slug is passed instead of full path, construct the URL
const slug = iconPath.toLowerCase().replace(/\.png$/, '');
return `${API_BASE}/assets/runners/${slug}-300x460.png`;
},
// Get runner icon with resolution option ('low' = 150x230, 'high' = 300x460)
getRunnerIconUrlBySlug: function(slug, resolution = 'high') {
if (!slug) return `${API_BASE}/assets/runners/placeholder.png`;
const suffix = resolution === 'low' ? '150x230' : '300x460';
return `${API_BASE}/assets/runners/${slug}-${suffix}.png`;
},
getItemIconUrl: function(iconPath) {
if (!iconPath) return `${API_BASE}/assets/items/placeholder.png`;
// If it's already a full path from API (e.g., "assets/items/consumables/patch-kit-64x64.png")
if (iconPath.startsWith('assets/')) {
return `${API_BASE}/${iconPath}`;
}
// Fallback: assume it's just a filename or slug
return `${API_BASE}/assets/items/${encodeURIComponent(iconPath)}`;
},
getStatIconUrl: function(statKey) {
// Map stat keys to icon filenames
const statIcons = {
'heat_capacity': 'heat-capacity.jpg',
'agility': 'agility.jpg',
'loot_speed': 'loot-speed.jpg',
'self_repair_speed': 'self-repair-speed.jpg',
'finisher_siphon': 'finisher-siphon.jpg',
'revive_speed': 'revive-speed.jpg',
'hardware': 'hardware.jpg',
'firewall': 'firewall.jpg'
};
const filename = statIcons[statKey] || 'placeholder.png';
return `/assets/icons/${filename}`;
},
// ============ CONSUMABLES ============
getConsumables: async function(type = null, rarity = null) {
let endpoint = '/api/consumables';
const params = [];
if (type) params.push(`type=${type}`);
if (rarity) params.push(`rarity=${rarity}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchAPI(endpoint);
},
// ============ CORES ============
getCores: async function(runnerType = null, rarity = null, purchaseable = null, activeOnly = null) {
let endpoint = '/api/cores';
const params = [];
if (runnerType) params.push(`runner=${runnerType}`);
if (rarity) params.push(`rarity=${rarity}`);
if (purchaseable) params.push(`purchaseable=true`);
if (activeOnly !== null) params.push(`active=${activeOnly}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchCoresAPI(endpoint);
},
getCoresByRunner: async function(runnerType) {
return await fetchCoresAPI(`/api/cores/runner/${runnerType}`);
},
getCoreBySlug: async function(slug) {
// Returns detailed core info with full balance history
return await fetchCoresAPI(`/api/cores/${slug}`);
},
getCoreChangelog: async function() {
// Chronological changelog of all balance changes
return await fetchCoresAPI('/api/cores/changelog');
},
getCoreIconUrl: function(iconPath) {
if (!iconPath) return `${CORES_API_BASE}/assets/items/cores/placeholder.png`;
// If it's already a full URL from the API response
if (iconPath.startsWith('http')) return iconPath;
// If it's a relative path from API
if (iconPath.startsWith('assets/')) {
return `${CORES_API_BASE}/${iconPath}`;
}
// Fallback: assume it's a slug and construct the path
return `${CORES_API_BASE}/assets/items/cores/${encodeURIComponent(iconPath)}-72x72.png`;
},
// ============ IMPLANTS ============
getImplants: async function(slot = null, rarity = null, page = null, limit = null) {
let endpoint = '/api/implants';
const params = [];
if (slot) params.push(`slot=${slot}`);
if (rarity) params.push(`rarity=${rarity}`);
if (page) params.push(`page=${page}`);
if (limit) params.push(`limit=${limit}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchImplantsAPI(endpoint);
},
getImplantBySlug: async function(slug) {
return await fetchImplantsAPI(`/api/implants/${slug}`);
},
getImplantSlots: async function() {
return await fetchImplantsAPI('/api/implants/slots');
},
getImplantsBySlot: async function(slot) {
return await fetchImplantsAPI(`/api/implants/slot/${slot}`);
},
getTraits: async function() {
return await fetchImplantsAPI('/api/traits');
},
// ============ FACTIONS (Contracts API) ============
getFactions: async function() {
return await fetchContractsAPI('/api/factions');
},
getFactionBySlug: async function(slug) {
return await fetchContractsAPI(`/api/factions/${slug}`);
},
getFactionContracts: async function(slug, type = null) {
let endpoint = `/api/factions/${slug}/contracts`;
const params = [];
if (type) params.push(`type=${type}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchContractsAPI(endpoint);
},
getFactionUpgrades: async function(slug, category = null, tier = null) {
let endpoint = `/api/factions/${slug}/upgrades`;
const params = [];
if (category) params.push(`category=${category}`);
if (tier) params.push(`tier=${tier}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchContractsAPI(endpoint);
},
getFactionReputation: async function(slug) {
return await fetchContractsAPI(`/api/factions/${slug}/reputation`);
},
// ============ CONTRACTS (Contracts API) ============
getContracts: async function(options = {}) {
let endpoint = '/api/contracts';
const params = [];
if (options.type) params.push(`type=${options.type}`);
if (options.faction) params.push(`faction=${options.faction}`);
if (options.difficulty) params.push(`difficulty=${options.difficulty}`);
if (options.map) params.push(`map=${options.map}`);
if (options.scope) params.push(`scope=${options.scope}`);
if (options.tag) params.push(`tag=${options.tag}`);
if (options.chain) params.push(`chain=${options.chain}`);
if (options.season) params.push(`season=${options.season}`);
if (options.active !== undefined) params.push(`active=${options.active}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return await fetchContractsAPI(endpoint);
},
getContractBySlug: async function(slug) {
return await fetchContractsAPI(`/api/contracts/${slug}`);
},
getContractRotation: async function() {
return await fetchContractsAPI('/api/contracts/rotation');
},
getContractTags: async function() {
return await fetchContractsAPI('/api/contract-tags');
},
// ============ DATABASE STATS ============
// New unified stats endpoint (API v2.0.0)
getStats: async function() {
return await fetchAPI('/api/stats');
},
// Legacy method - now uses the new /api/stats endpoint
getDbStats: async function() {
try {
const stats = await this.getStats();
if (stats) {
return {
weapons: stats.weapons?.total || 0,
runners: stats.runners?.total || 0,
items: stats.items?.total || 0,
mods: stats.mods?.total || 0,
cores: stats.cores?.total || 0,
factions: stats.factions?.total || 0,
cosmetics: stats.cosmetics?.total || 0,
maps: stats.maps?.total || 0,
implants: stats.implants?.total || 0,
contracts: stats.contracts?.total || 0
};
}
return { weapons: 0, runners: 0, items: 0, mods: 0 };
} catch (error) {
console.error('Error fetching stats:', error);
return { weapons: 0, runners: 0, items: 0, mods: 0 };
}
}
};
})();
// Weapons API Client — https://weapons.marathondb.gg
const WeaponsAPI = (function() {
const BASE = 'https://weapons.marathondb.gg';
async function fetchAPI(endpoint) {
try {
const url = `${BASE}${endpoint}`;
const cached = ApiCache.get(url);
if (cached) return cached;
const response = await fetch(url);
const data = await response.json().catch(() => null);
if (!response.ok) {
const err = new Error(`Weapons API Error: ${response.status}`);
err.status = response.status;
err.body = data;
throw err;
}
if (data) ApiCache.set(url, data);
return data;
} catch (error) {
if (error.status) throw error;
console.error('WeaponsAPI fetch error:', error);
return null;
}
}
// Pick the current-season stats row and flatten into weapon object.
// Prefer is_current === true; fall back to the row with the highest season_id (latest).
function normalizeWeapon(weapon) {
if (!weapon) return weapon;
if (!Array.isArray(weapon.stats) || weapon.stats.length === 0) return weapon;
const current = weapon.stats.reduce((a, b) => (b.season_id > a.season_id ? b : a));
return { ...weapon, stats: current };
}
return {
getWeapons: async function(category = null) {
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return await fetchAPI(`/api/weapons${qs}`);
},
getCategories: async function() {
return await fetchAPI('/api/weapons/categories');
},
getWeaponBySlug: async function(slug) {
const result = await fetchAPI(`/api/weapons/${encodeURIComponent(slug)}`);
if (result?.data) result.data = normalizeWeapon(result.data);
return result;
},
getStatRanges: async function() {
return await fetchAPI('/api/weapons/stat-ranges');
},
normalizeWeapon,
};
})();
// Twitch API Client (separate endpoint — 2-min cache for live data)
const TwitchAPI = (function() {
const API_BASE = 'https://twitch.rnk.gg/api/v2/categories/407314011';
const TWITCH_TTL = 2 * 60 * 1000;
const _twitchCache = {};
async function fetchAPI(endpoint) {
try {
const url = `${API_BASE}${endpoint}`;
const hit = _twitchCache[url];
if (hit && Date.now() - hit.ts < TWITCH_TTL) return hit.data;
const response = await fetch(url);
if (!response.ok) throw new Error(`Twitch API Error: ${response.status}`);
const data = await response.json();
_twitchCache[url] = { ts: Date.now(), data };
return data;
} catch (error) {
console.error('Twitch API error:', error);
return null;
}
}
return {
getCurrentStats: async function() {
return await fetchAPI('/current-v2');
},
getHistory: async function(range = '7d') {
return await fetchAPI(`/history-v2?range=${range}`);
}
};
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff