fix: Remove Number() conversions that corrupt uint64 IDs
JavaScript's Number type cannot accurately represent uint64 values exceeding Number.MAX_SAFE_INTEGER (2^53-1). Converting these IDs to numbers causes precision loss and API errors. Root cause found: - match/[id]/+layout.ts: `Number(params.id)` corrupted match IDs - player/[id]/+page.ts: `Number(params.id)` corrupted player IDs Example of the bug: - URL param: "3638078243082338615" (correct) - After Number(): 3638078243082339000 (rounded!) - API response: "Match 3638078243082339000 not found" Changes: - Remove Number() conversions in route loaders - Keep params.id as string throughout the application - Update API functions to only accept string (not string | number) - Update MatchesQueryParams.player_id type to string - Add comprehensive transformers for legacy API responses - Transform player stats: duo→mk_2, triple→mk_3, steamid64→id - Build full Steam avatar URLs - Make share_code optional (not always present) This ensures uint64 IDs maintain full precision from URL → API → response. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
parseMatch,
|
||||
parseMatchRounds,
|
||||
parseMatchWeapons,
|
||||
parseMatchChat,
|
||||
parseMatchParseResponse
|
||||
} from '$lib/schemas';
|
||||
import { transformMatchesListResponse, type LegacyMatchListItem } from './transformers';
|
||||
import {
|
||||
transformMatchesListResponse,
|
||||
transformMatchDetail,
|
||||
type LegacyMatchListItem,
|
||||
type LegacyMatchDetail
|
||||
} from './transformers';
|
||||
import type {
|
||||
Match,
|
||||
MatchesListResponse,
|
||||
@@ -36,15 +40,16 @@ export const matchesAPI = {
|
||||
|
||||
/**
|
||||
* Get match details with player statistics
|
||||
* @param matchId - Match ID (uint64)
|
||||
* @param matchId - Match ID (uint64 as string)
|
||||
* @returns Complete match data
|
||||
*/
|
||||
async getMatch(matchId: string | number): Promise<Match> {
|
||||
async getMatch(matchId: string): Promise<Match> {
|
||||
const url = `/match/${matchId}`;
|
||||
const data = await apiClient.get<Match>(url);
|
||||
// API returns legacy format
|
||||
const data = await apiClient.get<LegacyMatchDetail>(url);
|
||||
|
||||
// Validate with Zod schema
|
||||
return parseMatch(data);
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchDetail(data);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,11 +8,11 @@ import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
|
||||
export const playersAPI = {
|
||||
/**
|
||||
* Get player profile with match history
|
||||
* @param steamId - Steam ID (uint64)
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param beforeTime - Optional Unix timestamp for pagination
|
||||
* @returns Player profile with recent matches
|
||||
*/
|
||||
async getPlayer(steamId: string | number, beforeTime?: number): Promise<Player> {
|
||||
async getPlayer(steamId: string, beforeTime?: number): Promise<Player> {
|
||||
const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`;
|
||||
const data = await apiClient.get<Player>(url);
|
||||
|
||||
@@ -22,11 +22,11 @@ export const playersAPI = {
|
||||
|
||||
/**
|
||||
* Get lightweight player metadata
|
||||
* @param steamId - Steam ID
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param limit - Number of recent matches to include (default: 10)
|
||||
* @returns Player metadata
|
||||
*/
|
||||
async getPlayerMeta(steamId: string | number, limit = 10): Promise<PlayerMeta> {
|
||||
async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> {
|
||||
const url = `/player/${steamId}/meta/${limit}`;
|
||||
const data = await apiClient.get<PlayerMeta>(url);
|
||||
|
||||
@@ -36,21 +36,21 @@ export const playersAPI = {
|
||||
|
||||
/**
|
||||
* Add player to tracking system
|
||||
* @param steamId - Steam ID
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @param authCode - Steam authentication code
|
||||
* @returns Success response
|
||||
*/
|
||||
async trackPlayer(steamId: string | number, authCode: string): Promise<TrackPlayerResponse> {
|
||||
async trackPlayer(steamId: string, authCode: string): Promise<TrackPlayerResponse> {
|
||||
const url = `/player/${steamId}/track`;
|
||||
return apiClient.post<TrackPlayerResponse>(url, { auth_code: authCode });
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove player from tracking system
|
||||
* @param steamId - Steam ID
|
||||
* @param steamId - Steam ID (uint64 as string to preserve precision)
|
||||
* @returns Success response
|
||||
*/
|
||||
async untrackPlayer(steamId: string | number): Promise<TrackPlayerResponse> {
|
||||
async untrackPlayer(steamId: string): Promise<TrackPlayerResponse> {
|
||||
const url = `/player/${steamId}/track`;
|
||||
return apiClient.delete<TrackPlayerResponse>(url);
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
|
||||
*/
|
||||
|
||||
import type { MatchListItem, MatchesListResponse } from '$lib/types';
|
||||
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Legacy API match format (from api.csgow.tf)
|
||||
@@ -21,12 +21,71 @@ export interface LegacyMatchListItem {
|
||||
game_ban: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy API match detail format
|
||||
*/
|
||||
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;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
stats?: LegacyPlayerStats[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy player stats format
|
||||
*/
|
||||
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<string, unknown>;
|
||||
multi_kills?: {
|
||||
duo?: number;
|
||||
triple?: number;
|
||||
quad?: number;
|
||||
ace?: number;
|
||||
};
|
||||
dmg?: Record<string, unknown>;
|
||||
flash?: {
|
||||
duration?: {
|
||||
self?: number;
|
||||
team?: number;
|
||||
enemy?: number;
|
||||
};
|
||||
total?: {
|
||||
self?: number;
|
||||
team?: number;
|
||||
enemy?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy match list item to new format
|
||||
*/
|
||||
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
|
||||
return {
|
||||
match_id: Number(legacy.match_id),
|
||||
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],
|
||||
@@ -49,3 +108,56 @@ export function transformMatchesListResponse(
|
||||
next_page_time: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform legacy player stats to new format
|
||||
*/
|
||||
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
|
||||
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,
|
||||
kast: 0, // Not provided by legacy API
|
||||
// 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,
|
||||
tick_rate: 64, // Default to 64, not provided by API
|
||||
players: legacy.stats?.map(transformPlayerStats)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
|
||||
|
||||
/** MatchParseResponse schema */
|
||||
export const matchParseResponseSchema = z.object({
|
||||
match_id: z.number().positive(),
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
status: z.enum(['parsing', 'queued', 'completed', 'error']),
|
||||
message: z.string(),
|
||||
estimated_time: z.number().int().positive().optional()
|
||||
@@ -31,7 +31,7 @@ export const matchParseResponseSchema = z.object({
|
||||
|
||||
/** MatchParseStatus schema */
|
||||
export const matchParseStatusSchema = z.object({
|
||||
match_id: z.number().positive(),
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
status: z.enum(['pending', 'parsing', 'completed', 'error']),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
error_message: z.string().optional()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
/** MatchPlayer schema */
|
||||
export const matchPlayerSchema = z.object({
|
||||
id: z.number().positive(),
|
||||
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||
name: z.string().min(1),
|
||||
avatar: z.string().url(),
|
||||
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
|
||||
@@ -59,10 +59,11 @@ export const matchPlayerSchema = z.object({
|
||||
|
||||
/** Match schema */
|
||||
export const matchSchema = z.object({
|
||||
match_id: z.number().positive(),
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
share_code: z
|
||||
.string()
|
||||
.regex(/^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/),
|
||||
.regex(/^(CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})?$/)
|
||||
.optional(),
|
||||
map: z.string().min(1),
|
||||
date: z.string().datetime(),
|
||||
score_team_a: z.number().int().nonnegative(),
|
||||
@@ -79,7 +80,7 @@ export const matchSchema = z.object({
|
||||
|
||||
/** MatchListItem schema */
|
||||
export const matchListItemSchema = z.object({
|
||||
match_id: z.number().positive(),
|
||||
match_id: z.string().min(1), // uint64 as string to preserve precision
|
||||
map: z.string().min(1),
|
||||
date: z.string().datetime(),
|
||||
score_team_a: z.number().int().nonnegative(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { matchSchema, matchPlayerSchema } from './match.schema';
|
||||
|
||||
/** Player schema */
|
||||
export const playerSchema = z.object({
|
||||
id: z.number().positive(),
|
||||
id: z.string().min(1), // Steam ID uint64 as string to preserve precision
|
||||
name: z.string().min(1),
|
||||
avatar: z.string().url(),
|
||||
vanity_url: z.string().optional(),
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Represents a complete CS2 match with metadata and optional player stats
|
||||
*/
|
||||
export interface Match {
|
||||
/** Unique match identifier (uint64) */
|
||||
match_id: number;
|
||||
/** Unique match identifier (uint64 as string to preserve precision) */
|
||||
match_id: string;
|
||||
|
||||
/** CS:GO/CS2 share code */
|
||||
share_code: string;
|
||||
/** CS:GO/CS2 share code (optional, may not always be available) */
|
||||
share_code?: string;
|
||||
|
||||
/** Map name (e.g., "de_inferno") */
|
||||
map: string;
|
||||
@@ -50,7 +50,7 @@ export interface Match {
|
||||
* Minimal match information for lists
|
||||
*/
|
||||
export interface MatchListItem {
|
||||
match_id: number;
|
||||
match_id: string;
|
||||
map: string;
|
||||
date: string;
|
||||
score_team_a: number;
|
||||
@@ -65,8 +65,8 @@ export interface MatchListItem {
|
||||
* Player performance data for a specific match
|
||||
*/
|
||||
export interface MatchPlayer {
|
||||
/** Player Steam ID */
|
||||
id: number;
|
||||
/** Player Steam ID (uint64 as string to preserve precision) */
|
||||
id: string;
|
||||
|
||||
/** Player display name */
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { Match, MatchPlayer } from './Match';
|
||||
* Represents a Steam user with CS2 statistics
|
||||
*/
|
||||
export interface Player {
|
||||
/** Steam ID (uint64) */
|
||||
id: number;
|
||||
/** Steam ID (uint64 as string to preserve precision) */
|
||||
id: string;
|
||||
|
||||
/** Steam display name */
|
||||
name: string;
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface APIResponse<T> {
|
||||
* Match parse response
|
||||
*/
|
||||
export interface MatchParseResponse {
|
||||
match_id: number;
|
||||
match_id: string; // uint64 as string to preserve precision
|
||||
status: 'parsing' | 'queued' | 'completed' | 'error';
|
||||
message: string;
|
||||
estimated_time?: number; // seconds
|
||||
@@ -38,7 +38,7 @@ export interface MatchParseResponse {
|
||||
* Match parse status
|
||||
*/
|
||||
export interface MatchParseStatus {
|
||||
match_id: number;
|
||||
match_id: string; // uint64 as string to preserve precision
|
||||
status: 'pending' | 'parsing' | 'completed' | 'error';
|
||||
progress?: number; // 0-100
|
||||
error_message?: string;
|
||||
@@ -60,7 +60,7 @@ export interface MatchesListResponse {
|
||||
export interface MatchesQueryParams {
|
||||
limit?: number; // 1-100
|
||||
map?: string;
|
||||
player_id?: number;
|
||||
player_id?: string; // Steam ID uint64 as string
|
||||
before_time?: number; // Unix timestamp for pagination
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { api } from '$lib/api';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ params }) => {
|
||||
const matchId = Number(params.id);
|
||||
const matchId = params.id; // Keep as string to preserve uint64 precision
|
||||
|
||||
if (isNaN(matchId) || matchId <= 0) {
|
||||
if (!matchId || matchId.trim() === '') {
|
||||
throw error(400, 'Invalid match ID');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { api } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const playerId = Number(params.id);
|
||||
const playerId = params.id; // Keep as string to preserve uint64 precision
|
||||
|
||||
if (isNaN(playerId) || playerId <= 0) {
|
||||
if (!playerId || playerId.trim() === '') {
|
||||
throw error(400, 'Invalid player ID');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user