/** * API Response Transformers * Converts legacy CSGO:WTF API responses to the new CS2.WTF format * * IMPORTANT: The backend API returns data in a legacy format that differs from our TypeScript schemas. * These transformers bridge that gap by: * 1. Converting Unix timestamps to ISO 8601 strings * 2. Splitting score arrays [team_a, team_b] into separate fields * 3. Renaming fields (parsed → demo_parsed, vac → vac_present, etc.) * 4. Constructing full avatar URLs from hashes * 5. Normalizing team IDs (1/2 → 2/3) * * Always use these transformers before passing API data to Zod schemas or TypeScript types. */ import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types'; /** * Legacy API match list item format (from api.csgow.tf) * * VERIFIED: This interface matches the actual API response from GET /matches * Tested: 2025-11-12 via curl https://api.csgow.tf/matches?limit=2 */ export interface LegacyMatchListItem { match_id: string; // uint64 as string map: string; // Can be empty string if not parsed date: number; // Unix timestamp (seconds since epoch) score: [number, number]; // [team_a_score, team_b_score] duration: number; // Match duration in seconds match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win max_rounds: number; // 24 for MR12, 30 for MR15 parsed: boolean; // Whether demo has been parsed (NOT demo_parsed) vac: boolean; // Whether any player has VAC ban (NOT vac_present) game_ban: boolean; // Whether any player has game ban (NOT gameban_present) } /** * Legacy API match detail format (from GET /match/:id) * * VERIFIED: This interface matches the actual API response * Tested: 2025-11-12 via curl https://api.csgow.tf/match/3589487716842078322 * * Note: Uses 'stats' array, not 'players' array */ export interface LegacyMatchDetail { match_id: string; share_code?: string; map: string; date: number; // Unix timestamp score: [number, number]; // [team_a, team_b] duration: number; match_result: number; max_rounds: number; parsed: boolean; // NOT demo_parsed vac: boolean; // NOT vac_present game_ban: boolean; // NOT gameban_present stats?: LegacyPlayerStats[]; // Player stats array } /** * Legacy player stats format (nested within match detail) * * VERIFIED: Matches actual API response structure * - Player info nested under 'player' object * - Rank as object with 'old' and 'new' properties * - Multi-kills as object with 'duo', 'triple', 'quad', 'ace' * - Damage as object with 'enemy' and 'team' * - Flash stats with nested 'duration' and 'total' objects */ export interface LegacyPlayerStats { team_id: number; kills: number; deaths: number; assists: number; headshot: number; mvp: number; score: number; player: { steamid64: string; name: string; avatar: string; vac: boolean; game_ban: boolean; vanity_url?: string; }; rank: Record; multi_kills?: { duo?: number; triple?: number; quad?: number; ace?: number; }; dmg?: Record; flash?: { duration?: { self?: number; team?: number; enemy?: number; }; total?: { self?: number; team?: number; enemy?: number; }; }; } /** * Transform legacy match list item to new format * * Converts a single match from the API's legacy format to our schema format. * * Key transformations: * - date: Unix timestamp → ISO 8601 string * - score: [a, b] array → score_team_a, score_team_b fields * - parsed → demo_parsed (rename) * * @param legacy - Match data from API in legacy format * @returns Match data in schema-compatible format */ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem { return { match_id: legacy.match_id, // Keep as string to preserve uint64 precision map: legacy.map || 'unknown', // Handle empty map names date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string score_team_a: legacy.score[0], score_team_b: legacy.score[1], duration: legacy.duration, demo_parsed: legacy.parsed // Rename: parsed → demo_parsed }; } /** * Transform legacy matches list response to new format * * IMPORTANT: The API returns a plain array, NOT an object with properties. * This function wraps the array and adds pagination metadata that we calculate ourselves. * * How pagination works: * 1. API returns plain array: [match1, match2, ...] * 2. We request limit + 1 to check if there are more matches * 3. If we get > limit matches, hasMore = true * 4. We extract timestamp from last match for next page: matches[length-1].date * * @param legacyMatches - Array of matches from API (already requested limit + 1) * @param hasMore - Whether there are more matches available (calculated by caller) * @param nextPageTime - Unix timestamp for next page (extracted from last match by caller) * @returns Wrapped response with pagination metadata */ export function transformMatchesListResponse( legacyMatches: LegacyMatchListItem[], hasMore: boolean = false, nextPageTime?: number ): MatchesListResponse { return { matches: legacyMatches.map(transformMatchListItem), has_more: hasMore, next_page_time: nextPageTime }; } /** * Transform legacy player stats to new format */ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer { // Extract Premier rating from rank object // API provides rank as { old: number, new: number } const rankOld = legacy.rank && typeof legacy.rank.old === 'number' ? (legacy.rank.old as number) : undefined; const rankNew = legacy.rank && typeof legacy.rank.new === 'number' ? (legacy.rank.new as number) : undefined; return { id: legacy.player.steamid64, name: legacy.player.name, avatar: `https://avatars.steamstatic.com/${legacy.player.avatar}_full.jpg`, team_id: legacy.team_id, kills: legacy.kills, deaths: legacy.deaths, assists: legacy.assists, headshot: legacy.headshot, mvp: legacy.mvp, score: legacy.score, // Premier rating (CS2: 0-30000) rank_old: rankOld, rank_new: rankNew, // Multi-kills: map legacy names to new format mk_2: legacy.multi_kills?.duo, mk_3: legacy.multi_kills?.triple, mk_4: legacy.multi_kills?.quad, mk_5: legacy.multi_kills?.ace, // Flash stats flash_duration_self: legacy.flash?.duration?.self, flash_duration_team: legacy.flash?.duration?.team, flash_duration_enemy: legacy.flash?.duration?.enemy, flash_total_self: legacy.flash?.total?.self, flash_total_team: legacy.flash?.total?.team, flash_total_enemy: legacy.flash?.total?.enemy }; } /** * Transform legacy match detail to new format */ export function transformMatchDetail(legacy: LegacyMatchDetail): Match { return { match_id: legacy.match_id, share_code: legacy.share_code || undefined, map: legacy.map || 'unknown', date: new Date(legacy.date * 1000).toISOString(), score_team_a: legacy.score[0], score_team_b: legacy.score[1], duration: legacy.duration, match_result: legacy.match_result, max_rounds: legacy.max_rounds, demo_parsed: legacy.parsed, vac_present: legacy.vac, gameban_present: legacy.game_ban, players: legacy.stats?.map(transformPlayerStats) }; } /** * Legacy player profile format from API */ export interface LegacyPlayerProfile { steamid64: string; name: string; avatar: string; // Hash, not full URL vac: boolean; vac_date: number; // Unix timestamp game_ban: boolean; game_ban_date: number; // Unix timestamp tracked: boolean; match_stats?: { win: number; loss: number; }; matches?: Array<{ match_id: string; map: string; date: number; score: [number, number]; duration: number; match_result: number; max_rounds: number; parsed: boolean; vac: boolean; game_ban: boolean; stats: { team_id: number; kills: number; deaths: number; assists: number; headshot: number; mvp: number; score: number; rank: Record; multi_kills?: Record; dmg?: Record; }; }>; } /** * Transform legacy player profile to schema-compatible format */ export function transformPlayerProfile(legacy: LegacyPlayerProfile) { // Unix timestamp -62135596800 represents "no date" (year 0) const hasVacDate = legacy.vac_date && legacy.vac_date > 0; const hasGameBanDate = legacy.game_ban_date && legacy.game_ban_date > 0; return { id: legacy.steamid64, name: legacy.name, avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`, vac_count: legacy.vac ? 1 : 0, vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null, game_ban_count: legacy.game_ban ? 1 : 0, game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null, tracked: legacy.tracked, wins: legacy.match_stats?.win, losses: legacy.match_stats?.loss, matches: legacy.matches?.map((match) => { // Extract Premier rating from rank object const rankOld = match.stats.rank && typeof match.stats.rank.old === 'number' ? (match.stats.rank.old as number) : undefined; const rankNew = match.stats.rank && typeof match.stats.rank.new === 'number' ? (match.stats.rank.new as number) : undefined; return { match_id: match.match_id, map: match.map || 'unknown', date: new Date(match.date * 1000).toISOString(), score_team_a: match.score[0], score_team_b: match.score[1], duration: match.duration, match_result: match.match_result, max_rounds: match.max_rounds, demo_parsed: match.parsed, vac_present: match.vac, gameban_present: match.game_ban, stats: { id: legacy.steamid64, name: legacy.name, avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`, // Fix team_id: API returns 1/2, but schema expects min 2 // Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists) team_id: match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id, kills: match.stats.kills, deaths: match.stats.deaths, assists: match.stats.assists, headshot: match.stats.headshot, mvp: match.stats.mvp, score: match.stats.score, // Premier rating (CS2: 0-30000) rank_old: rankOld, rank_new: rankNew, mk_2: match.stats.multi_kills?.duo, mk_3: match.stats.multi_kills?.triple, mk_4: match.stats.multi_kills?.quad, mk_5: match.stats.multi_kills?.ace } }; }) }; }