diff --git a/src/lib/api/matches.ts b/src/lib/api/matches.ts index 3f99c1f..5faef27 100644 --- a/src/lib/api/matches.ts +++ b/src/lib/api/matches.ts @@ -11,6 +11,9 @@ import { 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, @@ -55,10 +58,11 @@ export const matchesAPI = { /** * 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): Promise { + async getMatchWeapons(matchId: string | number, match?: Match): Promise { const url = `/match/${matchId}/weapons`; const data = await apiClient.get(url); @@ -71,16 +75,18 @@ export const matchesAPI = { throw new Error('Demo not parsed yet or invalid response format'); } - return result.data; + // 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): Promise { + async getMatchRounds(matchId: string | number, match?: Match): Promise { const url = `/match/${matchId}/rounds`; const data = await apiClient.get(url); @@ -93,16 +99,18 @@ export const matchesAPI = { throw new Error('Demo not parsed yet or invalid response format'); } - return result.data; + // 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): Promise { + async getMatchChat(matchId: string | number, match?: Match): Promise { const url = `/match/${matchId}/chat`; const data = await apiClient.get(url); @@ -115,7 +123,8 @@ export const matchesAPI = { throw new Error('Demo not parsed yet or invalid response format'); } - return result.data; + // Transform raw API response to structured format + return transformChatResponse(result.data, String(matchId), match); }, /** diff --git a/src/lib/api/transformers/chatTransformer.ts b/src/lib/api/transformers/chatTransformer.ts new file mode 100644 index 0000000..6639132 --- /dev/null +++ b/src/lib/api/transformers/chatTransformer.ts @@ -0,0 +1,46 @@ +import type { ChatAPIResponse } from '$lib/types/api/ChatAPIResponse'; +import type { MatchChatResponse, Message, Match } from '$lib/types'; + +/** + * Transform raw chat API response into structured format + * @param rawData - Raw API response + * @param matchId - Match ID + * @param match - Match data with player information + * @returns Structured chat data + */ +export function transformChatResponse( + rawData: ChatAPIResponse, + matchId: string, + match?: Match +): MatchChatResponse { + const messages: Message[] = []; + + // Create player ID to name mapping + const playerMap = new Map(); + if (match?.players) { + for (const player of match.players) { + playerMap.set(player.id, player.name); + } + } + + // Flatten all player messages into a single array + for (const [playerId, playerMessages] of Object.entries(rawData)) { + const playerName = playerMap.get(playerId) || `Player ${playerId}`; + + for (const message of playerMessages) { + messages.push({ + ...message, + player_id: Number(playerId), + player_name: playerName + }); + } + } + + // Sort by tick + messages.sort((a, b) => a.tick - b.tick); + + return { + match_id: matchId, + messages + }; +} diff --git a/src/lib/api/transformers/roundsTransformer.ts b/src/lib/api/transformers/roundsTransformer.ts new file mode 100644 index 0000000..fce813a --- /dev/null +++ b/src/lib/api/transformers/roundsTransformer.ts @@ -0,0 +1,60 @@ +import type { RoundsAPIResponse } from '$lib/types/api/RoundsAPIResponse'; +import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/types'; + +/** + * Transform raw rounds API response into structured format + * @param rawData - Raw API response + * @param matchId - Match ID + * @param match - Match data with player information + * @returns Structured rounds data + */ +export function transformRoundsResponse( + rawData: RoundsAPIResponse, + matchId: string, + match?: Match +): MatchRoundsResponse { + const rounds: RoundDetail[] = []; + + // Create player ID to team mapping + const playerTeamMap = new Map(); + if (match?.players) { + for (const player of match.players) { + playerTeamMap.set(player.id, player.team_id); + } + } + + // Convert object keys to sorted round numbers + const roundNumbers = Object.keys(rawData) + .map(Number) + .sort((a, b) => a - b); + + for (const roundNum of roundNumbers) { + const roundData = rawData[String(roundNum)]; + if (!roundData) continue; + + const players: RoundStats[] = []; + + // Convert player data + for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) { + players.push({ + round: roundNum + 1, // API uses 0-indexed, we use 1-indexed + bank, + equipment, + spent, + player_id: Number(playerId) + }); + } + + rounds.push({ + round: roundNum + 1, + winner: 0, // TODO: Determine winner from data if available + win_reason: '', // TODO: Determine win reason if available + players + }); + } + + return { + match_id: matchId, + rounds + }; +} diff --git a/src/lib/api/transformers/weaponsTransformer.ts b/src/lib/api/transformers/weaponsTransformer.ts new file mode 100644 index 0000000..2070d6a --- /dev/null +++ b/src/lib/api/transformers/weaponsTransformer.ts @@ -0,0 +1,99 @@ +import type { WeaponsAPIResponse } from '$lib/types/api/WeaponsAPIResponse'; +import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from '$lib/types'; + +/** + * Transform raw weapons API response into structured format + * @param rawData - Raw API response + * @param matchId - Match ID + * @param match - Match data with player information + * @returns Structured weapons data + */ +export function transformWeaponsResponse( + rawData: WeaponsAPIResponse, + matchId: string, + match?: Match +): MatchWeaponsResponse { + const playerWeaponsMap = new Map< + string, + Map + >(); + + // Create player ID to name mapping + const playerMap = new Map(); + if (match?.players) { + for (const player of match.players) { + playerMap.set(player.id, player.name); + } + } + + // Process all stats + for (const roundStats of rawData.stats) { + for (const [attackerId, victims] of Object.entries(roundStats)) { + if (!playerWeaponsMap.has(attackerId)) { + playerWeaponsMap.set(attackerId, new Map()); + } + const weaponsMap = playerWeaponsMap.get(attackerId)!; + + for (const [_, hits] of Object.entries(victims)) { + for (const [eqType, hitGroup, damage] of hits) { + if (!weaponsMap.has(eqType)) { + weaponsMap.set(eqType, { damage: 0, hits: 0, hitGroups: [] }); + } + const weaponStats = weaponsMap.get(eqType)!; + weaponStats.damage += damage; + weaponStats.hits++; + weaponStats.hitGroups.push(hitGroup); + } + } + } + } + + // Convert to output format + const weapons: PlayerWeaponStats[] = []; + for (const [playerId, weaponsMap] of playerWeaponsMap.entries()) { + const playerName = playerMap.get(playerId) || `Player ${playerId}`; + const weapon_stats: WeaponStats[] = []; + + for (const [eqType, stats] of weaponsMap.entries()) { + const hitGroupCounts = { + head: 0, + chest: 0, + stomach: 0, + left_arm: 0, + right_arm: 0, + left_leg: 0, + right_leg: 0 + }; + for (const hitGroup of stats.hitGroups) { + if (hitGroup === 1) hitGroupCounts.head++; + else if (hitGroup === 2) hitGroupCounts.chest++; + else if (hitGroup === 3) hitGroupCounts.stomach++; + else if (hitGroup === 4) hitGroupCounts.left_arm++; + else if (hitGroup === 5) hitGroupCounts.right_arm++; + else if (hitGroup === 6) hitGroupCounts.left_leg++; + else if (hitGroup === 7) hitGroupCounts.right_leg++; + } + + weapon_stats.push({ + eq_type: eqType, + weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`, + kills: 0, // TODO: Calculate kills if needed + damage: stats.damage, + hits: stats.hits, + hit_groups: hitGroupCounts, + headshot_pct: hitGroupCounts.head > 0 ? (hitGroupCounts.head / stats.hits) * 100 : 0 + }); + } + + weapons.push({ + player_id: Number(playerId), + player_name: playerName, + weapon_stats + }); + } + + return { + match_id: matchId, + weapons + }; +} diff --git a/src/lib/components/charts/BarChart.svelte b/src/lib/components/charts/BarChart.svelte index a774c10..48fc847 100644 --- a/src/lib/components/charts/BarChart.svelte +++ b/src/lib/components/charts/BarChart.svelte @@ -43,6 +43,10 @@ let canvas: HTMLCanvasElement; let chart: Chart<'bar'> | null = null; + // Convert Svelte 5 $state proxy to plain object for Chart.js compatibility + // Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle + const plainData = $derived(JSON.parse(JSON.stringify(data))); + const defaultOptions: ChartConfiguration<'bar'>['options'] = { responsive: true, maintainAspectRatio: false, @@ -99,7 +103,7 @@ if (ctx) { chart = new Chart(ctx, { type: 'bar', - data: data, + data: plainData, options: { ...defaultOptions, ...options } }); } @@ -113,8 +117,8 @@ // Watch for data changes and update chart $effect(() => { - if (chart) { - chart.data = data; + if (chart && plainData) { + chart.data = plainData; chart.options = { ...defaultOptions, ...options }; chart.update(); } diff --git a/src/lib/components/charts/LineChart.svelte b/src/lib/components/charts/LineChart.svelte index d88f09c..450dd2c 100644 --- a/src/lib/components/charts/LineChart.svelte +++ b/src/lib/components/charts/LineChart.svelte @@ -49,6 +49,10 @@ let canvas: HTMLCanvasElement; let chart: Chart<'line'> | null = null; + // Convert Svelte 5 $state proxy to plain object for Chart.js compatibility + // Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle + const plainData = $derived(JSON.parse(JSON.stringify(data))); + const defaultOptions: ChartConfiguration<'line'>['options'] = { responsive: true, maintainAspectRatio: false, @@ -108,7 +112,7 @@ if (ctx) { chart = new Chart(ctx, { type: 'line', - data: data, + data: plainData, options: { ...defaultOptions, ...options } }); } @@ -122,8 +126,8 @@ // Watch for data changes and update chart $effect(() => { - if (chart) { - chart.data = data; + if (chart && plainData) { + chart.data = plainData; chart.options = { ...defaultOptions, ...options }; chart.update(); } diff --git a/src/lib/components/charts/PieChart.svelte b/src/lib/components/charts/PieChart.svelte index 2f0ba6a..478b48f 100644 --- a/src/lib/components/charts/PieChart.svelte +++ b/src/lib/components/charts/PieChart.svelte @@ -41,6 +41,10 @@ let canvas: HTMLCanvasElement; let chart: Chart<'doughnut'> | null = null; + // Convert Svelte 5 $state proxy to plain object for Chart.js compatibility + // Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle + const plainData = $derived(JSON.parse(JSON.stringify(data))); + const defaultOptions: ChartConfiguration<'doughnut'>['options'] = { responsive: true, maintainAspectRatio: false, @@ -74,7 +78,7 @@ if (ctx) { chart = new Chart(ctx, { type: 'doughnut', - data: data, + data: plainData, options: { ...defaultOptions, ...options } }); } @@ -88,8 +92,8 @@ // Watch for data changes and update chart $effect(() => { - if (chart) { - chart.data = data; + if (chart && plainData) { + chart.data = plainData; chart.options = { ...defaultOptions, ...options }; chart.update(); } diff --git a/src/lib/schemas/message.schema.ts b/src/lib/schemas/message.schema.ts index da4845c..45ed85a 100644 --- a/src/lib/schemas/message.schema.ts +++ b/src/lib/schemas/message.schema.ts @@ -16,11 +16,12 @@ export const messageSchema = z.object({ timestamp: z.string().datetime().optional() }); -/** MatchChatResponse schema */ -export const matchChatResponseSchema = z.object({ - match_id: z.number().positive(), - messages: z.array(messageSchema) -}); +/** MatchChatResponse schema - matches actual API format */ +// API returns: { "player_id": [{ message, all_chat, tick }, ...], ... } +export const matchChatResponseSchema = z.record( + z.string(), // player Steam ID as string key + z.array(messageSchema) +); /** EnrichedMessage schema (with player data) */ export const enrichedMessageSchema = messageSchema.extend({ diff --git a/src/lib/schemas/roundStats.schema.ts b/src/lib/schemas/roundStats.schema.ts index df8df9e..e0bd557 100644 --- a/src/lib/schemas/roundStats.schema.ts +++ b/src/lib/schemas/roundStats.schema.ts @@ -24,11 +24,19 @@ export const roundDetailSchema = z.object({ players: z.array(roundStatsSchema) }); -/** MatchRoundsResponse schema */ -export const matchRoundsResponseSchema = z.object({ - match_id: z.number().positive(), - rounds: z.array(roundDetailSchema) -}); +/** MatchRoundsResponse schema - matches actual API format */ +// API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... } +export const matchRoundsResponseSchema = z.record( + z.string(), // round number as string key + z.record( + z.string(), // player Steam ID as string key + z.tuple([ + z.number().int().nonnegative(), // bank + z.number().int().nonnegative(), // equipment value + z.number().int().nonnegative() // spent + ]) + ) +); /** TeamRoundStats schema */ export const teamRoundStatsSchema = z.object({ diff --git a/src/lib/schemas/weapon.schema.ts b/src/lib/schemas/weapon.schema.ts index 690e8cd..c0565aa 100644 --- a/src/lib/schemas/weapon.schema.ts +++ b/src/lib/schemas/weapon.schema.ts @@ -42,10 +42,25 @@ export const playerWeaponStatsSchema = z.object({ weapon_stats: z.array(weaponStatsSchema) }); -/** MatchWeaponsResponse schema */ +/** MatchWeaponsResponse schema - matches actual API format */ +// API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] } export const matchWeaponsResponseSchema = z.object({ - match_id: z.number().positive(), - weapons: z.array(playerWeaponStatsSchema) + equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name + stats: z.array( + z.record( + z.string(), // attacker Steam ID + z.record( + z.string(), // victim Steam ID + z.array( + z.tuple([ + z.number().int().nonnegative(), // eq_type + z.number().int().min(0).max(7), // hit_group + z.number().int().nonnegative() // damage + ]) + ) + ) + ) + ) }); /** Parser functions */ diff --git a/src/lib/types/Message.ts b/src/lib/types/Message.ts index bef64fc..19935b3 100644 --- a/src/lib/types/Message.ts +++ b/src/lib/types/Message.ts @@ -32,7 +32,7 @@ export interface Message { * Match chat response */ export interface MatchChatResponse { - match_id: number; + match_id: string | number; messages: Message[]; } diff --git a/src/lib/types/RoundStats.ts b/src/lib/types/RoundStats.ts index d7d99b7..8474ee1 100644 --- a/src/lib/types/RoundStats.ts +++ b/src/lib/types/RoundStats.ts @@ -80,6 +80,6 @@ export interface RoundDetail { * Complete match rounds response */ export interface MatchRoundsResponse { - match_id: number; + match_id: string | number; rounds: RoundDetail[]; } diff --git a/src/lib/types/Weapon.ts b/src/lib/types/Weapon.ts index 9286f3c..2c518b5 100644 --- a/src/lib/types/Weapon.ts +++ b/src/lib/types/Weapon.ts @@ -61,6 +61,7 @@ export interface WeaponStats { */ export interface PlayerWeaponStats { player_id: number; + player_name?: string; weapon_stats: WeaponStats[]; } @@ -68,7 +69,7 @@ export interface PlayerWeaponStats { * Match weapons response */ export interface MatchWeaponsResponse { - match_id: number; + match_id: string | number; weapons: PlayerWeaponStats[]; } diff --git a/src/lib/types/api/ChatAPIResponse.ts b/src/lib/types/api/ChatAPIResponse.ts new file mode 100644 index 0000000..bd273bf --- /dev/null +++ b/src/lib/types/api/ChatAPIResponse.ts @@ -0,0 +1,9 @@ +import type { Message } from '../Message'; + +/** + * Raw API response format for match chat endpoint + * API returns: { "player_id": [{ message, all_chat, tick }, ...], ... } + */ +export interface ChatAPIResponse { + [playerId: string]: Message[]; +} diff --git a/src/lib/types/api/RoundsAPIResponse.ts b/src/lib/types/api/RoundsAPIResponse.ts new file mode 100644 index 0000000..bb6f7b5 --- /dev/null +++ b/src/lib/types/api/RoundsAPIResponse.ts @@ -0,0 +1,9 @@ +/** + * Raw API response format for match rounds endpoint + * API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... } + */ +export interface RoundsAPIResponse { + [roundNumber: string]: { + [playerId: string]: [bank: number, equipment: number, spent: number]; + }; +} diff --git a/src/lib/types/api/WeaponsAPIResponse.ts b/src/lib/types/api/WeaponsAPIResponse.ts new file mode 100644 index 0000000..718d493 --- /dev/null +++ b/src/lib/types/api/WeaponsAPIResponse.ts @@ -0,0 +1,12 @@ +/** + * Raw API response format for match weapons endpoint + * API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] } + */ +export interface WeaponsAPIResponse { + equipment_map: Record; // eq_type ID -> weapon name + stats: Array<{ + [attackerId: string]: { + [victimId: string]: Array<[eqType: number, hitGroup: number, damage: number]>; + }; + }>; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 385acc8..f0d22f3 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -2,6 +2,11 @@ * Central export for all CS2.WTF type definitions */ +// Raw API response types (internal format from backend) +export type { RoundsAPIResponse } from './api/RoundsAPIResponse'; +export type { WeaponsAPIResponse } from './api/WeaponsAPIResponse'; +export type { ChatAPIResponse } from './api/ChatAPIResponse'; + // Match types export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match'; diff --git a/src/routes/match/[id]/+layout.ts b/src/routes/match/[id]/+layout.ts index 4f89f48..8785264 100644 --- a/src/routes/match/[id]/+layout.ts +++ b/src/routes/match/[id]/+layout.ts @@ -10,10 +10,26 @@ export const load: LayoutLoad = async ({ params }) => { } try { + // First load match data (required) const match = await api.matches.getMatch(matchId); + // Then load optional data in parallel, passing match for player name mapping + const [roundsResult, weaponsResult, chatResult] = await Promise.allSettled([ + api.matches.getMatchRounds(matchId, match), + api.matches.getMatchWeapons(matchId, match), + api.matches.getMatchChat(matchId, match) + ]); + + // Optional data - return null if not available (demo not parsed yet) + const rounds = roundsResult.status === 'fulfilled' ? roundsResult.value : null; + const weapons = weaponsResult.status === 'fulfilled' ? weaponsResult.value : null; + const chat = chatResult.status === 'fulfilled' ? chatResult.value : null; + return { - match + match, + rounds, + weapons, + chat }; } catch (err) { console.error(`Failed to load match ${matchId}:`, err); diff --git a/src/routes/match/[id]/+page.ts b/src/routes/match/[id]/+page.ts index cb5f814..4944cb1 100644 --- a/src/routes/match/[id]/+page.ts +++ b/src/routes/match/[id]/+page.ts @@ -1,21 +1,10 @@ -import { api } from '$lib/api'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ params }) => { - const matchId = params.id; +export const load: PageLoad = async ({ parent }) => { + // Get all data from parent layout (already loaded upfront) + const { rounds } = await parent(); - try { - // Fetch rounds data for the timeline visualization - const rounds = await api.matches.getMatchRounds(matchId); - - return { - rounds - }; - } catch (err) { - console.error(`Failed to load rounds for match ${matchId}:`, err); - // Return empty rounds if the endpoint fails (demo might not be parsed yet) - return { - rounds: null - }; - } + return { + rounds + }; }; diff --git a/src/routes/match/[id]/chat/+page.svelte b/src/routes/match/[id]/chat/+page.svelte index e0f2869..32661d5 100644 --- a/src/routes/match/[id]/chat/+page.svelte +++ b/src/routes/match/[id]/chat/+page.svelte @@ -4,12 +4,6 @@ import Badge from '$lib/components/ui/Badge.svelte'; import type { PageData } from './$types'; - interface MessagePlayer { - id: number; - name: string; - team_id: number; - } - let { data }: { data: PageData } = $props(); const { match, chatData } = data; @@ -19,23 +13,6 @@ let showAllChat = $state(true); let selectedPlayer = $state(null); - let messagePlayers = $state([]); - let filteredMessages = $state['messages']>([]); - let messagesByRound = $state['messages']>>({}); - let rounds = $state([]); - let totalMessages = $state(0); - let teamChatCount = $state(0); - let allChatCount = $state(0); - - // Get player info for a message - const getPlayerInfo = (playerId: number) => { - const player = match.players?.find((p) => p.id === String(playerId)); - return { - name: player?.name || `Player ${playerId}`, - team_id: player?.team_id || 0 - }; - }; - // Check if text likely needs translation (contains non-ASCII or Cyrillic characters) const mightNeedTranslation = (text: string): boolean => { // Check for Cyrillic, Chinese, Japanese, Korean, Arabic, etc. @@ -52,62 +29,68 @@ window.open(translateUrl, '_blank', 'width=800,height=600,noopener,noreferrer'); }; - if (chatData) { - // Get unique players who sent messages - messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id))) - .filter((playerId): playerId is number => playerId !== undefined) - .map((playerId) => { - const player = match.players?.find((p) => p.id === String(playerId)); - return { - id: playerId, - name: player?.name || `Player ${playerId}`, - team_id: player?.team_id || 0 - }; - }); + // Get unique players who sent messages - use $derived for computed values + const messagePlayers = $derived( + chatData + ? Array.from(new Set(chatData.messages.map((m) => m.player_id))) + .filter((playerId): playerId is number => playerId !== undefined) + .map((playerId) => { + const player = match.players?.find((p) => p.id === String(playerId)); + return { + id: playerId, + name: player?.name || `Player ${playerId}`, + team_id: player?.team_id || 0 + }; + }) + : [] + ); - // Filter messages - const computeFilteredMessages = () => { - return chatData.messages.filter((msg) => { - // Chat type filter - if (!showTeamChat && !msg.all_chat) return false; - if (!showAllChat && msg.all_chat) return false; + // Filter messages using $derived + const filteredMessages = $derived( + chatData + ? chatData.messages.filter((msg) => { + // Chat type filter + if (!showTeamChat && !msg.all_chat) return false; + if (!showAllChat && msg.all_chat) return false; - // Player filter - if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false; + // Player filter + if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false; - // Search filter - if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } + // Search filter + if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } - return true; - }); - }; + return true; + }) + : [] + ); - // Update filtered messages reactively - $effect(() => { - filteredMessages = computeFilteredMessages(); - - // Group messages by round - messagesByRound = {}; - for (const msg of filteredMessages) { + // Group messages by round using $derived + const messagesByRound = $derived( + filteredMessages.reduce( + (acc, msg) => { const round = msg.round || 0; - if (!messagesByRound[round]) { - messagesByRound[round] = []; + if (!acc[round]) { + acc[round] = []; } - messagesByRound[round].push(msg); - } + acc[round].push(msg); + return acc; + }, + {} as Record + ) + ); - rounds = Object.keys(messagesByRound) - .map(Number) - .sort((a, b) => a - b); - }); + const rounds = $derived( + Object.keys(messagesByRound) + .map(Number) + .sort((a, b) => a - b) + ); - // Stats - totalMessages = chatData.messages.length; - teamChatCount = chatData.messages.filter((m) => !m.all_chat).length; - allChatCount = chatData.messages.filter((m) => m.all_chat).length; - } + // Stats using $derived + const totalMessages = $derived(chatData?.messages.length || 0); + const teamChatCount = $derived(chatData?.messages.filter((m) => !m.all_chat).length || 0); + const allChatCount = $derived(chatData?.messages.filter((m) => m.all_chat).length || 0); @@ -229,17 +212,20 @@
{#each messagesByRound[round] as message} - {@const playerInfo = getPlayerInfo(message.player_id || 0)} + {@const player = match.players?.find((p) => p.id === String(message.player_id))} + {@const playerName = + message.player_name || player?.name || `Player ${message.player_id}`} + {@const teamId = player?.team_id || 0}
- {playerInfo.name.charAt(0).toUpperCase()} + {playerName.charAt(0).toUpperCase()}
@@ -248,10 +234,10 @@ - {playerInfo.name} + {playerName} {#if message.all_chat} All Chat diff --git a/src/routes/match/[id]/chat/+page.ts b/src/routes/match/[id]/chat/+page.ts index c5e5edc..bf86b7b 100644 --- a/src/routes/match/[id]/chat/+page.ts +++ b/src/routes/match/[id]/chat/+page.ts @@ -1,39 +1,14 @@ -import { api } from '$lib/api'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent, params }) => { - const { match } = await parent(); +export const load: PageLoad = async ({ parent }) => { + // Get all data from parent layout (already loaded upfront) + const { match, chat } = await parent(); - // Only load chat data if match is parsed - if (!match.demo_parsed) { - return { - match, - chatData: null, - meta: { - title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF` - } - }; - } - - try { - const chatData = await api.matches.getMatchChat(params.id); - - return { - match, - chatData, - meta: { - title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF` - } - }; - } catch (err) { - console.error(`Failed to load chat data for match ${params.id}:`, err); - // Return null instead of throwing error - return { - match, - chatData: null, - meta: { - title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF` - } - }; - } + return { + match, + chatData: chat, + meta: { + title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF` + } + }; }; diff --git a/src/routes/match/[id]/details/+page.svelte b/src/routes/match/[id]/details/+page.svelte index 698c041..ea77900 100644 --- a/src/routes/match/[id]/details/+page.svelte +++ b/src/routes/match/[id]/details/+page.svelte @@ -350,7 +350,6 @@ - // Top Performers
{#if sortedPlayers.length > 0 && sortedPlayers[0]} diff --git a/src/routes/match/[id]/details/+page.ts b/src/routes/match/[id]/details/+page.ts index 76f4963..92b87c9 100644 --- a/src/routes/match/[id]/details/+page.ts +++ b/src/routes/match/[id]/details/+page.ts @@ -1,39 +1,14 @@ -import { api } from '$lib/api'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent, params }) => { - const { match } = await parent(); +export const load: PageLoad = async ({ parent }) => { + // Get all data from parent layout (already loaded upfront) + const { match, weapons } = await parent(); - // Only load weapons data if match is parsed - if (!match.demo_parsed) { - return { - match, - weaponsData: null, - meta: { - title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF` - } - }; - } - - try { - const weaponsData = await api.matches.getMatchWeapons(params.id); - - return { - match, - weaponsData, - meta: { - title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF` - } - }; - } catch (err) { - console.error(`Failed to load weapons data for match ${params.id}:`, err); - // Return null instead of throwing error - return { - match, - weaponsData: null, - meta: { - title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF` - } - }; - } + return { + match, + weaponsData: weapons, + meta: { + title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF` + } + }; }; diff --git a/src/routes/match/[id]/economy/+page.svelte b/src/routes/match/[id]/economy/+page.svelte index e805b88..d48d3b9 100644 --- a/src/routes/match/[id]/economy/+page.svelte +++ b/src/routes/match/[id]/economy/+page.svelte @@ -17,15 +17,15 @@ winner: number; teamA_buyType: string; teamB_buyType: string; + economyAdvantage: number; // Cumulative economy differential (teamA - teamB) } let { data }: { data: PageData } = $props(); const { match, roundsData } = data; - // Get unique team IDs dynamically - const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : []; - const firstTeamId = uniqueTeamIds[0] ?? 2; - const secondTeamId = uniqueTeamIds[1] ?? 3; + // Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3 + const tTeamId = 2; + const ctTeamId = 3; // Only process if rounds data exists let teamEconomy = $state([]); @@ -40,36 +40,46 @@ tension?: number; }>; } | null>(null); + let economyAdvantageChartData = $state<{ + labels: string[]; + datasets: Array<{ + label: string; + data: number[]; + borderColor?: string; + backgroundColor?: string; + fill?: boolean; + tension?: number; + }>; + } | null>(null); let totalRounds = $state(0); let teamA_fullBuys = $state(0); let teamB_fullBuys = $state(0); let teamA_ecos = $state(0); let teamB_ecos = $state(0); + let halfRoundIndex = $state(0); if (roundsData) { // Process rounds data to calculate team totals for (const roundData of roundsData.rounds) { - const teamAPlayers = roundData.players.filter((p) => { + const tPlayers = roundData.players.filter((p) => { const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id)); - return matchPlayer?.team_id === firstTeamId; + return matchPlayer?.team_id === tTeamId; }); - const teamBPlayers = roundData.players.filter((p) => { + const ctPlayers = roundData.players.filter((p) => { const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id)); - return matchPlayer?.team_id === secondTeamId; + return matchPlayer?.team_id === ctTeamId; }); - const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0); - const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0); - const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); - const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); - const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); - const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); + const t_bank = tPlayers.reduce((sum, p) => sum + (p.bank || 0), 0); + const ct_bank = ctPlayers.reduce((sum, p) => sum + (p.bank || 0), 0); + const t_equipment = tPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); + const ct_equipment = ctPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); + const t_spent = tPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); + const ct_spent = ctPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); - const avgTeamA_equipment = - teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0; - const avgTeamB_equipment = - teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0; + const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0; + const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0; const classifyBuyType = (avgEquipment: number): string => { if (avgEquipment < 1500) return 'Eco'; @@ -78,21 +88,38 @@ return 'Full Buy'; }; + // Calculate per-round economy advantage using bank + spent (like old portal) + // Teams swap sides at halftime, so we need to account for perspective flip + const t_totalEconomy = t_bank + t_spent; + const ct_totalEconomy = ct_bank + ct_spent; + + // Determine perspective based on round (teams swap at half) + const halfPoint = 12; // MR12 format: rounds 1-12 first half, 13-24 second half + let economyAdvantage; + if (roundData.round <= halfPoint) { + // First half: T - CT + economyAdvantage = t_totalEconomy - ct_totalEconomy; + } else { + // Second half: CT - T (teams swapped sides) + economyAdvantage = ct_totalEconomy - t_totalEconomy; + } + teamEconomy.push({ round: roundData.round, - teamA_bank, - teamB_bank, - teamA_equipment, - teamB_equipment, - teamA_spent, - teamB_spent, + teamA_bank: t_bank, + teamB_bank: ct_bank, + teamA_equipment: t_equipment, + teamB_equipment: ct_equipment, + teamA_spent: t_spent, + teamB_spent: ct_spent, winner: roundData.winner || 0, - teamA_buyType: classifyBuyType(avgTeamA_equipment), - teamB_buyType: classifyBuyType(avgTeamB_equipment) + teamA_buyType: classifyBuyType(avgT_equipment), + teamB_buyType: classifyBuyType(avgCT_equipment), + economyAdvantage }); } - // Prepare chart data + // Prepare equipment value chart data equipmentChartData = { labels: teamEconomy.map((r) => `R${r.round}`), datasets: [ @@ -115,6 +142,35 @@ ] }; + // Prepare economy advantage chart data + // Positive = above 0, Negative = below 0 + halfRoundIndex = Math.floor(teamEconomy.length / 2); + economyAdvantageChartData = { + labels: teamEconomy.map((r) => `${r.round}`), + datasets: [ + { + label: 'Advantage', + data: teamEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)), + borderColor: 'rgb(59, 130, 246)', + backgroundColor: 'rgba(59, 130, 246, 0.6)', + fill: 'origin', + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4 + }, + { + label: 'Disadvantage', + data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)), + borderColor: 'rgb(249, 115, 22)', + backgroundColor: 'rgba(249, 115, 22, 0.6)', + fill: 'origin', + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4 + } + ] + }; + // Calculate summary stats totalRounds = teamEconomy.length; teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length; @@ -210,6 +266,57 @@ {:else}
+ + +
+

Economy

+

Net-worth differential (bank + spent)

+
+ {#if economyAdvantageChartData} +
+ { + if (context.tick.value === 0) { + return 'rgba(156, 163, 175, 0.5)'; // Stronger line at 0 + } + return 'rgba(156, 163, 175, 0.1)'; + }, + lineWidth: (context) => { + return context.tick.value === 0 ? 2 : 1; + } + } + } + }, + interaction: { + mode: 'index', + intersect: false + } + }} + /> + {#if halfRoundIndex > 0} +
+
+
+ Half-Point +
+
+ {/if} +
+ {/if} +
+
diff --git a/src/routes/match/[id]/economy/+page.ts b/src/routes/match/[id]/economy/+page.ts index ec20f83..61126f6 100644 --- a/src/routes/match/[id]/economy/+page.ts +++ b/src/routes/match/[id]/economy/+page.ts @@ -1,39 +1,14 @@ -import { api } from '$lib/api'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent, params }) => { - const { match } = await parent(); +export const load: PageLoad = async ({ parent }) => { + // Get all data from parent layout (already loaded upfront) + const { match, rounds } = await parent(); - // Only load rounds data if match is parsed - if (!match.demo_parsed) { - return { - match, - roundsData: null, - meta: { - title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF` - } - }; - } - - try { - const roundsData = await api.matches.getMatchRounds(params.id); - - return { - match, - roundsData, - meta: { - title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF` - } - }; - } catch (err) { - console.error(`Failed to load rounds data for match ${params.id}:`, err); - // Return null instead of throwing error - return { - match, - roundsData: null, - meta: { - title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF` - } - }; - } + return { + match, + roundsData: rounds, + meta: { + title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF` + } + }; }; diff --git a/src/routes/match/[id]/weapons/+page.ts b/src/routes/match/[id]/weapons/+page.ts index cbf1d62..8130897 100644 --- a/src/routes/match/[id]/weapons/+page.ts +++ b/src/routes/match/[id]/weapons/+page.ts @@ -1,23 +1,11 @@ import type { PageLoad } from './$types'; -import { matchesAPI } from '$lib/api/matches'; -export const load: PageLoad = async ({ parent, params }) => { - const { match } = await parent(); +export const load: PageLoad = async ({ parent }) => { + // Get all data from parent layout (already loaded upfront) + const { match, weapons } = await parent(); - try { - // Fetch weapons statistics for this match - const weapons = await matchesAPI.getMatchWeapons(params.id); - - return { - match, - weapons - }; - } catch (error) { - console.error('Failed to load weapons data:', error); - // Return match without weapons data on error - return { - match, - weapons: { match_id: parseInt(params.id), weapons: [] } - }; - } + return { + match, + weapons: weapons || { match_id: match.match_id, weapons: [] } + }; };