initial
This commit is contained in:
BIN
backend/data/faction-assets/arachne.png
Normal file
BIN
backend/data/faction-assets/arachne.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
backend/data/faction-assets/cyberacme.png
Normal file
BIN
backend/data/faction-assets/cyberacme.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
backend/data/faction-assets/mida.png
Normal file
BIN
backend/data/faction-assets/mida.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
backend/data/faction-assets/nucaloric.png
Normal file
BIN
backend/data/faction-assets/nucaloric.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
backend/data/faction-assets/sekiguchi.png
Normal file
BIN
backend/data/faction-assets/sekiguchi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
backend/data/faction-assets/traxus.png
Normal file
BIN
backend/data/faction-assets/traxus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
backend/data/fonts/Ki-Bold.otf
Normal file
BIN
backend/data/fonts/Ki-Bold.otf
Normal file
Binary file not shown.
BIN
backend/data/fonts/Ki-Regular.otf
Normal file
BIN
backend/data/fonts/Ki-Regular.otf
Normal file
Binary file not shown.
745
backend/references/api.js
Normal file
745
backend/references/api.js
Normal 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}`);
|
||||
}
|
||||
};
|
||||
})();
|
||||
1479
backend/references/faction-upgrades.js
Normal file
1479
backend/references/faction-upgrades.js
Normal file
File diff suppressed because it is too large
Load Diff
1479
backend/references/faction-upgrades.live.js
Normal file
1479
backend/references/faction-upgrades.live.js
Normal file
File diff suppressed because it is too large
Load Diff
592
backend/server.js
Normal file
592
backend/server.js
Normal file
@@ -0,0 +1,592 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const DB_PATH = resolve('backend', 'data', 'catalog.db');
|
||||
const FACTION_ASSETS_DIR = resolve('backend', 'data', 'faction-assets');
|
||||
const ITEMS_URL = 'https://items.marathondb.gg/api/items';
|
||||
const UPGRADES_URL = 'https://marathondb.gg/js/data/faction-upgrades.js';
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS catalog_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
payload TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS popularity_stats (
|
||||
entry_type TEXT NOT NULL CHECK (entry_type IN ('item', 'upgrade')),
|
||||
slug TEXT NOT NULL,
|
||||
add_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (entry_type, slug)
|
||||
)
|
||||
`);
|
||||
|
||||
const upsertCacheStatement = db.prepare(`
|
||||
INSERT INTO catalog_cache (id, payload, updated_at_ms)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at_ms = excluded.updated_at_ms
|
||||
`);
|
||||
|
||||
const selectCacheStatement = db.prepare('SELECT payload, updated_at_ms FROM catalog_cache WHERE id = 1');
|
||||
const incrementPopularityStatement = db.prepare(`
|
||||
INSERT INTO popularity_stats (entry_type, slug, add_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(entry_type, slug) DO UPDATE SET add_count = popularity_stats.add_count + 1
|
||||
`);
|
||||
const selectPopularStatement = db.prepare(`
|
||||
SELECT entry_type, slug, add_count
|
||||
FROM popularity_stats
|
||||
ORDER BY add_count DESC, slug ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
let refreshPromise = null;
|
||||
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function normalizeName(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeColor(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return '#3a4f77';
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed || '#3a4f77';
|
||||
}
|
||||
|
||||
function getName(raw) {
|
||||
const candidates = [raw.name, raw.display_name, raw.item_name, raw.title];
|
||||
for (const candidate of candidates) {
|
||||
const name = normalizeName(candidate);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getSlug(raw) {
|
||||
const candidates = [raw.slug, raw.id, raw.item_id, raw.uuid, raw.name];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
}
|
||||
|
||||
return `item-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function getIconPath(raw) {
|
||||
const candidates = [raw.icon, raw.icon_path, raw.image, raw.image_url];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRarity(raw) {
|
||||
const candidates = [raw.rarity, raw.rarity_name, raw.item_rarity, raw.quality, raw.tier];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getItemIconUrl(iconPath, slug) {
|
||||
const itemImageBase = 'https://items.marathondb.gg/images/items';
|
||||
const apiBase = 'https://helpbot.marathondb.gg';
|
||||
|
||||
if (!iconPath) {
|
||||
return `${itemImageBase}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
if (iconPath.startsWith('http://') || iconPath.startsWith('https://')) {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
if (iconPath.startsWith('assets/')) {
|
||||
return `${apiBase}/${iconPath}`;
|
||||
}
|
||||
|
||||
return `${itemImageBase}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
function extractRawItems(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.filter((row) => typeof row === 'object' && row !== null);
|
||||
}
|
||||
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataArray = asArray(payload.data);
|
||||
if (dataArray.length > 0) {
|
||||
return dataArray.filter((row) => typeof row === 'object' && row !== null);
|
||||
}
|
||||
|
||||
const dataAsObject = payload.data;
|
||||
if (dataAsObject && typeof dataAsObject === 'object' && Array.isArray(dataAsObject.items)) {
|
||||
return dataAsObject.items.filter((row) => typeof row === 'object' && row !== null);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseFactionUpgradesFromScript(source) {
|
||||
const context = { window: {} };
|
||||
vm.createContext(context);
|
||||
|
||||
const script = new vm.Script(`\n${source}\n;globalThis.__factionUpgrades = typeof FACTION_UPGRADES !== 'undefined' ? FACTION_UPGRADES : window.FACTION_UPGRADES;\n`);
|
||||
script.runInContext(context, { timeout: 5000 });
|
||||
|
||||
const parsed = context.__factionUpgrades;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Could not parse FACTION_UPGRADES from faction-upgrades.js');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
function getFactionAssetUrl(factionKey) {
|
||||
const normalizedKey = normalizeName(factionKey).toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
return `/api/faction-assets/${normalizedKey}.png`;
|
||||
}
|
||||
|
||||
function buildUpgradeResults(factionUpgrades, itemsBySlug) {
|
||||
const results = [];
|
||||
|
||||
for (const [factionKey, factionValue] of Object.entries(factionUpgrades)) {
|
||||
if (!factionValue || typeof factionValue !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const faction = factionValue;
|
||||
const factionName = normalizeName(faction.name) || factionKey;
|
||||
const factionColor = normalizeColor(faction.color);
|
||||
const upgrades = asArray(faction.upgrades);
|
||||
|
||||
for (const upgradeEntry of upgrades) {
|
||||
if (!upgradeEntry || typeof upgradeEntry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgrade = upgradeEntry;
|
||||
const upgradeName = normalizeName(upgrade.name);
|
||||
if (!upgradeName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mappedLevels = [];
|
||||
const levels = asArray(upgrade.levels);
|
||||
|
||||
for (const [levelIndex, levelEntry] of levels.entries()) {
|
||||
if (!levelEntry || typeof levelEntry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const levelNumber = Math.max(1, Math.floor(toNumber(levelEntry.level)) || levelIndex + 1);
|
||||
const salvageBySlug = new Map();
|
||||
const salvage = asArray(levelEntry.salvage);
|
||||
for (const salvageEntry of salvage) {
|
||||
if (!salvageEntry || typeof salvageEntry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const salvageSlug = normalizeName(salvageEntry.slug).toLowerCase();
|
||||
const amount = Math.floor(toNumber(salvageEntry.amount));
|
||||
if (!salvageSlug || !Number.isFinite(amount) || amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = salvageBySlug.get(salvageSlug) || 0;
|
||||
salvageBySlug.set(salvageSlug, current + amount);
|
||||
}
|
||||
|
||||
const mappedSalvage = Array.from(salvageBySlug.entries())
|
||||
.map(([slug, amount]) => ({ slug, amount, item: itemsBySlug.get(slug) }))
|
||||
.filter((entry) => Boolean(entry.item))
|
||||
.map((entry) => ({
|
||||
slug: entry.slug,
|
||||
amount: entry.amount,
|
||||
name: entry.item.name,
|
||||
iconUrl: entry.item.iconUrl,
|
||||
rarity: entry.item.rarity,
|
||||
}));
|
||||
|
||||
mappedLevels.push({
|
||||
level: levelNumber,
|
||||
salvage: mappedSalvage,
|
||||
});
|
||||
}
|
||||
|
||||
const levelsWithSalvage = mappedLevels.filter((entry) => entry.salvage.length > 0);
|
||||
if (levelsWithSalvage.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mappedLevels.sort((a, b) => a.level - b.level);
|
||||
|
||||
const rawUpgradeSlug = normalizeName(upgrade.slug).toLowerCase().replace(/\s+/g, '-');
|
||||
const fallbackSlug = `${factionKey}-${upgradeName.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const upgradeSlug = rawUpgradeSlug || fallbackSlug;
|
||||
|
||||
results.push({
|
||||
id: `upgrade-${upgradeSlug}`,
|
||||
slug: upgradeSlug,
|
||||
name: `${upgradeName} (${factionName})`,
|
||||
factionName,
|
||||
factionColor,
|
||||
iconUrl: getFactionAssetUrl(factionKey),
|
||||
levels: mappedLevels,
|
||||
isUpgrade: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs) {
|
||||
const rows = extractRawItems(itemsPayload);
|
||||
|
||||
const items = rows
|
||||
.map((row) => {
|
||||
const name = getName(row);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = getSlug(row);
|
||||
const iconPath = getIconPath(row);
|
||||
const rarity = getRarity(row);
|
||||
return {
|
||||
id: slug,
|
||||
slug,
|
||||
name,
|
||||
iconUrl: getItemIconUrl(iconPath, slug),
|
||||
rarity,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const itemsBySlug = new Map(items.map((item) => [item.slug, item]));
|
||||
const upgrades = buildUpgradeResults(factionUpgradesPayload, itemsBySlug);
|
||||
|
||||
return {
|
||||
updatedAt: new Date(updatedAtMs).toISOString(),
|
||||
items,
|
||||
upgrades,
|
||||
};
|
||||
}
|
||||
|
||||
function readCachedCatalog() {
|
||||
const row = selectCacheStatement.get();
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
payload: JSON.parse(row.payload),
|
||||
updatedAtMs: row.updated_at_ms,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCatalog(catalog, updatedAtMs) {
|
||||
upsertCacheStatement.run(JSON.stringify(catalog), updatedAtMs);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'marathon-todo-proxy/1.0',
|
||||
Accept: 'application/json,text/javascript,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for ${url}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'marathon-todo-proxy/1.0',
|
||||
Accept: 'text/javascript,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed for ${url}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function refreshCatalog() {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const [itemsPayload, upgradesScript] = await Promise.all([fetchJson(ITEMS_URL), fetchText(UPGRADES_URL)]);
|
||||
const factionUpgradesPayload = parseFactionUpgradesFromScript(upgradesScript);
|
||||
const updatedAtMs = Date.now();
|
||||
const catalog = buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs);
|
||||
|
||||
writeCatalog(catalog, updatedAtMs);
|
||||
return catalog;
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function isStale(updatedAtMs) {
|
||||
return Date.now() - updatedAtMs >= REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendBinary(response, statusCode, contentType, body) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': body.byteLength,
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function readRequestBody(request) {
|
||||
return new Promise((resolveBody, rejectBody) => {
|
||||
const chunks = [];
|
||||
|
||||
request.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
request.on('end', () => {
|
||||
resolveBody(Buffer.concat(chunks).toString('utf-8'));
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
rejectBody(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const raw = await readRequestBody(request);
|
||||
if (!raw.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON body');
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
|
||||
if (request.method === 'GET' && requestUrl.pathname === '/health') {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && requestUrl.pathname === '/api/catalog/refresh') {
|
||||
try {
|
||||
const catalog = await refreshCatalog();
|
||||
sendJson(response, 200, catalog);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown refresh error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestUrl.pathname.startsWith('/api/faction-assets/')) {
|
||||
const filename = requestUrl.pathname.replace('/api/faction-assets/', '');
|
||||
if (!/^[a-z0-9_-]+\.png$/i.test(filename)) {
|
||||
sendJson(response, 400, { error: 'Invalid faction asset path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const assetPath = resolve(FACTION_ASSETS_DIR, filename);
|
||||
if (!assetPath.startsWith(FACTION_ASSETS_DIR) || !existsSync(assetPath)) {
|
||||
sendJson(response, 404, { error: 'Faction asset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = readFileSync(assetPath);
|
||||
sendBinary(response, 200, 'image/png', asset);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Failed to read faction asset' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestUrl.pathname === '/api/catalog') {
|
||||
const cached = readCachedCatalog();
|
||||
if (cached) {
|
||||
if (isStale(cached.updatedAtMs)) {
|
||||
void refreshCatalog().catch((error) => {
|
||||
console.error('[proxy] background refresh failed:', error);
|
||||
});
|
||||
}
|
||||
sendJson(response, 200, cached.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await refreshCatalog();
|
||||
sendJson(response, 200, catalog);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown fetch error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && requestUrl.pathname === '/api/popularity/track') {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const entryType = body?.type === 'upgrade' ? 'upgrade' : body?.type === 'item' ? 'item' : '';
|
||||
const slug = normalizeName(body?.slug).toLowerCase();
|
||||
|
||||
if (!entryType || !slug) {
|
||||
sendJson(response, 400, { error: 'Payload must include type ("item" or "upgrade") and slug.' });
|
||||
return;
|
||||
}
|
||||
|
||||
incrementPopularityStatement.run(entryType, slug);
|
||||
sendJson(response, 200, { ok: true });
|
||||
} catch (error) {
|
||||
sendJson(response, 400, { error: error instanceof Error ? error.message : 'Invalid request' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestUrl.pathname === '/api/popularity') {
|
||||
const limitParam = Number(requestUrl.searchParams.get('limit'));
|
||||
const limit = Number.isFinite(limitParam) ? Math.min(Math.max(Math.floor(limitParam), 1), 20) : 5;
|
||||
|
||||
const cached = readCachedCatalog();
|
||||
let catalog = cached?.payload;
|
||||
|
||||
if (!catalog) {
|
||||
try {
|
||||
catalog = await refreshCatalog();
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: error instanceof Error ? error.message : 'Failed to load catalog for popularity',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = selectPopularStatement.all(limit * 4);
|
||||
const itemBySlug = new Map((Array.isArray(catalog.items) ? catalog.items : []).map((item) => [item.slug, item]));
|
||||
const upgradeBySlug = new Map(
|
||||
(Array.isArray(catalog.upgrades) ? catalog.upgrades : []).map((upgrade) => [upgrade.slug, upgrade]),
|
||||
);
|
||||
|
||||
const picks = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (picks.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
const slug = normalizeName(row.slug).toLowerCase();
|
||||
if (!slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.entry_type === 'item') {
|
||||
const item = itemBySlug.get(slug);
|
||||
if (item) {
|
||||
picks.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.entry_type === 'upgrade') {
|
||||
const upgrade = upgradeBySlug.get(slug);
|
||||
if (upgrade) {
|
||||
picks.push(upgrade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(response, 200, { picks });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[proxy] listening on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
void refreshCatalog().catch((error) => {
|
||||
console.error('[proxy] scheduled refresh failed:', error);
|
||||
});
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
void refreshCatalog().catch((error) => {
|
||||
console.error('[proxy] initial refresh failed:', error);
|
||||
});
|
||||
Reference in New Issue
Block a user