forked from CSGOWTF/csgowtf
- Update Zod schemas to match raw API response formats - Create transformation layer (rounds, weapons, chat) to convert raw API to structured format - Add player name mapping in transformers for better UX - Fix Svelte 5 reactivity issues in chat page (replace $effect with $derived) - Fix Chart.js compatibility with Svelte 5 state proxies using JSON serialization - Add economy advantage chart with halftime perspective flip (WIP) - Remove stray comment from details page - Update layout to load match data first, then pass to API methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
238 lines
8.1 KiB
TypeScript
238 lines
8.1 KiB
TypeScript
import { apiClient } from './client';
|
|
import {
|
|
parseMatchRoundsSafe,
|
|
parseMatchWeaponsSafe,
|
|
parseMatchChatSafe,
|
|
parseMatchParseResponse
|
|
} from '$lib/schemas';
|
|
import {
|
|
transformMatchesListResponse,
|
|
transformMatchDetail,
|
|
type LegacyMatchListItem,
|
|
type LegacyMatchDetail
|
|
} from './transformers';
|
|
import { transformRoundsResponse } from './transformers/roundsTransformer';
|
|
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
|
|
import { transformChatResponse } from './transformers/chatTransformer';
|
|
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<MatchParseResponse> {
|
|
const url = `/match/parse/${shareCode}`;
|
|
const data = await apiClient.get<MatchParseResponse>(url);
|
|
|
|
// Validate with Zod schema
|
|
return parseMatchParseResponse(data);
|
|
},
|
|
|
|
/**
|
|
* Get match details with player statistics
|
|
* @param matchId - Match ID (uint64 as string)
|
|
* @returns Complete match data
|
|
*/
|
|
async getMatch(matchId: string): Promise<Match> {
|
|
const url = `/match/${matchId}`;
|
|
// API returns legacy format
|
|
const data = await apiClient.get<LegacyMatchDetail>(url);
|
|
|
|
// Transform legacy API response to new format
|
|
return transformMatchDetail(data);
|
|
},
|
|
|
|
/**
|
|
* Get match weapons statistics
|
|
* @param matchId - Match ID
|
|
* @param match - Optional match data for player name mapping
|
|
* @returns Weapon statistics for all players
|
|
* @throws Error if data is invalid or demo not parsed yet
|
|
*/
|
|
async getMatchWeapons(matchId: string | number, match?: Match): Promise<MatchWeaponsResponse> {
|
|
const url = `/match/${matchId}/weapons`;
|
|
const data = await apiClient.get<unknown>(url);
|
|
|
|
// Validate with Zod schema using safe parse
|
|
// This handles cases where the demo hasn't been parsed yet
|
|
const result = parseMatchWeaponsSafe(data);
|
|
|
|
if (!result.success) {
|
|
// If validation fails, it's likely the demo hasn't been parsed yet
|
|
throw new Error('Demo not parsed yet or invalid response format');
|
|
}
|
|
|
|
// Transform raw API response to structured format
|
|
return transformWeaponsResponse(result.data, String(matchId), match);
|
|
},
|
|
|
|
/**
|
|
* Get match round-by-round statistics
|
|
* @param matchId - Match ID
|
|
* @param match - Optional match data for player name mapping
|
|
* @returns Round statistics and economy data
|
|
* @throws Error if data is invalid or demo not parsed yet
|
|
*/
|
|
async getMatchRounds(matchId: string | number, match?: Match): Promise<MatchRoundsResponse> {
|
|
const url = `/match/${matchId}/rounds`;
|
|
const data = await apiClient.get<unknown>(url);
|
|
|
|
// Validate with Zod schema using safe parse
|
|
// This handles cases where the demo hasn't been parsed yet
|
|
const result = parseMatchRoundsSafe(data);
|
|
|
|
if (!result.success) {
|
|
// If validation fails, it's likely the demo hasn't been parsed yet
|
|
throw new Error('Demo not parsed yet or invalid response format');
|
|
}
|
|
|
|
// Transform raw API response to structured format
|
|
return transformRoundsResponse(result.data, String(matchId), match);
|
|
},
|
|
|
|
/**
|
|
* Get match chat messages
|
|
* @param matchId - Match ID
|
|
* @param match - Optional match data for player name mapping
|
|
* @returns Chat messages from the match
|
|
* @throws Error if data is invalid or demo not parsed yet
|
|
*/
|
|
async getMatchChat(matchId: string | number, match?: Match): Promise<MatchChatResponse> {
|
|
const url = `/match/${matchId}/chat`;
|
|
const data = await apiClient.get<unknown>(url);
|
|
|
|
// Validate with Zod schema using safe parse
|
|
// This handles cases where the demo hasn't been parsed yet
|
|
const result = parseMatchChatSafe(data);
|
|
|
|
if (!result.success) {
|
|
// If validation fails, it's likely the demo hasn't been parsed yet
|
|
throw new Error('Demo not parsed yet or invalid response format');
|
|
}
|
|
|
|
// Transform raw API response to structured format
|
|
return transformChatResponse(result.data, String(matchId), match);
|
|
},
|
|
|
|
/**
|
|
* Get paginated list of matches
|
|
*
|
|
* IMPORTANT: The API returns a plain array, not an object with properties.
|
|
* We must manually implement pagination by:
|
|
* 1. Requesting limit + 1 matches
|
|
* 2. Checking if we got more than limit (means there are more pages)
|
|
* 3. Extracting timestamp from last match for next page
|
|
*
|
|
* Pagination flow:
|
|
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
|
|
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
|
|
* - Continue until response.length < limit (reached the end)
|
|
*
|
|
* @param params - Query parameters (filters, pagination)
|
|
* @param params.limit - Number of matches to return (default: 50)
|
|
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
|
|
* @param params.map - Filter by map name (e.g., "de_inferno")
|
|
* @param params.player_id - Filter by player Steam ID
|
|
* @returns List of matches with pagination metadata
|
|
*/
|
|
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
|
|
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
|
|
const limit = params?.limit || 50;
|
|
|
|
// CRITICAL: API returns a plain array, not a wrapped object
|
|
// NOTE: Backend has a hard limit of 20 matches per request
|
|
// We assume hasMore = true if we get exactly the limit we requested
|
|
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
|
|
params: {
|
|
limit: limit,
|
|
map: params?.map,
|
|
player_id: params?.player_id
|
|
}
|
|
});
|
|
|
|
// Handle null or empty response
|
|
if (!data || !Array.isArray(data)) {
|
|
console.warn('[API] getMatches received null or invalid data');
|
|
return transformMatchesListResponse([], false, undefined);
|
|
}
|
|
|
|
// If we got exactly the limit, assume there might be more
|
|
// If we got less, we've reached the end
|
|
const hasMore = data.length === limit;
|
|
|
|
// Get the timestamp from the LAST match BEFORE transformation
|
|
// The legacy API format has `date` as a Unix timestamp (number)
|
|
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
|
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
|
|
|
// Transform legacy API response to new format
|
|
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
|
},
|
|
|
|
/**
|
|
* Search matches (cancelable for live search)
|
|
* @param params - Search parameters
|
|
* @returns List of matching matches
|
|
*/
|
|
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
|
|
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
|
|
const limit = params?.limit || 20;
|
|
|
|
// API returns a plain array, not a wrapped object
|
|
// Backend has a hard limit of 20 matches per request
|
|
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
|
|
params: {
|
|
limit: limit,
|
|
map: params?.map,
|
|
player_id: params?.player_id
|
|
}
|
|
});
|
|
|
|
// If we got exactly the limit, assume there might be more
|
|
const hasMore = data.length === limit;
|
|
|
|
// Get the timestamp from the LAST match BEFORE transformation
|
|
// The legacy API format has `date` as a Unix timestamp (number)
|
|
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
|
|
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
|
|
|
|
// Transform legacy API response to new format
|
|
return transformMatchesListResponse(data, hasMore, nextPageTime);
|
|
},
|
|
|
|
/**
|
|
* 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<Match> {
|
|
// 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;
|