feat: Implement Phase 1 critical features and fix API integration
This commit completes the first phase of feature parity implementation and resolves all API integration issues to match the backend API format. ## API Integration Fixes - Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated) - Update TypeScript types to make fields optional where backend doesn't guarantee them - Update Zod schemas to validate optional fields correctly - Fix mock data to match real API response format (plain arrays, not wrapped objects) - Update UI components to handle undefined values with proper fallbacks - Add comprehensive API documentation for Match and Player endpoints ## Phase 1 Features Implemented (3/6) ### 1. Player Tracking System ✅ - Created TrackPlayerModal.svelte with auth code input - Integrated track/untrack player API endpoints - Added UI for providing optional share code - Displays tracked status on player profiles - Full validation and error handling ### 2. Share Code Parsing ✅ - Created ShareCodeInput.svelte component - Added to matches page for easy match submission - Real-time validation of share code format - Parse status feedback with loading states - Auto-redirect to match page on success ### 3. VAC/Game Ban Status ✅ - Added VAC and game ban count/date fields to Player type - Display status badges on player profile pages - Show ban count and date when available - Visual indicators using DaisyUI badge components ## Component Improvements - Modal.svelte: Added Svelte 5 Snippet types, actions slot support - ThemeToggle.svelte: Removed deprecated svelte:component usage - Tooltip.svelte: Fixed type safety with Snippet type - All new components follow Svelte 5 runes pattern ($state, $derived, $bindable) ## Type Safety & Linting - Fixed all ESLint errors (any types → proper types) - Fixed form label accessibility issues - Replaced error: any with error: unknown + proper type guards - Added Snippet type imports where needed - Updated all catch blocks to use instanceof Error checks ## Static Assets - Migrated all files from public/ to static/ directory per SvelteKit best practices - Moved 200+ map icons, screenshots, and other assets - Updated all import paths to use /images/ (served from static/) ## Documentation - Created IMPLEMENTATION_STATUS.md tracking all 15 missing features - Updated API.md with optional field annotations - Created MATCHES_API.md with comprehensive endpoint documentation - Added inline comments marking optional vs required fields ## Testing - Updated mock fixtures to remove default values - Fixed mock handlers to return plain arrays like real API - Ensured all components handle undefined gracefully ## Remaining Phase 1 Tasks - [ ] Add VAC status column to match scoreboard - [ ] Create weapons statistics tab for matches - [ ] Implement recently visited players on home page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,38 +4,32 @@ import { APIException } from '$lib/types';
|
||||
|
||||
/**
|
||||
* API Client Configuration
|
||||
*
|
||||
* Uses SvelteKit server routes (/api/[...path]/+server.ts) to proxy requests to the backend.
|
||||
* This approach:
|
||||
* - Works in all environments (dev, preview, production)
|
||||
* - No CORS issues
|
||||
* - Single code path for consistency
|
||||
* - Can add caching, rate limiting, auth in the future
|
||||
*
|
||||
* Backend selection is controlled by VITE_API_BASE_URL environment variable:
|
||||
* - Local development: VITE_API_BASE_URL=http://localhost:8000
|
||||
* - Production: VITE_API_BASE_URL=https://api.csgow.tf
|
||||
*
|
||||
* Note: During SSR, we call the backend directly since relative URLs don't work server-side.
|
||||
*/
|
||||
const getAPIBaseURL = (): string => {
|
||||
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
// Check if we're running on the server (SSR) or in production
|
||||
// On the server, we must use the actual API URL, not the proxy
|
||||
if (import.meta.env.SSR || import.meta.env.PROD) {
|
||||
return apiUrl;
|
||||
function getAPIBaseURL(): string {
|
||||
// During SSR, call backend API directly (relative URLs don't work server-side)
|
||||
if (import.meta.env.SSR) {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
}
|
||||
|
||||
// In development mode on the client, use the Vite proxy to avoid CORS issues
|
||||
// The proxy will forward /api requests to VITE_API_BASE_URL
|
||||
// In browser, use SvelteKit route
|
||||
return '/api';
|
||||
};
|
||||
}
|
||||
|
||||
const API_BASE_URL = getAPIBaseURL();
|
||||
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
|
||||
|
||||
// Log the API configuration
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.SSR) {
|
||||
console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL);
|
||||
} else {
|
||||
console.log('[API Client] Browser mode - using Vite proxy');
|
||||
console.log('[API Client] Frontend requests: /api/*');
|
||||
console.log(
|
||||
'[API Client] Proxy target:',
|
||||
import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API Client
|
||||
* Provides centralized HTTP communication with error handling
|
||||
|
||||
@@ -93,23 +93,54 @@ export const matchesAPI = {
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @returns List of matches with 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;
|
||||
|
||||
// API returns a plain array, not a wrapped object
|
||||
// CRITICAL: API returns a plain array, not a wrapped object
|
||||
// We request limit + 1 to detect if there are more pages
|
||||
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
|
||||
params: {
|
||||
limit: params?.limit,
|
||||
limit: limit + 1, // Request one extra to check if there are more
|
||||
map: params?.map,
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// Check if there are more matches (if we got the extra one)
|
||||
const hasMore = data.length > limit;
|
||||
|
||||
// Remove the extra match if we have more
|
||||
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
|
||||
|
||||
// If there are more matches, use the timestamp of the last match for pagination
|
||||
// This timestamp is used in the next request: /matches/next/{timestamp}
|
||||
const lastMatch =
|
||||
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
|
||||
const nextPageTime =
|
||||
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(data);
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -118,19 +149,32 @@ export const matchesAPI = {
|
||||
* @returns List of matching matches
|
||||
*/
|
||||
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
|
||||
const url = '/matches';
|
||||
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
|
||||
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
|
||||
params: {
|
||||
limit: params?.limit || 20,
|
||||
limit: limit + 1, // Request one extra to check if there are more
|
||||
map: params?.map,
|
||||
player_id: params?.player_id,
|
||||
before_time: params?.before_time
|
||||
player_id: params?.player_id
|
||||
}
|
||||
});
|
||||
|
||||
// Check if there are more matches (if we got the extra one)
|
||||
const hasMore = data.length > limit;
|
||||
|
||||
// Remove the extra match if we have more
|
||||
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
|
||||
|
||||
// If there are more matches, use the timestamp of the last match for pagination
|
||||
const lastMatch =
|
||||
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
|
||||
const nextPageTime =
|
||||
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
|
||||
|
||||
// Transform legacy API response to new format
|
||||
return transformMatchesListResponse(data);
|
||||
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ export const playersAPI = {
|
||||
const transformedData = transformPlayerProfile(legacyData);
|
||||
|
||||
// Validate the player data
|
||||
// parsePlayer throws on validation failure, so player is always defined if we reach this point
|
||||
const player = parsePlayer(transformedData);
|
||||
|
||||
// Calculate aggregated stats from matches
|
||||
@@ -60,18 +61,19 @@ export const playersAPI = {
|
||||
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
|
||||
|
||||
// Find the most recent match date
|
||||
const lastMatchDate = matches.length > 0 ? matches[0].date : new Date().toISOString();
|
||||
const lastMatchDate =
|
||||
matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString();
|
||||
|
||||
// Transform to PlayerMeta format
|
||||
const playerMeta: PlayerMeta = {
|
||||
id: parseInt(player.id),
|
||||
id: parseInt(player.id, 10),
|
||||
name: player.name,
|
||||
avatar: player.avatar, // Already transformed by transformPlayerProfile
|
||||
recent_matches: recentMatches.length,
|
||||
last_match_date: lastMatchDate,
|
||||
avg_kills: avgKills,
|
||||
avg_deaths: avgDeaths,
|
||||
avg_kast: totalKast / recentMatches.length || 0, // Placeholder KAST calculation
|
||||
avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
|
||||
win_rate: winRate
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
/**
|
||||
* 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 format (from api.csgow.tf)
|
||||
* 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;
|
||||
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;
|
||||
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
|
||||
* 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;
|
||||
@@ -33,14 +51,21 @@ export interface LegacyMatchDetail {
|
||||
duration: number;
|
||||
match_result: number;
|
||||
max_rounds: number;
|
||||
parsed: boolean;
|
||||
vac: boolean;
|
||||
game_ban: boolean;
|
||||
stats?: LegacyPlayerStats[];
|
||||
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
|
||||
* 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;
|
||||
@@ -82,6 +107,16 @@ export interface LegacyPlayerStats {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -91,21 +126,36 @@ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListIt
|
||||
score_team_a: legacy.score[0],
|
||||
score_team_b: legacy.score[1],
|
||||
duration: legacy.duration,
|
||||
demo_parsed: legacy.parsed,
|
||||
player_count: 10 // Default to 10 players (5v5)
|
||||
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[]
|
||||
legacyMatches: LegacyMatchListItem[],
|
||||
hasMore: boolean = false,
|
||||
nextPageTime?: number
|
||||
): MatchesListResponse {
|
||||
return {
|
||||
matches: legacyMatches.map(transformMatchListItem),
|
||||
has_more: false, // Legacy API doesn't provide pagination info
|
||||
next_page_time: undefined
|
||||
has_more: hasMore,
|
||||
next_page_time: nextPageTime
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +163,13 @@ export function transformMatchesListResponse(
|
||||
* 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,
|
||||
@@ -124,7 +181,9 @@ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
|
||||
headshot: legacy.headshot,
|
||||
mvp: legacy.mvp,
|
||||
score: legacy.score,
|
||||
kast: 0, // Not provided by legacy API
|
||||
// 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,
|
||||
@@ -157,7 +216,6 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -216,46 +274,59 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
|
||||
id: legacy.steamid64,
|
||||
name: legacy.name,
|
||||
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
||||
steam_updated: new Date().toISOString(), // Not provided by API
|
||||
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) => ({
|
||||
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,
|
||||
tick_rate: 64, // Not provided by API
|
||||
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,
|
||||
kast: 0,
|
||||
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
|
||||
}
|
||||
}))
|
||||
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
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user