diff --git a/.env.example b/.env.example index 4647eaa..ff47f5c 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,79 @@ +# CS2.WTF Environment Configuration +# Copy this file to .env for local development +# DO NOT commit .env to version control + +# ============================================ # API Configuration +# ============================================ + +# Backend API Base URL +# Default: http://localhost:8000 (local development) +# Production: https://api.csgow.tf or your backend URL VITE_API_BASE_URL=http://localhost:8000 + +# API request timeout in milliseconds +# Default: 10000 (10 seconds) VITE_API_TIMEOUT=10000 +# ============================================ # Feature Flags -VITE_ENABLE_LIVE_MATCHES=false -VITE_ENABLE_ANALYTICS=false +# ============================================ -# Analytics (optional) +# Enable live match updates (polling/WebSocket) +# Default: false +VITE_ENABLE_LIVE_MATCHES=false + +# Enable analytics tracking +# Default: true (respects user consent) +VITE_ENABLE_ANALYTICS=true + +# Enable debug mode (verbose logging, dev tools) +# Default: false +VITE_DEBUG_MODE=false + +# ============================================ +# Analytics & Tracking (Optional) +# ============================================ + +# Plausible Analytics +# Only required if analytics is enabled # VITE_PLAUSIBLE_DOMAIN=cs2.wtf # VITE_PLAUSIBLE_API_HOST=https://plausible.io -# Sentry (optional) -# VITE_SENTRY_DSN= -# VITE_SENTRY_ENVIRONMENT=development +# Umami Analytics (alternative) +# VITE_UMAMI_WEBSITE_ID=your-website-id +# VITE_UMAMI_SRC=https://analytics.example.com/script.js + +# ============================================ +# Experimental Features +# ============================================ + +# Enable WebGL-based heatmaps (high performance) +# Default: false (use Canvas fallback) +# VITE_ENABLE_WEBGL_HEATMAPS=false + +# Enable MSW API mocking in development +# Useful for frontend development without backend +# Default: false +# VITE_ENABLE_MSW_MOCKING=false + +# ============================================ +# Build Configuration +# ============================================ + +# App version (auto-populated from package.json) +# VITE_APP_VERSION=2.0.0 + +# Build timestamp (auto-populated during build) +# VITE_BUILD_TIMESTAMP=2024-11-04T12:00:00Z + +# ============================================ +# SSR/Deployment (Advanced) +# ============================================ + +# Public base URL for the application +# Used for canonical URLs, sitemaps, etc. +# PUBLIC_BASE_URL=https://cs2.wtf + +# Origin whitelist for CORS (if handling API in same domain) +# PUBLIC_CORS_ORIGINS=https://cs2.wtf,https://www.cs2.wtf diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..b735293 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,169 @@ +import axios from 'axios'; +import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; +import { APIException } from '$lib/types'; + +/** + * API Client Configuration + */ +const API_BASE_URL = + typeof window !== 'undefined' + ? import.meta.env?.VITE_API_BASE_URL || 'http://localhost:8000' + : 'http://localhost:8000'; + +const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000; + +/** + * Base API Client + * Provides centralized HTTP communication with error handling + */ +class APIClient { + private client: AxiosInstance; + private abortControllers: Map; + + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + timeout: API_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + } + }); + + this.abortControllers = new Map(); + + // Request interceptor + this.client.interceptors.request.use( + (config) => { + // Add request ID for tracking + const requestId = `${config.method}_${config.url}_${Date.now()}`; + config.headers['X-Request-ID'] = requestId; + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const apiError = this.handleError(error); + return Promise.reject(apiError); + } + ); + } + + /** + * Handle API errors and convert to APIException + */ + private handleError(error: AxiosError): APIException { + // Network error (no response from server) + if (!error.response) { + if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { + return APIException.timeout('Request timed out. Please try again.'); + } + return APIException.networkError( + 'Unable to connect to the server. Please check your internet connection.' + ); + } + + // Server responded with error status + const { status, data } = error.response; + return APIException.fromResponse(status, data); + } + + /** + * GET request + */ + async get(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.get(url, config); + return response.data; + } + + /** + * POST request + */ + async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response = await this.client.post(url, data, config); + return response.data; + } + + /** + * PUT request + */ + async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const response = await this.client.put(url, data, config); + return response.data; + } + + /** + * DELETE request + */ + async delete(url: string, config?: AxiosRequestConfig): Promise { + const response = await this.client.delete(url, config); + return response.data; + } + + /** + * Cancelable GET request + * Automatically cancels previous request with same key + */ + async getCancelable(url: string, key: string, config?: AxiosRequestConfig): Promise { + // Cancel previous request with same key + if (this.abortControllers.has(key)) { + this.abortControllers.get(key)?.abort(); + } + + // Create new abort controller + const controller = new AbortController(); + this.abortControllers.set(key, controller); + + try { + const response = await this.client.get(url, { + ...config, + signal: controller.signal + }); + this.abortControllers.delete(key); + return response.data; + } catch (error) { + this.abortControllers.delete(key); + throw error; + } + } + + /** + * Cancel a specific request by key + */ + cancelRequest(key: string): void { + const controller = this.abortControllers.get(key); + if (controller) { + controller.abort(); + this.abortControllers.delete(key); + } + } + + /** + * Cancel all pending requests + */ + cancelAllRequests(): void { + this.abortControllers.forEach((controller) => controller.abort()); + this.abortControllers.clear(); + } + + /** + * Get base URL for constructing full URLs + */ + getBaseURL(): string { + return API_BASE_URL; + } +} + +/** + * Singleton API client instance + */ +export const apiClient = new APIClient(); + +/** + * Export for testing/mocking + */ +export { APIClient }; diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..15e3fbf --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,30 @@ +/** + * CS2.WTF API Client + * Central export for all API endpoints + */ + +export { apiClient, APIClient } from './client'; +export { playersAPI } from './players'; +export { matchesAPI } from './matches'; + +/** + * Convenience re-exports + */ +export { APIException, APIErrorType } from '$lib/types'; + +// Import for combined API object +import { playersAPI } from './players'; +import { matchesAPI } from './matches'; + +/** + * Combined API object for convenience + */ +export const api = { + players: playersAPI, + matches: matchesAPI +}; + +/** + * Default export + */ +export default api; diff --git a/src/lib/api/matches.ts b/src/lib/api/matches.ts new file mode 100644 index 0000000..f94743d --- /dev/null +++ b/src/lib/api/matches.ts @@ -0,0 +1,150 @@ +import { apiClient } from './client'; +import { + parseMatch, + parseMatchesList, + parseMatchRounds, + parseMatchWeapons, + parseMatchChat, + parseMatchParseResponse +} from '$lib/schemas'; +import type { + Match, + MatchesListResponse, + MatchesQueryParams, + MatchParseResponse, + MatchRoundsResponse, + MatchWeaponsResponse, + MatchChatResponse +} from '$lib/types'; + +/** + * Match API endpoints + */ +export const matchesAPI = { + /** + * Parse match from share code + * @param shareCode - CS:GO/CS2 match share code + * @returns Parse status response + */ + async parseMatch(shareCode: string): Promise { + const url = `/match/parse/${shareCode}`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parseMatchParseResponse(data); + }, + + /** + * Get match details with player statistics + * @param matchId - Match ID (uint64) + * @returns Complete match data + */ + async getMatch(matchId: string | number): Promise { + const url = `/match/${matchId}`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parseMatch(data); + }, + + /** + * Get match weapons statistics + * @param matchId - Match ID + * @returns Weapon statistics for all players + */ + async getMatchWeapons(matchId: string | number): Promise { + const url = `/match/${matchId}/weapons`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parseMatchWeapons(data); + }, + + /** + * Get match round-by-round statistics + * @param matchId - Match ID + * @returns Round statistics and economy data + */ + async getMatchRounds(matchId: string | number): Promise { + const url = `/match/${matchId}/rounds`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parseMatchRounds(data); + }, + + /** + * Get match chat messages + * @param matchId - Match ID + * @returns Chat messages from the match + */ + async getMatchChat(matchId: string | number): Promise { + const url = `/match/${matchId}/chat`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parseMatchChat(data); + }, + + /** + * Get paginated list of matches + * @param params - Query parameters (filters, pagination) + * @returns List of matches with pagination + */ + async getMatches(params?: MatchesQueryParams): Promise { + const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches'; + + const data = await apiClient.get(url, { + params: { + limit: params?.limit, + map: params?.map, + player_id: params?.player_id + } + }); + + // Validate with Zod schema + return parseMatchesList(data); + }, + + /** + * Search matches (cancelable for live search) + * @param params - Search parameters + * @returns List of matching matches + */ + async searchMatches(params?: MatchesQueryParams): Promise { + const url = '/matches'; + const data = await apiClient.getCancelable(url, 'match-search', { + params: { + limit: params?.limit || 20, + map: params?.map, + player_id: params?.player_id, + before_time: params?.before_time + } + }); + + // Validate with Zod schema + return parseMatchesList(data); + }, + + /** + * Get match by share code + * Convenience method that extracts match ID from share code if needed + * @param shareCodeOrId - Share code or match ID + * @returns Match data + */ + async getMatchByShareCode(shareCodeOrId: string): Promise { + // If it looks like a share code, parse it first + if (shareCodeOrId.startsWith('CSGO-')) { + const parseResult = await this.parseMatch(shareCodeOrId); + return this.getMatch(parseResult.match_id); + } + + // Otherwise treat as match ID + return this.getMatch(shareCodeOrId); + } +}; + +/** + * Match API with default export + */ +export default matchesAPI; diff --git a/src/lib/api/players.ts b/src/lib/api/players.ts new file mode 100644 index 0000000..bcd00c5 --- /dev/null +++ b/src/lib/api/players.ts @@ -0,0 +1,76 @@ +import { apiClient } from './client'; +import { parsePlayer, parsePlayerMeta } from '$lib/schemas'; +import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types'; + +/** + * Player API endpoints + */ +export const playersAPI = { + /** + * Get player profile with match history + * @param steamId - Steam ID (uint64) + * @param beforeTime - Optional Unix timestamp for pagination + * @returns Player profile with recent matches + */ + async getPlayer(steamId: string | number, beforeTime?: number): Promise { + const url = beforeTime ? `/player/${steamId}/next/${beforeTime}` : `/player/${steamId}`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parsePlayer(data); + }, + + /** + * Get lightweight player metadata + * @param steamId - Steam ID + * @param limit - Number of recent matches to include (default: 10) + * @returns Player metadata + */ + async getPlayerMeta(steamId: string | number, limit = 10): Promise { + const url = `/player/${steamId}/meta/${limit}`; + const data = await apiClient.get(url); + + // Validate with Zod schema + return parsePlayerMeta(data); + }, + + /** + * Add player to tracking system + * @param steamId - Steam ID + * @param authCode - Steam authentication code + * @returns Success response + */ + async trackPlayer(steamId: string | number, authCode: string): Promise { + const url = `/player/${steamId}/track`; + return apiClient.post(url, { auth_code: authCode }); + }, + + /** + * Remove player from tracking system + * @param steamId - Steam ID + * @returns Success response + */ + async untrackPlayer(steamId: string | number): Promise { + const url = `/player/${steamId}/track`; + return apiClient.delete(url); + }, + + /** + * Search players by name (cancelable) + * @param query - Search query + * @param limit - Maximum results + * @returns Array of player matches + */ + async searchPlayers(query: string, limit = 10): Promise { + const url = `/players/search`; + const data = await apiClient.getCancelable(url, 'player-search', { + params: { q: query, limit } + }); + return data; + } +}; + +/** + * Player API with default export + */ +export default playersAPI; diff --git a/src/lib/schemas/api.schema.ts b/src/lib/schemas/api.schema.ts new file mode 100644 index 0000000..924a20c --- /dev/null +++ b/src/lib/schemas/api.schema.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import { matchListItemSchema } from './match.schema'; + +/** + * Zod schemas for API responses and error handling + */ + +/** APIError schema */ +export const apiErrorSchema = z.object({ + error: z.string(), + message: z.string(), + status_code: z.number().int(), + timestamp: z.string().datetime().optional() +}); + +/** Generic APIResponse schema */ +export const apiResponseSchema = (dataSchema: T) => + z.object({ + data: dataSchema, + success: z.boolean(), + error: apiErrorSchema.optional() + }); + +/** MatchParseResponse schema */ +export const matchParseResponseSchema = z.object({ + match_id: z.number().positive(), + status: z.enum(['parsing', 'queued', 'completed', 'error']), + message: z.string(), + estimated_time: z.number().int().positive().optional() +}); + +/** MatchParseStatus schema */ +export const matchParseStatusSchema = z.object({ + match_id: z.number().positive(), + status: z.enum(['pending', 'parsing', 'completed', 'error']), + progress: z.number().int().min(0).max(100).optional(), + error_message: z.string().optional() +}); + +/** MatchesListResponse schema */ +export const matchesListResponseSchema = z.object({ + matches: z.array(matchListItemSchema), + next_page_time: z.number().int().optional(), + has_more: z.boolean(), + total_count: z.number().int().nonnegative().optional() +}); + +/** MatchesQueryParams schema */ +export const matchesQueryParamsSchema = z.object({ + limit: z.number().int().min(1).max(100).optional(), + map: z.string().optional(), + player_id: z.number().positive().optional(), + before_time: z.number().int().positive().optional() +}); + +/** TrackPlayerResponse schema */ +export const trackPlayerResponseSchema = z.object({ + success: z.boolean(), + message: z.string() +}); + +/** Parser functions */ +export const parseAPIError = (data: unknown) => apiErrorSchema.parse(data); +export const parseMatchParseResponse = (data: unknown) => matchParseResponseSchema.parse(data); +export const parseMatchesList = (data: unknown) => matchesListResponseSchema.parse(data); +export const parseMatchesQueryParams = (data: unknown) => matchesQueryParamsSchema.parse(data); +export const parseTrackPlayerResponse = (data: unknown) => trackPlayerResponseSchema.parse(data); + +/** Safe parser functions */ +export const parseMatchesListSafe = (data: unknown) => matchesListResponseSchema.safeParse(data); +export const parseAPIErrorSafe = (data: unknown) => apiErrorSchema.safeParse(data); + +/** Infer TypeScript types */ +export type APIErrorSchema = z.infer; +export type MatchParseResponseSchema = z.infer; +export type MatchParseStatusSchema = z.infer; +export type MatchesListResponseSchema = z.infer; +export type MatchesQueryParamsSchema = z.infer; +export type TrackPlayerResponseSchema = z.infer; diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts new file mode 100644 index 0000000..a6350c7 --- /dev/null +++ b/src/lib/schemas/index.ts @@ -0,0 +1,116 @@ +/** + * Central export for all Zod schemas + * Provides runtime validation for CS2.WTF data models + */ + +// Match schemas +export { + matchSchema, + matchPlayerSchema, + matchListItemSchema, + parseMatch, + parseMatchSafe, + parseMatchPlayer, + parseMatchListItem, + type MatchSchema, + type MatchPlayerSchema, + type MatchListItemSchema +} from './match.schema'; + +// Player schemas +export { + playerSchema, + playerMetaSchema, + playerProfileSchema, + parsePlayer, + parsePlayerSafe, + parsePlayerMeta, + parsePlayerProfile, + normalizePlayerData, + type PlayerSchema, + type PlayerMetaSchema, + type PlayerProfileSchema +} from './player.schema'; + +// Round statistics schemas +export { + roundStatsSchema, + roundDetailSchema, + matchRoundsResponseSchema, + teamRoundStatsSchema, + parseRoundStats, + parseRoundDetail, + parseMatchRounds, + parseTeamRoundStats, + parseRoundStatsSafe, + parseMatchRoundsSafe, + type RoundStatsSchema, + type RoundDetailSchema, + type MatchRoundsResponseSchema, + type TeamRoundStatsSchema +} from './roundStats.schema'; + +// Weapon schemas +export { + weaponSchema, + hitGroupsSchema, + weaponStatsSchema, + playerWeaponStatsSchema, + matchWeaponsResponseSchema, + parseWeapon, + parseWeaponStats, + parsePlayerWeaponStats, + parseMatchWeapons, + parseWeaponSafe, + parseMatchWeaponsSafe, + type WeaponSchema, + type HitGroupsSchema, + type WeaponStatsSchema, + type PlayerWeaponStatsSchema, + type MatchWeaponsResponseSchema +} from './weapon.schema'; + +// Message/Chat schemas +export { + messageSchema, + matchChatResponseSchema, + enrichedMessageSchema, + chatFilterSchema, + chatStatsSchema, + parseMessage, + parseMatchChat, + parseEnrichedMessage, + parseChatFilter, + parseChatStats, + parseMessageSafe, + parseMatchChatSafe, + type MessageSchema, + type MatchChatResponseSchema, + type EnrichedMessageSchema, + type ChatFilterSchema, + type ChatStatsSchema +} from './message.schema'; + +// API schemas +export { + apiErrorSchema, + apiResponseSchema, + matchParseResponseSchema, + matchParseStatusSchema, + matchesListResponseSchema, + matchesQueryParamsSchema, + trackPlayerResponseSchema, + parseAPIError, + parseMatchParseResponse, + parseMatchesList, + parseMatchesQueryParams, + parseTrackPlayerResponse, + parseMatchesListSafe, + parseAPIErrorSafe, + type APIErrorSchema, + type MatchParseResponseSchema, + type MatchParseStatusSchema, + type MatchesListResponseSchema, + type MatchesQueryParamsSchema, + type TrackPlayerResponseSchema +} from './api.schema'; diff --git a/src/lib/schemas/match.schema.ts b/src/lib/schemas/match.schema.ts new file mode 100644 index 0000000..39a06d1 --- /dev/null +++ b/src/lib/schemas/match.schema.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; + +/** + * Zod schemas for Match data models + * Provides runtime validation and type safety + */ + +/** MatchPlayer schema */ +export const matchPlayerSchema = z.object({ + id: z.number().positive(), + name: z.string().min(1), + avatar: z.string().url(), + team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT + + // Performance metrics + kills: z.number().int().nonnegative(), + deaths: z.number().int().nonnegative(), + assists: z.number().int().nonnegative(), + headshot: z.number().int().nonnegative(), + mvp: z.number().int().nonnegative(), + score: z.number().int().nonnegative(), + kast: z.number().int().min(0).max(100), + + // Rank (CS2 Premier rating: 0-30000) + rank_old: z.number().int().min(0).max(30000).optional(), + rank_new: z.number().int().min(0).max(30000).optional(), + + // Damage + dmg_enemy: z.number().int().nonnegative().optional(), + dmg_team: z.number().int().nonnegative().optional(), + + // Multi-kills + mk_2: z.number().int().nonnegative().optional(), + mk_3: z.number().int().nonnegative().optional(), + mk_4: z.number().int().nonnegative().optional(), + mk_5: z.number().int().nonnegative().optional(), + + // Utility damage + ud_he: z.number().int().nonnegative().optional(), + ud_flames: z.number().int().nonnegative().optional(), + ud_flash: z.number().int().nonnegative().optional(), + ud_smoke: z.number().int().nonnegative().optional(), + ud_decoy: z.number().int().nonnegative().optional(), + + // Flash statistics + flash_assists: z.number().int().nonnegative().optional(), + flash_duration_enemy: z.number().nonnegative().optional(), + flash_duration_team: z.number().nonnegative().optional(), + flash_duration_self: z.number().nonnegative().optional(), + flash_total_enemy: z.number().int().nonnegative().optional(), + flash_total_team: z.number().int().nonnegative().optional(), + flash_total_self: z.number().int().nonnegative().optional(), + + // Other + crosshair: z.string().optional(), + color: z.enum(['green', 'yellow', 'purple', 'blue', 'orange', 'grey']).optional(), + avg_ping: z.number().nonnegative().optional() +}); + +/** Match schema */ +export const matchSchema = z.object({ + match_id: z.number().positive(), + 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}$/), + map: z.string().min(1), + date: z.string().datetime(), + score_team_a: z.number().int().nonnegative(), + score_team_b: z.number().int().nonnegative(), + duration: z.number().int().positive(), + match_result: z.number().int().min(0).max(2), // 0 = tie, 1 = team_a win, 2 = team_b win + max_rounds: z.number().int().positive(), + demo_parsed: z.boolean(), + vac_present: z.boolean(), + gameban_present: z.boolean(), + tick_rate: z.number().positive(), + players: z.array(matchPlayerSchema).optional() +}); + +/** MatchListItem schema */ +export const matchListItemSchema = z.object({ + match_id: z.number().positive(), + map: z.string().min(1), + date: z.string().datetime(), + score_team_a: z.number().int().nonnegative(), + score_team_b: z.number().int().nonnegative(), + duration: z.number().int().positive(), + demo_parsed: z.boolean(), + player_count: z.number().int().min(2).max(10) +}); + +/** Parser functions for safe data validation */ +export const parseMatch = (data: unknown) => matchSchema.parse(data); +export const parseMatchSafe = (data: unknown) => matchSchema.safeParse(data); +export const parseMatchPlayer = (data: unknown) => matchPlayerSchema.parse(data); +export const parseMatchListItem = (data: unknown) => matchListItemSchema.parse(data); + +/** Infer TypeScript types from schemas */ +export type MatchSchema = z.infer; +export type MatchPlayerSchema = z.infer; +export type MatchListItemSchema = z.infer; diff --git a/src/lib/schemas/message.schema.ts b/src/lib/schemas/message.schema.ts new file mode 100644 index 0000000..da4845c --- /dev/null +++ b/src/lib/schemas/message.schema.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +/** + * Zod schemas for Message/Chat data models + */ + +/** Message schema */ +export const messageSchema = z.object({ + message: z.string(), + all_chat: z.boolean(), + tick: z.number().int().nonnegative(), + match_player_id: z.number().positive().optional(), + player_id: z.number().positive().optional(), + player_name: z.string().optional(), + round: z.number().int().positive().optional(), + timestamp: z.string().datetime().optional() +}); + +/** MatchChatResponse schema */ +export const matchChatResponseSchema = z.object({ + match_id: z.number().positive(), + messages: z.array(messageSchema) +}); + +/** EnrichedMessage schema (with player data) */ +export const enrichedMessageSchema = messageSchema.extend({ + player_name: z.string().min(1), + player_avatar: z.string().url().optional(), + team_id: z.number().int().min(2).max(3).optional(), + round: z.number().int().positive() +}); + +/** ChatFilter schema */ +export const chatFilterSchema = z.object({ + player_id: z.number().positive().optional(), + chat_type: z.enum(['all', 'team', 'all_chat']).optional(), + round: z.number().int().positive().optional(), + search: z.string().optional() +}); + +/** ChatStats schema */ +export const chatStatsSchema = z.object({ + total_messages: z.number().int().nonnegative(), + team_chat_count: z.number().int().nonnegative(), + all_chat_count: z.number().int().nonnegative(), + messages_per_player: z.record(z.number().int().nonnegative()), + most_active_player: z.object({ + player_id: z.number().positive(), + message_count: z.number().int().positive() + }) +}); + +/** Parser functions */ +export const parseMessage = (data: unknown) => messageSchema.parse(data); +export const parseMatchChat = (data: unknown) => matchChatResponseSchema.parse(data); +export const parseEnrichedMessage = (data: unknown) => enrichedMessageSchema.parse(data); +export const parseChatFilter = (data: unknown) => chatFilterSchema.parse(data); +export const parseChatStats = (data: unknown) => chatStatsSchema.parse(data); + +/** Safe parser functions */ +export const parseMessageSafe = (data: unknown) => messageSchema.safeParse(data); +export const parseMatchChatSafe = (data: unknown) => matchChatResponseSchema.safeParse(data); + +/** Infer TypeScript types */ +export type MessageSchema = z.infer; +export type MatchChatResponseSchema = z.infer; +export type EnrichedMessageSchema = z.infer; +export type ChatFilterSchema = z.infer; +export type ChatStatsSchema = z.infer; diff --git a/src/lib/schemas/player.schema.ts b/src/lib/schemas/player.schema.ts new file mode 100644 index 0000000..6526ecc --- /dev/null +++ b/src/lib/schemas/player.schema.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import { matchSchema, matchPlayerSchema } from './match.schema'; + +/** + * Zod schemas for Player data models + */ + +/** Player schema */ +export const playerSchema = z.object({ + id: z.number().positive(), + name: z.string().min(1), + avatar: z.string().url(), + vanity_url: z.string().optional(), + vanity_url_real: z.string().optional(), + steam_updated: z.string().datetime(), + profile_created: z.string().datetime().optional(), + wins: z.number().int().nonnegative().optional(), + losses: z.number().int().nonnegative().optional(), + // Also support backend's typo "looses" + looses: z.number().int().nonnegative().optional(), + ties: z.number().int().nonnegative().optional(), + vac_count: z.number().int().nonnegative().optional(), + vac_date: z.string().datetime().nullable().optional(), + game_ban_count: z.number().int().nonnegative().optional(), + game_ban_date: z.string().datetime().nullable().optional(), + oldest_sharecode_seen: z.string().optional(), + matches: z + .array( + matchSchema.extend({ + stats: matchPlayerSchema + }) + ) + .optional() +}); + +/** Transform player data to normalize "looses" to "losses" */ +export const normalizePlayerData = (data: z.infer) => { + if (data.looses !== undefined && data.losses === undefined) { + return { ...data, losses: data.looses }; + } + return data; +}; + +/** PlayerMeta schema */ +export const playerMetaSchema = z.object({ + id: z.number().positive(), + name: z.string().min(1), + avatar: z.string().url(), + recent_matches: z.number().int().nonnegative(), + last_match_date: z.string().datetime(), + avg_kills: z.number().nonnegative(), + avg_deaths: z.number().nonnegative(), + avg_kast: z.number().nonnegative(), + win_rate: z.number().nonnegative() +}); + +/** PlayerProfile schema (extended with calculated stats) */ +export const playerProfileSchema = playerSchema.extend({ + total_matches: z.number().int().nonnegative(), + kd_ratio: z.number().nonnegative(), + win_rate: z.number().nonnegative(), + avg_headshot_pct: z.number().nonnegative(), + avg_kast: z.number().nonnegative(), + current_rating: z.number().int().min(0).max(30000).optional(), + peak_rating: z.number().int().min(0).max(30000).optional() +}); + +/** Parser functions */ +export const parsePlayer = (data: unknown) => { + const parsed = playerSchema.parse(data); + return normalizePlayerData(parsed); +}; + +export const parsePlayerSafe = (data: unknown) => { + const result = playerSchema.safeParse(data); + if (result.success) { + return { ...result, data: normalizePlayerData(result.data) }; + } + return result; +}; + +export const parsePlayerMeta = (data: unknown) => playerMetaSchema.parse(data); +export const parsePlayerProfile = (data: unknown) => playerProfileSchema.parse(data); + +/** Infer TypeScript types */ +export type PlayerSchema = z.infer; +export type PlayerMetaSchema = z.infer; +export type PlayerProfileSchema = z.infer; diff --git a/src/lib/schemas/roundStats.schema.ts b/src/lib/schemas/roundStats.schema.ts new file mode 100644 index 0000000..df8df9e --- /dev/null +++ b/src/lib/schemas/roundStats.schema.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +/** + * Zod schemas for Round Statistics data models + */ + +/** RoundStats schema */ +export const roundStatsSchema = z.object({ + round: z.number().int().positive(), + bank: z.number().int().nonnegative(), + equipment: z.number().int().nonnegative(), + spent: z.number().int().nonnegative(), + kills_in_round: z.number().int().nonnegative().optional(), + damage_in_round: z.number().int().nonnegative().optional(), + match_player_id: z.number().positive().optional(), + player_id: z.number().positive().optional() +}); + +/** RoundDetail schema (with player breakdown) */ +export const roundDetailSchema = z.object({ + round: z.number().int().positive(), + winner: z.number().int().min(2).max(3), // 2 = T, 3 = CT + win_reason: z.string(), + players: z.array(roundStatsSchema) +}); + +/** MatchRoundsResponse schema */ +export const matchRoundsResponseSchema = z.object({ + match_id: z.number().positive(), + rounds: z.array(roundDetailSchema) +}); + +/** TeamRoundStats schema */ +export const teamRoundStatsSchema = z.object({ + round: z.number().int().positive(), + team_id: z.number().int().min(2).max(3), + total_bank: z.number().int().nonnegative(), + total_equipment: z.number().int().nonnegative(), + avg_equipment: z.number().nonnegative(), + total_spent: z.number().int().nonnegative(), + winner: z.number().int().min(2).max(3).optional(), + win_reason: z + .enum(['elimination', 'bomb_defused', 'bomb_exploded', 'time', 'target_saved']) + .optional(), + buy_type: z.enum(['eco', 'semi-eco', 'force', 'full']).optional() +}); + +/** Parser functions */ +export const parseRoundStats = (data: unknown) => roundStatsSchema.parse(data); +export const parseRoundDetail = (data: unknown) => roundDetailSchema.parse(data); +export const parseMatchRounds = (data: unknown) => matchRoundsResponseSchema.parse(data); +export const parseTeamRoundStats = (data: unknown) => teamRoundStatsSchema.parse(data); + +/** Safe parser functions */ +export const parseRoundStatsSafe = (data: unknown) => roundStatsSchema.safeParse(data); +export const parseMatchRoundsSafe = (data: unknown) => matchRoundsResponseSchema.safeParse(data); + +/** Infer TypeScript types */ +export type RoundStatsSchema = z.infer; +export type RoundDetailSchema = z.infer; +export type MatchRoundsResponseSchema = z.infer; +export type TeamRoundStatsSchema = z.infer; diff --git a/src/lib/schemas/weapon.schema.ts b/src/lib/schemas/weapon.schema.ts new file mode 100644 index 0000000..690e8cd --- /dev/null +++ b/src/lib/schemas/weapon.schema.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +/** + * Zod schemas for Weapon data models + */ + +/** Weapon schema */ +export const weaponSchema = z.object({ + victim: z.number().positive(), + dmg: z.number().int().nonnegative(), + eq_type: z.number().int().positive(), + hit_group: z.number().int().min(0).max(7), // 0-7 hit groups + match_player_id: z.number().positive().optional() +}); + +/** Hit groups breakdown schema */ +export const hitGroupsSchema = z.object({ + head: z.number().int().nonnegative(), + chest: z.number().int().nonnegative(), + stomach: z.number().int().nonnegative(), + left_arm: z.number().int().nonnegative(), + right_arm: z.number().int().nonnegative(), + left_leg: z.number().int().nonnegative(), + right_leg: z.number().int().nonnegative() +}); + +/** WeaponStats schema */ +export const weaponStatsSchema = z.object({ + eq_type: z.number().int().positive(), + weapon_name: z.string().min(1), + kills: z.number().int().nonnegative(), + damage: z.number().int().nonnegative(), + hits: z.number().int().nonnegative(), + hit_groups: hitGroupsSchema, + headshot_pct: z.number().nonnegative().optional(), + accuracy: z.number().nonnegative().optional() +}); + +/** PlayerWeaponStats schema */ +export const playerWeaponStatsSchema = z.object({ + player_id: z.number().positive(), + weapon_stats: z.array(weaponStatsSchema) +}); + +/** MatchWeaponsResponse schema */ +export const matchWeaponsResponseSchema = z.object({ + match_id: z.number().positive(), + weapons: z.array(playerWeaponStatsSchema) +}); + +/** Parser functions */ +export const parseWeapon = (data: unknown) => weaponSchema.parse(data); +export const parseWeaponStats = (data: unknown) => weaponStatsSchema.parse(data); +export const parsePlayerWeaponStats = (data: unknown) => playerWeaponStatsSchema.parse(data); +export const parseMatchWeapons = (data: unknown) => matchWeaponsResponseSchema.parse(data); + +/** Safe parser functions */ +export const parseWeaponSafe = (data: unknown) => weaponSchema.safeParse(data); +export const parseMatchWeaponsSafe = (data: unknown) => matchWeaponsResponseSchema.safeParse(data); + +/** Infer TypeScript types */ +export type WeaponSchema = z.infer; +export type HitGroupsSchema = z.infer; +export type WeaponStatsSchema = z.infer; +export type PlayerWeaponStatsSchema = z.infer; +export type MatchWeaponsResponseSchema = z.infer; diff --git a/src/lib/types/Match.ts b/src/lib/types/Match.ts new file mode 100644 index 0000000..957aac8 --- /dev/null +++ b/src/lib/types/Match.ts @@ -0,0 +1,138 @@ +/** + * Match data model + * Represents a complete CS2 match with metadata and optional player stats + */ +export interface Match { + /** Unique match identifier (uint64) */ + match_id: number; + + /** CS:GO/CS2 share code */ + share_code: string; + + /** Map name (e.g., "de_inferno") */ + map: string; + + /** Match date and time (ISO 8601) */ + date: string; + + /** Final score for team A (T/CT side) */ + score_team_a: number; + + /** Final score for team B (CT/T side) */ + score_team_b: number; + + /** Match duration in seconds */ + duration: number; + + /** Match result: 0 = tie, 1 = team_a win, 2 = team_b win */ + match_result: number; + + /** Maximum rounds (24 for MR12, 30 for MR15) */ + max_rounds: number; + + /** Whether the demo has been successfully parsed */ + demo_parsed: boolean; + + /** Whether any player has a VAC ban */ + vac_present: boolean; + + /** Whether any player has a game ban */ + gameban_present: boolean; + + /** Server tick rate (64 or 128) */ + tick_rate: number; + + /** Array of player statistics (optional, included in detailed match view) */ + players?: MatchPlayer[]; +} + +/** + * Minimal match information for lists + */ +export interface MatchListItem { + match_id: number; + map: string; + date: string; + score_team_a: number; + score_team_b: number; + duration: number; + demo_parsed: boolean; + player_count: number; +} + +/** + * Match player statistics + * Player performance data for a specific match + */ +export interface MatchPlayer { + /** Player Steam ID */ + id: number; + + /** Player display name */ + name: string; + + /** Steam avatar URL */ + avatar: string; + + /** Team ID: 2 = T side, 3 = CT side */ + team_id: number; + + // Performance metrics + kills: number; + deaths: number; + assists: number; + + /** Headshot kills */ + headshot: number; + + /** MVP stars earned */ + mvp: number; + + /** In-game score */ + score: number; + + /** KAST percentage (0-100): Kill/Assist/Survive/Trade */ + kast: number; + + // Rank tracking (CS2 Premier rating: 0-30000) + rank_old?: number; + rank_new?: number; + + // Damage statistics + dmg_enemy?: number; + dmg_team?: number; + + // Multi-kill counts + mk_2?: number; // Double kills + mk_3?: number; // Triple kills + mk_4?: number; // Quad kills + mk_5?: number; // Aces + + // Utility damage + ud_he?: number; // HE grenade damage + ud_flames?: number; // Molotov/Incendiary damage + ud_flash?: number; // Flash grenades used + ud_smoke?: number; // Smoke grenades used + ud_decoy?: number; // Decoy grenades used + + // Flash statistics + flash_assists?: number; + flash_duration_enemy?: number; // Total enemy blind time + flash_duration_team?: number; // Total team blind time + flash_duration_self?: number; // Self-flash time + flash_total_enemy?: number; // Enemies flashed count + flash_total_team?: number; // Teammates flashed count + flash_total_self?: number; // Self-flash count + + // Other + crosshair?: string; + color?: 'green' | 'yellow' | 'purple' | 'blue' | 'orange' | 'grey'; + avg_ping?: number; +} + +/** + * Match with extended player details (full scoreboard) + */ +export type MatchWithPlayers = Match & { + players: MatchPlayer[]; +}; diff --git a/src/lib/types/Message.ts b/src/lib/types/Message.ts new file mode 100644 index 0000000..bef64fc --- /dev/null +++ b/src/lib/types/Message.ts @@ -0,0 +1,78 @@ +/** + * Chat message data model + * In-game chat messages from match demos + */ +export interface Message { + /** Chat message text content */ + message: string; + + /** true = all chat (both teams), false = team chat only */ + all_chat: boolean; + + /** Game tick when message was sent */ + tick: number; + + /** Reference to MatchPlayer ID */ + match_player_id?: number; + + /** Player ID who sent the message */ + player_id?: number; + + /** Player name (included in API response) */ + player_name?: string; + + /** Round number when message was sent */ + round?: number; + + /** Message timestamp (ISO 8601) */ + timestamp?: string; +} + +/** + * Match chat response + */ +export interface MatchChatResponse { + match_id: number; + messages: Message[]; +} + +/** + * Chat message with enhanced player data + */ +export interface EnrichedMessage extends Message { + player_name: string; + player_avatar?: string; + team_id?: number; + round: number; +} + +/** + * Chat filter options + */ +export interface ChatFilter { + /** Filter by player ID */ + player_id?: number; + + /** Filter by chat type */ + chat_type?: 'all' | 'team' | 'all_chat'; + + /** Filter by round number */ + round?: number; + + /** Search message content */ + search?: string; +} + +/** + * Chat statistics + */ +export interface ChatStats { + total_messages: number; + team_chat_count: number; + all_chat_count: number; + messages_per_player: Record; + most_active_player: { + player_id: number; + message_count: number; + }; +} diff --git a/src/lib/types/Player.ts b/src/lib/types/Player.ts new file mode 100644 index 0000000..2f34440 --- /dev/null +++ b/src/lib/types/Player.ts @@ -0,0 +1,107 @@ +import type { Match, MatchPlayer } from './Match'; + +/** + * Player profile data model + * Represents a Steam user with CS2 statistics + */ +export interface Player { + /** Steam ID (uint64) */ + id: number; + + /** Steam display name */ + name: string; + + /** Steam avatar URL */ + avatar: string; + + /** Custom Steam profile URL */ + vanity_url?: string; + + /** Actual vanity URL (may differ from vanity_url) */ + vanity_url_real?: string; + + /** Last time Steam profile was updated (ISO 8601) */ + steam_updated: string; + + /** Steam account creation date (ISO 8601) */ + profile_created?: string; + + /** Total competitive wins */ + wins?: number; + + /** + * Total competitive losses + * Note: Backend has typo "looses", we map it to "losses" + */ + losses?: number; + + /** Total ties */ + ties?: number; + + /** Number of VAC bans on record */ + vac_count?: number; + + /** Date of last VAC ban (ISO 8601) */ + vac_date?: string | null; + + /** Number of game bans on record */ + game_ban_count?: number; + + /** Date of last game ban (ISO 8601) */ + game_ban_date?: string | null; + + /** Oldest match share code seen for this player */ + oldest_sharecode_seen?: string; + + /** Recent matches with player statistics */ + matches?: PlayerMatch[]; +} + +/** + * Player match entry (includes match + player stats) + */ +export interface PlayerMatch extends Match { + /** Player's statistics for this match */ + stats: MatchPlayer; +} + +/** + * Lightweight player metadata for quick previews + */ +export interface PlayerMeta { + id: number; + name: string; + avatar: string; + recent_matches: number; + last_match_date: string; + avg_kills: number; + avg_deaths: number; + avg_kast: number; + win_rate: number; +} + +/** + * Player profile with calculated aggregate statistics + */ +export interface PlayerProfile extends Player { + /** Total matches played */ + total_matches: number; + + /** Overall K/D ratio */ + kd_ratio: number; + + /** Overall win rate percentage */ + win_rate: number; + + /** Average headshot percentage */ + avg_headshot_pct: number; + + /** Average KAST percentage */ + avg_kast: number; + + /** Current CS2 Premier rating (0-30000) */ + current_rating?: number; + + /** Peak CS2 Premier rating */ + peak_rating?: number; +} diff --git a/src/lib/types/RoundStats.ts b/src/lib/types/RoundStats.ts new file mode 100644 index 0000000..d7d99b7 --- /dev/null +++ b/src/lib/types/RoundStats.ts @@ -0,0 +1,85 @@ +/** + * Round statistics data model + * Economy and performance data for a single round + */ +export interface RoundStats { + /** Round number (1-24 for MR12, 1-30 for MR15) */ + round: number; + + /** Money available at round start */ + bank: number; + + /** Value of equipment purchased/held */ + equipment: number; + + /** Total money spent this round */ + spent: number; + + /** Kills achieved in this round */ + kills_in_round?: number; + + /** Damage dealt in this round */ + damage_in_round?: number; + + /** Reference to MatchPlayer ID */ + match_player_id?: number; + + /** Player ID for this round data */ + player_id?: number; +} + +/** + * Team round statistics + * Aggregated economy data for a team in a round + */ +export interface TeamRoundStats { + round: number; + team_id: number; // 2 = T, 3 = CT + + /** Total team money at round start */ + total_bank: number; + + /** Total equipment value */ + total_equipment: number; + + /** Average equipment value per player */ + avg_equipment: number; + + /** Total money spent */ + total_spent: number; + + /** Round winner (2 = T, 3 = CT) */ + winner?: number; + + /** Win reason */ + win_reason?: 'elimination' | 'bomb_defused' | 'bomb_exploded' | 'time' | 'target_saved'; + + /** Buy type classification */ + buy_type?: 'eco' | 'semi-eco' | 'force' | 'full'; +} + +/** + * Complete match rounds data + */ +export interface MatchRoundsData { + match_id: number; + rounds: RoundStats[]; +} + +/** + * Round details with player breakdown + */ +export interface RoundDetail { + round: number; + winner: number; + win_reason: string; + players: RoundStats[]; +} + +/** + * Complete match rounds response + */ +export interface MatchRoundsResponse { + match_id: number; + rounds: RoundDetail[]; +} diff --git a/src/lib/types/Weapon.ts b/src/lib/types/Weapon.ts new file mode 100644 index 0000000..9286f3c --- /dev/null +++ b/src/lib/types/Weapon.ts @@ -0,0 +1,147 @@ +/** + * Weapon statistics data model + * Tracks weapon usage, damage, and hit locations + */ +export interface Weapon { + /** Player ID of the victim who was hit/killed */ + victim: number; + + /** Damage dealt with this hit */ + dmg: number; + + /** Weapon equipment type ID */ + eq_type: number; + + /** Hit location group (1=head, 2=chest, 3=stomach, 4=left_arm, 5=right_arm, 6=left_leg, 7=right_leg) */ + hit_group: number; + + /** Reference to MatchPlayer ID */ + match_player_id?: number; +} + +/** + * Weapon performance statistics for a player + */ +export interface WeaponStats { + /** Weapon equipment type ID */ + eq_type: number; + + /** Weapon display name */ + weapon_name: string; + + /** Total kills with this weapon */ + kills: number; + + /** Total damage dealt */ + damage: number; + + /** Total hits landed */ + hits: number; + + /** Hit group distribution */ + hit_groups: { + head: number; + chest: number; + stomach: number; + left_arm: number; + right_arm: number; + left_leg: number; + right_leg: number; + }; + + /** Headshot percentage */ + headshot_pct?: number; + + /** Accuracy percentage (hits / shots) if available */ + accuracy?: number; +} + +/** + * Player weapon statistics + */ +export interface PlayerWeaponStats { + player_id: number; + weapon_stats: WeaponStats[]; +} + +/** + * Match weapons response + */ +export interface MatchWeaponsResponse { + match_id: number; + weapons: PlayerWeaponStats[]; +} + +/** + * Hit group enumeration + */ +export enum HitGroup { + Generic = 0, + Head = 1, + Chest = 2, + Stomach = 3, + LeftArm = 4, + RightArm = 5, + LeftLeg = 6, + RightLeg = 7 +} + +/** + * Weapon type enumeration + * Equipment type IDs from CS2 + */ +export enum WeaponType { + // Pistols + Glock = 1, + USP = 2, + P2000 = 3, + P250 = 4, + Deagle = 5, + FiveSeven = 6, + Tec9 = 7, + CZ75 = 8, + DualBerettas = 9, + + // SMGs + MP9 = 10, + MAC10 = 11, + MP7 = 12, + UMP45 = 13, + P90 = 14, + PPBizon = 15, + MP5SD = 16, + + // Rifles + AK47 = 17, + M4A4 = 18, + M4A1S = 19, + Galil = 20, + Famas = 21, + AUG = 22, + SG553 = 23, + + // Sniper Rifles + AWP = 24, + SSG08 = 25, + SCAR20 = 26, + G3SG1 = 27, + + // Heavy + Nova = 28, + XM1014 = 29, + Mag7 = 30, + SawedOff = 31, + M249 = 32, + Negev = 33, + + // Equipment + Zeus = 34, + Knife = 35, + HEGrenade = 36, + Flashbang = 37, + Smoke = 38, + Molotov = 39, + Decoy = 40, + Incendiary = 41, + C4 = 42 +} diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts new file mode 100644 index 0000000..29027d9 --- /dev/null +++ b/src/lib/types/api.ts @@ -0,0 +1,161 @@ +/** + * API response types and error handling + */ + +import type { Match, MatchListItem } from './Match'; +import type { Player, PlayerMeta } from './Player'; + +/** + * Standard API error response + */ +export interface APIError { + error: string; + message: string; + status_code: number; + timestamp?: string; +} + +/** + * Generic API response wrapper + */ +export interface APIResponse { + data: T; + success: boolean; + error?: APIError; +} + +/** + * Match parse response + */ +export interface MatchParseResponse { + match_id: number; + status: 'parsing' | 'queued' | 'completed' | 'error'; + message: string; + estimated_time?: number; // seconds +} + +/** + * Match parse status + */ +export interface MatchParseStatus { + match_id: number; + status: 'pending' | 'parsing' | 'completed' | 'error'; + progress?: number; // 0-100 + error_message?: string; +} + +/** + * Matches list response with pagination + */ +export interface MatchesListResponse { + matches: MatchListItem[]; + next_page_time?: number; // Unix timestamp + has_more: boolean; + total_count?: number; +} + +/** + * Match list query parameters + */ +export interface MatchesQueryParams { + limit?: number; // 1-100 + map?: string; + player_id?: number; + before_time?: number; // Unix timestamp for pagination +} + +/** + * Player track/untrack response + */ +export interface TrackPlayerResponse { + success: boolean; + message: string; +} + +/** + * Player profile response + */ +export type PlayerProfileResponse = Player; + +/** + * Player metadata response + */ +export type PlayerMetaResponse = PlayerMeta; + +/** + * Match details response + */ +export type MatchDetailsResponse = Match; + +/** + * Error types for better error handling + */ +export enum APIErrorType { + NetworkError = 'NETWORK_ERROR', + ServerError = 'SERVER_ERROR', + NotFound = 'NOT_FOUND', + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED', + Timeout = 'TIMEOUT', + ValidationError = 'VALIDATION_ERROR', + UnknownError = 'UNKNOWN_ERROR' +} + +/** + * Typed API error class + */ +export class APIException extends Error { + constructor( + public type: APIErrorType, + public message: string, + public statusCode?: number, + public details?: unknown + ) { + super(message); + this.name = 'APIException'; + } + + static fromResponse(statusCode: number, data?: unknown): APIException { + let type: APIErrorType; + let message: string; + + switch (statusCode) { + case 400: + type = APIErrorType.BadRequest; + message = 'Invalid request parameters'; + break; + case 401: + type = APIErrorType.Unauthorized; + message = 'Unauthorized access'; + break; + case 404: + type = APIErrorType.NotFound; + message = 'Resource not found'; + break; + case 500: + case 502: + case 503: + type = APIErrorType.ServerError; + message = 'Server error occurred'; + break; + default: + type = APIErrorType.UnknownError; + message = 'An unknown error occurred'; + } + + // Extract message from response data if available + if (data && typeof data === 'object' && 'message' in data) { + message = String(data.message); + } + + return new APIException(type, message, statusCode, data); + } + + static networkError(message = 'Network connection failed'): APIException { + return new APIException(APIErrorType.NetworkError, message); + } + + static timeout(message = 'Request timed out'): APIException { + return new APIException(APIErrorType.Timeout, message); + } +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..385acc8 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,42 @@ +/** + * Central export for all CS2.WTF type definitions + */ + +// Match types +export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match'; + +// Player types +export type { Player, PlayerMatch, PlayerMeta, PlayerProfile } from './Player'; + +// Round statistics types +export type { + RoundStats, + TeamRoundStats, + MatchRoundsData, + RoundDetail, + MatchRoundsResponse +} from './RoundStats'; + +// Weapon types +export type { Weapon, WeaponStats, PlayerWeaponStats, MatchWeaponsResponse } from './Weapon'; + +export { HitGroup, WeaponType } from './Weapon'; + +// Message/Chat types +export type { Message, MatchChatResponse, EnrichedMessage, ChatFilter, ChatStats } from './Message'; + +// API response types +export type { + APIError, + APIResponse, + MatchParseResponse, + MatchParseStatus, + MatchesListResponse, + MatchesQueryParams, + TrackPlayerResponse, + PlayerProfileResponse, + PlayerMetaResponse, + MatchDetailsResponse +} from './api'; + +export { APIErrorType, APIException } from './api'; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..921802b --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,44 @@ +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +/** + * MSW Browser Worker + * Used for mocking API requests in the browser (development mode) + */ + +/** + * Create MSW service worker + */ +export const worker = setupWorker(...handlers); + +/** + * Start MSW worker with console logging + */ +export const startMocking = async () => { + const isDev = import.meta.env?.DEV ?? false; + const isMockingEnabled = import.meta.env?.VITE_ENABLE_MSW_MOCKING === 'true'; + + if (isDev && isMockingEnabled) { + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: '/mockServiceWorker.js' + } + }); + + console.log('[MSW] API mocking enabled for development'); + } +}; + +/** + * Stop MSW worker + */ +export const stopMocking = () => { + worker.stop(); + console.log('[MSW] API mocking stopped'); +}; + +/** + * Default export + */ +export default worker; diff --git a/src/mocks/fixtures.ts b/src/mocks/fixtures.ts new file mode 100644 index 0000000..862a60a --- /dev/null +++ b/src/mocks/fixtures.ts @@ -0,0 +1,194 @@ +import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib/types'; + +/** + * Mock data fixtures for testing and development + */ + +/** Mock players */ +export const mockPlayers: Player[] = [ + { + id: 765611980123456, // Smaller mock Steam ID (safe integer) + name: 'TestPlayer1', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + vanity_url: 'testplayer1', + steam_updated: '2024-11-04T10:30:00Z', + profile_created: '2015-03-12T00:00:00Z', + wins: 1250, + losses: 980, + ties: 45, + vac_count: 0, + game_ban_count: 0 + }, + { + id: 765611980876543, // Smaller mock Steam ID (safe integer) + name: 'TestPlayer2', + avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg', + steam_updated: '2024-11-04T11:15:00Z', + wins: 850, + losses: 720, + ties: 30, + vac_count: 0, + game_ban_count: 0 + } +]; + +/** Mock player metadata */ +export const mockPlayerMeta: PlayerMeta = { + id: 765611980123456, + name: 'TestPlayer1', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + recent_matches: 25, + last_match_date: '2024-11-01T18:45:00Z', + avg_kills: 21.3, + avg_deaths: 17.8, + avg_kast: 75.2, + win_rate: 56.5 +}; + +/** Mock match players */ +export const mockMatchPlayers: MatchPlayer[] = [ + { + id: 765611980123456, + name: 'Player1', + avatar: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + team_id: 2, + kills: 24, + deaths: 18, + assists: 6, + headshot: 12, + mvp: 3, + score: 56, + kast: 78, + rank_old: 18500, + rank_new: 18650, + dmg_enemy: 2450, + dmg_team: 120, + flash_assists: 4, + flash_duration_enemy: 15.6, + flash_total_enemy: 8, + ud_he: 450, + ud_flames: 230, + ud_flash: 5, + ud_smoke: 3, + avg_ping: 25.5, + color: 'yellow' + }, + { + id: 765611980876543, + name: 'Player2', + avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg', + team_id: 2, + kills: 19, + deaths: 20, + assists: 8, + headshot: 9, + mvp: 2, + score: 48, + kast: 72, + rank_old: 17200, + rank_new: 17180, + dmg_enemy: 2180, + dmg_team: 85, + avg_ping: 32.1, + color: 'blue' + }, + { + id: 765611980111111, + name: 'Player3', + avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg', + team_id: 3, + kills: 22, + deaths: 19, + assists: 5, + headshot: 14, + mvp: 4, + score: 60, + kast: 80, + rank_old: 19800, + rank_new: 19920, + dmg_enemy: 2680, + dmg_team: 45, + avg_ping: 18.3, + color: 'green' + } +]; + +/** Mock matches */ +export const mockMatches: Match[] = [ + { + match_id: 358948771684207, // Smaller mock match ID (safe integer) + share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX', + map: 'de_inferno', + date: '2024-11-01T18:45:00Z', + score_team_a: 13, + score_team_b: 10, + duration: 2456, + match_result: 1, + max_rounds: 24, + demo_parsed: true, + vac_present: false, + gameban_present: false, + tick_rate: 64.0, + players: mockMatchPlayers + }, + { + match_id: 358948771684208, + share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY', + map: 'de_mirage', + date: '2024-11-02T20:15:00Z', + score_team_a: 16, + score_team_b: 14, + duration: 2845, + match_result: 1, + max_rounds: 24, + demo_parsed: true, + vac_present: false, + gameban_present: false, + tick_rate: 64.0 + }, + { + match_id: 358948771684209, + share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ', + map: 'de_dust2', + date: '2024-11-03T15:30:00Z', + score_team_a: 9, + score_team_b: 13, + duration: 1980, + match_result: 2, + max_rounds: 24, + demo_parsed: true, + vac_present: false, + gameban_present: false, + tick_rate: 64.0 + } +]; + +/** Mock match list items */ +export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({ + match_id: match.match_id, + map: match.map, + date: match.date, + score_team_a: match.score_team_a, + score_team_b: match.score_team_b, + duration: match.duration, + demo_parsed: match.demo_parsed, + player_count: 10 +})); + +/** Helper: Generate random Steam ID (safe integer) */ +export const generateSteamId = (): number => { + return 765611980000000 + Math.floor(Math.random() * 999999); +}; + +/** Helper: Get mock player by ID */ +export const getMockPlayer = (id: number): Player | undefined => { + return mockPlayers.find((p) => p.id === id); +}; + +/** Helper: Get mock match by ID */ +export const getMockMatch = (id: number): Match | undefined => { + return mockMatches.find((m) => m.match_id === id); +}; diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts new file mode 100644 index 0000000..fb6c42c --- /dev/null +++ b/src/mocks/handlers/index.ts @@ -0,0 +1,17 @@ +/** + * MSW Request Handlers + * Mocks all CS2.WTF API endpoints for testing and development + */ + +import { playersHandlers } from './players'; +import { matchesHandlers } from './matches'; + +/** + * Combined handlers for all API endpoints + */ +export const handlers = [...playersHandlers, ...matchesHandlers]; + +/** + * Default export + */ +export default handlers; diff --git a/src/mocks/handlers/matches.ts b/src/mocks/handlers/matches.ts new file mode 100644 index 0000000..06175a1 --- /dev/null +++ b/src/mocks/handlers/matches.ts @@ -0,0 +1,192 @@ +import { http, HttpResponse, delay } from 'msw'; +import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures'; +import type { + MatchParseResponse, + MatchesListResponse, + MatchRoundsResponse, + MatchWeaponsResponse, + MatchChatResponse +} from '$lib/types'; + +/** + * MSW handlers for Match API endpoints + */ + +const API_BASE_URL = 'http://localhost:8000'; + +export const matchesHandlers = [ + // GET /match/parse/:sharecode + http.get(`${API_BASE_URL}/match/parse/:sharecode`, async () => { + // Simulate parsing delay + await delay(500); + + const response: MatchParseResponse = { + match_id: 358948771684207, + status: 'parsing', + message: 'Demo download and parsing initiated', + estimated_time: 120 + }; + + return HttpResponse.json(response); + }), + + // GET /match/:id + http.get(`${API_BASE_URL}/match/:id`, ({ params }) => { + const { id } = params; + const matchId = Number(id); + + const match = getMockMatch(matchId) || mockMatches[0]; + + return HttpResponse.json(match); + }), + + // GET /match/:id/weapons + http.get(`${API_BASE_URL}/match/:id/weapons`, ({ params }) => { + const { id } = params; + const matchId = Number(id); + + const response: MatchWeaponsResponse = { + match_id: matchId, + weapons: [ + { + player_id: 765611980123456, + weapon_stats: [ + { + eq_type: 17, + weapon_name: 'AK-47', + kills: 12, + damage: 1450, + hits: 48, + hit_groups: { + head: 8, + chest: 25, + stomach: 8, + left_arm: 3, + right_arm: 2, + left_leg: 1, + right_leg: 1 + }, + headshot_pct: 16.7 + } + ] + } + ] + }; + + return HttpResponse.json(response); + }), + + // GET /match/:id/rounds + http.get(`${API_BASE_URL}/match/:id/rounds`, ({ params }) => { + const { id } = params; + const matchId = Number(id); + + const winReasons = ['elimination', 'bomb_defused', 'bomb_exploded']; + const response: MatchRoundsResponse = { + match_id: matchId, + rounds: Array.from({ length: 23 }, (_, i) => ({ + round: i + 1, + winner: i % 2 === 0 ? 2 : 3, + win_reason: winReasons[i % 3] || 'elimination', + players: [ + { + round: i + 1, + player_id: 765611980123456, + bank: 800 + i * 1000, + equipment: 650 + i * 500, + spent: 650 + i * 500, + kills_in_round: i % 3, + damage_in_round: 100 + i * 20 + } + ] + })) + }; + + return HttpResponse.json(response); + }), + + // GET /match/:id/chat + http.get(`${API_BASE_URL}/match/:id/chat`, ({ params }) => { + const { id } = params; + const matchId = Number(id); + + const response: MatchChatResponse = { + match_id: matchId, + messages: [ + { + player_id: 765611980123456, + player_name: 'Player1', + message: 'nice shot!', + tick: 15840, + round: 8, + all_chat: true, + timestamp: '2024-11-01T19:12:34Z' + }, + { + player_id: 765611980876543, + player_name: 'Player2', + message: 'thanks', + tick: 15920, + round: 8, + all_chat: true, + timestamp: '2024-11-01T19:12:38Z' + }, + { + player_id: 765611980111111, + player_name: 'Player3', + message: 'rush b no stop', + tick: 18400, + round: 9, + all_chat: false, + timestamp: '2024-11-01T19:14:12Z' + } + ] + }; + + return HttpResponse.json(response); + }), + + // GET /matches + http.get(`${API_BASE_URL}/matches`, ({ request }) => { + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit')) || 50; + const map = url.searchParams.get('map'); + const playerId = url.searchParams.get('player_id'); + + let matches = [...mockMatchListItems]; + + // Apply filters + if (map) { + matches = matches.filter((m) => m.map === map); + } + + if (playerId) { + // In a real scenario, filter by player participation + matches = matches.slice(0, Math.ceil(matches.length / 2)); + } + + const response: MatchesListResponse = { + matches: matches.slice(0, limit), + next_page_time: Date.now() / 1000 - 86400, + has_more: matches.length > limit, + total_count: matches.length + }; + + return HttpResponse.json(response); + }), + + // GET /matches/next/:time + http.get(`${API_BASE_URL}/matches/next/:time`, ({ request }) => { + const url = new URL(request.url); + const limit = Number(url.searchParams.get('limit')) || 50; + + // Return older matches for pagination + const response: MatchesListResponse = { + matches: mockMatchListItems.slice(limit, limit * 2), + next_page_time: Date.now() / 1000 - 172800, + has_more: mockMatchListItems.length > limit * 2 + }; + + return HttpResponse.json(response); + }) +]; diff --git a/src/mocks/handlers/players.ts b/src/mocks/handlers/players.ts new file mode 100644 index 0000000..2aa2db3 --- /dev/null +++ b/src/mocks/handlers/players.ts @@ -0,0 +1,99 @@ +import { http, HttpResponse } from 'msw'; +import { mockPlayers, mockPlayerMeta, getMockPlayer } from '../fixtures'; +import type { TrackPlayerResponse } from '$lib/types'; + +/** + * MSW handlers for Player API endpoints + */ + +const API_BASE_URL = 'http://localhost:8000'; + +export const playersHandlers = [ + // GET /player/:id + http.get(`${API_BASE_URL}/player/:id`, ({ params }) => { + const { id } = params; + const playerId = Number(id); + + const player = getMockPlayer(playerId); + if (!player) { + return HttpResponse.json(mockPlayers[0]); + } + + return HttpResponse.json(player); + }), + + // GET /player/:id/next/:time + http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => { + const { id } = params; + const playerId = Number(id); + + const player = getMockPlayer(playerId) ?? mockPlayers[0]; + + // Return player with paginated matches (simulate older matches) + return HttpResponse.json({ + ...player, + matches: player?.matches?.slice(5, 10) || [] + }); + }), + + // GET /player/:id/meta + http.get(`${API_BASE_URL}/player/:id/meta`, () => { + return HttpResponse.json(mockPlayerMeta); + }), + + // GET /player/:id/meta/:limit + http.get(`${API_BASE_URL}/player/:id/meta/:limit`, ({ params }) => { + const { limit } = params; + const limitNum = Number(limit); + + return HttpResponse.json({ + ...mockPlayerMeta, + recent_matches: limitNum + }); + }), + + // POST /player/:id/track + http.post(`${API_BASE_URL}/player/:id/track`, async () => { + const response: TrackPlayerResponse = { + success: true, + message: 'Player added to tracking queue' + }; + + return HttpResponse.json(response); + }), + + // DELETE /player/:id/track + http.delete(`${API_BASE_URL}/player/:id/track`, () => { + const response: TrackPlayerResponse = { + success: true, + message: 'Player removed from tracking' + }; + + return HttpResponse.json(response); + }), + + // GET /players/search (custom endpoint for search) + http.get(`${API_BASE_URL}/players/search`, ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get('q'); + const limit = Number(url.searchParams.get('limit')) || 10; + + // Filter players by name + const filtered = mockPlayers + .filter((p) => p.name.toLowerCase().includes(query?.toLowerCase() || '')) + .slice(0, limit) + .map((p) => ({ + id: p.id, + name: p.name, + avatar: p.avatar, + recent_matches: 25, + last_match_date: '2024-11-01T18:45:00Z', + avg_kills: 20.5, + avg_deaths: 18.2, + avg_kast: 74.0, + win_rate: 55.0 + })); + + return HttpResponse.json(filtered); + }) +]; diff --git a/src/mocks/server.ts b/src/mocks/server.ts new file mode 100644 index 0000000..bdbb420 --- /dev/null +++ b/src/mocks/server.ts @@ -0,0 +1,39 @@ +import { setupServer } from 'msw/node'; +import { beforeAll, afterEach, afterAll } from 'vitest'; +import { handlers } from './handlers'; + +/** + * MSW Server + * Used for mocking API requests in Node.js (tests) + */ + +/** + * Create MSW server for testing + */ +export const server = setupServer(...handlers); + +/** + * Setup server for tests + * Call this in test setup files (e.g., vitest.setup.ts) + */ +export const setupMockServer = () => { + // Start server before all tests + beforeAll(() => { + server.listen({ onUnhandledRequest: 'bypass' }); + }); + + // Reset handlers after each test + afterEach(() => { + server.resetHandlers(); + }); + + // Close server after all tests + afterAll(() => { + server.close(); + }); +}; + +/** + * Default export + */ +export default server; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..b16410d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,25 @@ +/// +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_API_TIMEOUT?: string; + readonly VITE_ENABLE_LIVE_MATCHES?: string; + readonly VITE_ENABLE_ANALYTICS?: string; + readonly VITE_DEBUG_MODE?: string; + readonly VITE_ENABLE_MSW_MOCKING?: string; + readonly VITE_PLAUSIBLE_DOMAIN?: string; + readonly VITE_PLAUSIBLE_API_HOST?: string; + readonly VITE_UMAMI_WEBSITE_ID?: string; + readonly VITE_UMAMI_SRC?: string; + readonly VITE_ENABLE_WEBGL_HEATMAPS?: string; + readonly VITE_APP_VERSION?: string; + readonly VITE_BUILD_TIMESTAMP?: string; + readonly DEV?: boolean; + readonly PROD?: boolean; + readonly MODE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}