fix: Fix match detail data loading and add API transformation layer
- 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>
This commit is contained in:
@@ -11,6 +11,9 @@ import {
|
|||||||
type LegacyMatchListItem,
|
type LegacyMatchListItem,
|
||||||
type LegacyMatchDetail
|
type LegacyMatchDetail
|
||||||
} from './transformers';
|
} from './transformers';
|
||||||
|
import { transformRoundsResponse } from './transformers/roundsTransformer';
|
||||||
|
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
|
||||||
|
import { transformChatResponse } from './transformers/chatTransformer';
|
||||||
import type {
|
import type {
|
||||||
Match,
|
Match,
|
||||||
MatchesListResponse,
|
MatchesListResponse,
|
||||||
@@ -55,10 +58,11 @@ export const matchesAPI = {
|
|||||||
/**
|
/**
|
||||||
* Get match weapons statistics
|
* Get match weapons statistics
|
||||||
* @param matchId - Match ID
|
* @param matchId - Match ID
|
||||||
|
* @param match - Optional match data for player name mapping
|
||||||
* @returns Weapon statistics for all players
|
* @returns Weapon statistics for all players
|
||||||
* @throws Error if data is invalid or demo not parsed yet
|
* @throws Error if data is invalid or demo not parsed yet
|
||||||
*/
|
*/
|
||||||
async getMatchWeapons(matchId: string | number): Promise<MatchWeaponsResponse> {
|
async getMatchWeapons(matchId: string | number, match?: Match): Promise<MatchWeaponsResponse> {
|
||||||
const url = `/match/${matchId}/weapons`;
|
const url = `/match/${matchId}/weapons`;
|
||||||
const data = await apiClient.get<unknown>(url);
|
const data = await apiClient.get<unknown>(url);
|
||||||
|
|
||||||
@@ -71,16 +75,18 @@ export const matchesAPI = {
|
|||||||
throw new Error('Demo not parsed yet or invalid response format');
|
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
|
* Get match round-by-round statistics
|
||||||
* @param matchId - Match ID
|
* @param matchId - Match ID
|
||||||
|
* @param match - Optional match data for player name mapping
|
||||||
* @returns Round statistics and economy data
|
* @returns Round statistics and economy data
|
||||||
* @throws Error if data is invalid or demo not parsed yet
|
* @throws Error if data is invalid or demo not parsed yet
|
||||||
*/
|
*/
|
||||||
async getMatchRounds(matchId: string | number): Promise<MatchRoundsResponse> {
|
async getMatchRounds(matchId: string | number, match?: Match): Promise<MatchRoundsResponse> {
|
||||||
const url = `/match/${matchId}/rounds`;
|
const url = `/match/${matchId}/rounds`;
|
||||||
const data = await apiClient.get<unknown>(url);
|
const data = await apiClient.get<unknown>(url);
|
||||||
|
|
||||||
@@ -93,16 +99,18 @@ export const matchesAPI = {
|
|||||||
throw new Error('Demo not parsed yet or invalid response format');
|
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
|
* Get match chat messages
|
||||||
* @param matchId - Match ID
|
* @param matchId - Match ID
|
||||||
|
* @param match - Optional match data for player name mapping
|
||||||
* @returns Chat messages from the match
|
* @returns Chat messages from the match
|
||||||
* @throws Error if data is invalid or demo not parsed yet
|
* @throws Error if data is invalid or demo not parsed yet
|
||||||
*/
|
*/
|
||||||
async getMatchChat(matchId: string | number): Promise<MatchChatResponse> {
|
async getMatchChat(matchId: string | number, match?: Match): Promise<MatchChatResponse> {
|
||||||
const url = `/match/${matchId}/chat`;
|
const url = `/match/${matchId}/chat`;
|
||||||
const data = await apiClient.get<unknown>(url);
|
const data = await apiClient.get<unknown>(url);
|
||||||
|
|
||||||
@@ -115,7 +123,8 @@ export const matchesAPI = {
|
|||||||
throw new Error('Demo not parsed yet or invalid response format');
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
src/lib/api/transformers/chatTransformer.ts
Normal file
46
src/lib/api/transformers/chatTransformer.ts
Normal file
@@ -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<string, string>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/lib/api/transformers/roundsTransformer.ts
Normal file
60
src/lib/api/transformers/roundsTransformer.ts
Normal file
@@ -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<string, number>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
99
src/lib/api/transformers/weaponsTransformer.ts
Normal file
99
src/lib/api/transformers/weaponsTransformer.ts
Normal file
@@ -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<number, { damage: number; hits: number; hitGroups: number[] }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Create player ID to name mapping
|
||||||
|
const playerMap = new Map<string, string>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@
|
|||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
let chart: Chart<'bar'> | null = null;
|
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'] = {
|
const defaultOptions: ChartConfiguration<'bar'>['options'] = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -99,7 +103,7 @@
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: data,
|
data: plainData,
|
||||||
options: { ...defaultOptions, ...options }
|
options: { ...defaultOptions, ...options }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -113,8 +117,8 @@
|
|||||||
|
|
||||||
// Watch for data changes and update chart
|
// Watch for data changes and update chart
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chart) {
|
if (chart && plainData) {
|
||||||
chart.data = data;
|
chart.data = plainData;
|
||||||
chart.options = { ...defaultOptions, ...options };
|
chart.options = { ...defaultOptions, ...options };
|
||||||
chart.update();
|
chart.update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
let chart: Chart<'line'> | null = null;
|
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'] = {
|
const defaultOptions: ChartConfiguration<'line'>['options'] = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -108,7 +112,7 @@
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: data,
|
data: plainData,
|
||||||
options: { ...defaultOptions, ...options }
|
options: { ...defaultOptions, ...options }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,8 +126,8 @@
|
|||||||
|
|
||||||
// Watch for data changes and update chart
|
// Watch for data changes and update chart
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chart) {
|
if (chart && plainData) {
|
||||||
chart.data = data;
|
chart.data = plainData;
|
||||||
chart.options = { ...defaultOptions, ...options };
|
chart.options = { ...defaultOptions, ...options };
|
||||||
chart.update();
|
chart.update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
let chart: Chart<'doughnut'> | null = null;
|
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'] = {
|
const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -74,7 +78,7 @@
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: data,
|
data: plainData,
|
||||||
options: { ...defaultOptions, ...options }
|
options: { ...defaultOptions, ...options }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,8 +92,8 @@
|
|||||||
|
|
||||||
// Watch for data changes and update chart
|
// Watch for data changes and update chart
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chart) {
|
if (chart && plainData) {
|
||||||
chart.data = data;
|
chart.data = plainData;
|
||||||
chart.options = { ...defaultOptions, ...options };
|
chart.options = { ...defaultOptions, ...options };
|
||||||
chart.update();
|
chart.update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ export const messageSchema = z.object({
|
|||||||
timestamp: z.string().datetime().optional()
|
timestamp: z.string().datetime().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
/** MatchChatResponse schema */
|
/** MatchChatResponse schema - matches actual API format */
|
||||||
export const matchChatResponseSchema = z.object({
|
// API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
|
||||||
match_id: z.number().positive(),
|
export const matchChatResponseSchema = z.record(
|
||||||
messages: z.array(messageSchema)
|
z.string(), // player Steam ID as string key
|
||||||
});
|
z.array(messageSchema)
|
||||||
|
);
|
||||||
|
|
||||||
/** EnrichedMessage schema (with player data) */
|
/** EnrichedMessage schema (with player data) */
|
||||||
export const enrichedMessageSchema = messageSchema.extend({
|
export const enrichedMessageSchema = messageSchema.extend({
|
||||||
|
|||||||
@@ -24,11 +24,19 @@ export const roundDetailSchema = z.object({
|
|||||||
players: z.array(roundStatsSchema)
|
players: z.array(roundStatsSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
/** MatchRoundsResponse schema */
|
/** MatchRoundsResponse schema - matches actual API format */
|
||||||
export const matchRoundsResponseSchema = z.object({
|
// API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
|
||||||
match_id: z.number().positive(),
|
export const matchRoundsResponseSchema = z.record(
|
||||||
rounds: z.array(roundDetailSchema)
|
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 */
|
/** TeamRoundStats schema */
|
||||||
export const teamRoundStatsSchema = z.object({
|
export const teamRoundStatsSchema = z.object({
|
||||||
|
|||||||
@@ -42,10 +42,25 @@ export const playerWeaponStatsSchema = z.object({
|
|||||||
weapon_stats: z.array(weaponStatsSchema)
|
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({
|
export const matchWeaponsResponseSchema = z.object({
|
||||||
match_id: z.number().positive(),
|
equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name
|
||||||
weapons: z.array(playerWeaponStatsSchema)
|
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 */
|
/** Parser functions */
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface Message {
|
|||||||
* Match chat response
|
* Match chat response
|
||||||
*/
|
*/
|
||||||
export interface MatchChatResponse {
|
export interface MatchChatResponse {
|
||||||
match_id: number;
|
match_id: string | number;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,6 @@ export interface RoundDetail {
|
|||||||
* Complete match rounds response
|
* Complete match rounds response
|
||||||
*/
|
*/
|
||||||
export interface MatchRoundsResponse {
|
export interface MatchRoundsResponse {
|
||||||
match_id: number;
|
match_id: string | number;
|
||||||
rounds: RoundDetail[];
|
rounds: RoundDetail[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface WeaponStats {
|
|||||||
*/
|
*/
|
||||||
export interface PlayerWeaponStats {
|
export interface PlayerWeaponStats {
|
||||||
player_id: number;
|
player_id: number;
|
||||||
|
player_name?: string;
|
||||||
weapon_stats: WeaponStats[];
|
weapon_stats: WeaponStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ export interface PlayerWeaponStats {
|
|||||||
* Match weapons response
|
* Match weapons response
|
||||||
*/
|
*/
|
||||||
export interface MatchWeaponsResponse {
|
export interface MatchWeaponsResponse {
|
||||||
match_id: number;
|
match_id: string | number;
|
||||||
weapons: PlayerWeaponStats[];
|
weapons: PlayerWeaponStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
src/lib/types/api/ChatAPIResponse.ts
Normal file
9
src/lib/types/api/ChatAPIResponse.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
9
src/lib/types/api/RoundsAPIResponse.ts
Normal file
9
src/lib/types/api/RoundsAPIResponse.ts
Normal file
@@ -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];
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/lib/types/api/WeaponsAPIResponse.ts
Normal file
12
src/lib/types/api/WeaponsAPIResponse.ts
Normal file
@@ -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<string, string>; // eq_type ID -> weapon name
|
||||||
|
stats: Array<{
|
||||||
|
[attackerId: string]: {
|
||||||
|
[victimId: string]: Array<[eqType: number, hitGroup: number, damage: number]>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
* Central export for all CS2.WTF type definitions
|
* 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
|
// Match types
|
||||||
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,26 @@ export const load: LayoutLoad = async ({ params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// First load match data (required)
|
||||||
const match = await api.matches.getMatch(matchId);
|
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 {
|
return {
|
||||||
match
|
match,
|
||||||
|
rounds,
|
||||||
|
weapons,
|
||||||
|
chat
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to load match ${matchId}:`, err);
|
console.error(`Failed to load match ${matchId}:`, err);
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { api } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
const matchId = params.id;
|
// Get all data from parent layout (already loaded upfront)
|
||||||
|
const { rounds } = await parent();
|
||||||
|
|
||||||
try {
|
return {
|
||||||
// Fetch rounds data for the timeline visualization
|
rounds
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,6 @@
|
|||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface MessagePlayer {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
team_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { match, chatData } = data;
|
const { match, chatData } = data;
|
||||||
|
|
||||||
@@ -19,23 +13,6 @@
|
|||||||
let showAllChat = $state(true);
|
let showAllChat = $state(true);
|
||||||
let selectedPlayer = $state<number | null>(null);
|
let selectedPlayer = $state<number | null>(null);
|
||||||
|
|
||||||
let messagePlayers = $state<MessagePlayer[]>([]);
|
|
||||||
let filteredMessages = $state<NonNullable<PageData['chatData']>['messages']>([]);
|
|
||||||
let messagesByRound = $state<Record<number, NonNullable<PageData['chatData']>['messages']>>({});
|
|
||||||
let rounds = $state<number[]>([]);
|
|
||||||
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)
|
// Check if text likely needs translation (contains non-ASCII or Cyrillic characters)
|
||||||
const mightNeedTranslation = (text: string): boolean => {
|
const mightNeedTranslation = (text: string): boolean => {
|
||||||
// Check for Cyrillic, Chinese, Japanese, Korean, Arabic, etc.
|
// Check for Cyrillic, Chinese, Japanese, Korean, Arabic, etc.
|
||||||
@@ -52,62 +29,68 @@
|
|||||||
window.open(translateUrl, '_blank', 'width=800,height=600,noopener,noreferrer');
|
window.open(translateUrl, '_blank', 'width=800,height=600,noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (chatData) {
|
// Get unique players who sent messages - use $derived for computed values
|
||||||
// Get unique players who sent messages
|
const messagePlayers = $derived(
|
||||||
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id)))
|
chatData
|
||||||
.filter((playerId): playerId is number => playerId !== undefined)
|
? Array.from(new Set(chatData.messages.map((m) => m.player_id)))
|
||||||
.map((playerId) => {
|
.filter((playerId): playerId is number => playerId !== undefined)
|
||||||
const player = match.players?.find((p) => p.id === String(playerId));
|
.map((playerId) => {
|
||||||
return {
|
const player = match.players?.find((p) => p.id === String(playerId));
|
||||||
id: playerId,
|
return {
|
||||||
name: player?.name || `Player ${playerId}`,
|
id: playerId,
|
||||||
team_id: player?.team_id || 0
|
name: player?.name || `Player ${playerId}`,
|
||||||
};
|
team_id: player?.team_id || 0
|
||||||
});
|
};
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
// Filter messages
|
// Filter messages using $derived
|
||||||
const computeFilteredMessages = () => {
|
const filteredMessages = $derived(
|
||||||
return chatData.messages.filter((msg) => {
|
chatData
|
||||||
// Chat type filter
|
? chatData.messages.filter((msg) => {
|
||||||
if (!showTeamChat && !msg.all_chat) return false;
|
// Chat type filter
|
||||||
if (!showAllChat && msg.all_chat) return false;
|
if (!showTeamChat && !msg.all_chat) return false;
|
||||||
|
if (!showAllChat && msg.all_chat) return false;
|
||||||
|
|
||||||
// Player filter
|
// Player filter
|
||||||
if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false;
|
if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false;
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) {
|
if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
})
|
||||||
};
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
// Update filtered messages reactively
|
// Group messages by round using $derived
|
||||||
$effect(() => {
|
const messagesByRound = $derived(
|
||||||
filteredMessages = computeFilteredMessages();
|
filteredMessages.reduce(
|
||||||
|
(acc, msg) => {
|
||||||
// Group messages by round
|
|
||||||
messagesByRound = {};
|
|
||||||
for (const msg of filteredMessages) {
|
|
||||||
const round = msg.round || 0;
|
const round = msg.round || 0;
|
||||||
if (!messagesByRound[round]) {
|
if (!acc[round]) {
|
||||||
messagesByRound[round] = [];
|
acc[round] = [];
|
||||||
}
|
}
|
||||||
messagesByRound[round].push(msg);
|
acc[round].push(msg);
|
||||||
}
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, typeof filteredMessages>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
rounds = Object.keys(messagesByRound)
|
const rounds = $derived(
|
||||||
.map(Number)
|
Object.keys(messagesByRound)
|
||||||
.sort((a, b) => a - b);
|
.map(Number)
|
||||||
});
|
.sort((a, b) => a - b)
|
||||||
|
);
|
||||||
|
|
||||||
// Stats
|
// Stats using $derived
|
||||||
totalMessages = chatData.messages.length;
|
const totalMessages = $derived(chatData?.messages.length || 0);
|
||||||
teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
|
const teamChatCount = $derived(chatData?.messages.filter((m) => !m.all_chat).length || 0);
|
||||||
allChatCount = chatData.messages.filter((m) => m.all_chat).length;
|
const allChatCount = $derived(chatData?.messages.filter((m) => m.all_chat).length || 0);
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -229,17 +212,20 @@
|
|||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="divide-y divide-base-300">
|
<div class="divide-y divide-base-300">
|
||||||
{#each messagesByRound[round] as message}
|
{#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}
|
||||||
<div class="p-4 transition-colors hover:bg-base-200/50">
|
<div class="p-4 transition-colors hover:bg-base-200/50">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Player Avatar/Icon -->
|
<!-- Player Avatar/Icon -->
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white"
|
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||||
class:bg-terrorist={playerInfo.team_id === 2}
|
class:bg-terrorist={teamId === 2}
|
||||||
class:bg-ct={playerInfo.team_id === 3}
|
class:bg-ct={teamId === 3}
|
||||||
class:bg-base-300={playerInfo.team_id === 0}
|
class:bg-base-300={teamId === 0}
|
||||||
>
|
>
|
||||||
{playerInfo.name.charAt(0).toUpperCase()}
|
{playerName.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message Content -->
|
<!-- Message Content -->
|
||||||
@@ -248,10 +234,10 @@
|
|||||||
<a
|
<a
|
||||||
href={`/player/${message.player_id || 0}`}
|
href={`/player/${message.player_id || 0}`}
|
||||||
class="font-semibold hover:underline"
|
class="font-semibold hover:underline"
|
||||||
class:text-terrorist={playerInfo.team_id === 2}
|
class:text-terrorist={teamId === 2}
|
||||||
class:text-ct={playerInfo.team_id === 3}
|
class:text-ct={teamId === 3}
|
||||||
>
|
>
|
||||||
{playerInfo.name}
|
{playerName}
|
||||||
</a>
|
</a>
|
||||||
{#if message.all_chat}
|
{#if message.all_chat}
|
||||||
<Badge variant="success" size="sm">All Chat</Badge>
|
<Badge variant="success" size="sm">All Chat</Badge>
|
||||||
|
|||||||
@@ -1,39 +1,14 @@
|
|||||||
import { api } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ parent, params }) => {
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
const { match } = await parent();
|
// Get all data from parent layout (already loaded upfront)
|
||||||
|
const { match, chat } = await parent();
|
||||||
|
|
||||||
// Only load chat data if match is parsed
|
return {
|
||||||
if (!match.demo_parsed) {
|
match,
|
||||||
return {
|
chatData: chat,
|
||||||
match,
|
meta: {
|
||||||
chatData: null,
|
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||||
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`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -350,7 +350,6 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Top Performers -->
|
<!-- Top Performers -->
|
||||||
// Top Performers
|
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
|
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
|
||||||
<!-- Most Kills -->
|
<!-- Most Kills -->
|
||||||
|
|||||||
@@ -1,39 +1,14 @@
|
|||||||
import { api } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ parent, params }) => {
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
const { match } = await parent();
|
// Get all data from parent layout (already loaded upfront)
|
||||||
|
const { match, weapons } = await parent();
|
||||||
|
|
||||||
// Only load weapons data if match is parsed
|
return {
|
||||||
if (!match.demo_parsed) {
|
match,
|
||||||
return {
|
weaponsData: weapons,
|
||||||
match,
|
meta: {
|
||||||
weaponsData: null,
|
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||||
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`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,15 +17,15 @@
|
|||||||
winner: number;
|
winner: number;
|
||||||
teamA_buyType: string;
|
teamA_buyType: string;
|
||||||
teamB_buyType: string;
|
teamB_buyType: string;
|
||||||
|
economyAdvantage: number; // Cumulative economy differential (teamA - teamB)
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
const { match, roundsData } = data;
|
const { match, roundsData } = data;
|
||||||
|
|
||||||
// Get unique team IDs dynamically
|
// Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3
|
||||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
const tTeamId = 2;
|
||||||
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
const ctTeamId = 3;
|
||||||
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
|
||||||
|
|
||||||
// Only process if rounds data exists
|
// Only process if rounds data exists
|
||||||
let teamEconomy = $state<TeamEconomy[]>([]);
|
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||||
@@ -40,36 +40,46 @@
|
|||||||
tension?: number;
|
tension?: number;
|
||||||
}>;
|
}>;
|
||||||
} | null>(null);
|
} | 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 totalRounds = $state(0);
|
||||||
let teamA_fullBuys = $state(0);
|
let teamA_fullBuys = $state(0);
|
||||||
let teamB_fullBuys = $state(0);
|
let teamB_fullBuys = $state(0);
|
||||||
let teamA_ecos = $state(0);
|
let teamA_ecos = $state(0);
|
||||||
let teamB_ecos = $state(0);
|
let teamB_ecos = $state(0);
|
||||||
|
let halfRoundIndex = $state<number>(0);
|
||||||
|
|
||||||
if (roundsData) {
|
if (roundsData) {
|
||||||
// Process rounds data to calculate team totals
|
// Process rounds data to calculate team totals
|
||||||
for (const roundData of roundsData.rounds) {
|
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));
|
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));
|
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 t_bank = tPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||||
const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
const ct_bank = ctPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||||
const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
const t_equipment = tPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||||
const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
const ct_equipment = ctPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||||
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
const t_spent = tPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
const ct_spent = ctPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
|
|
||||||
const avgTeamA_equipment =
|
const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
|
||||||
teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
|
const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0;
|
||||||
const avgTeamB_equipment =
|
|
||||||
teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
|
|
||||||
|
|
||||||
const classifyBuyType = (avgEquipment: number): string => {
|
const classifyBuyType = (avgEquipment: number): string => {
|
||||||
if (avgEquipment < 1500) return 'Eco';
|
if (avgEquipment < 1500) return 'Eco';
|
||||||
@@ -78,21 +88,38 @@
|
|||||||
return 'Full Buy';
|
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({
|
teamEconomy.push({
|
||||||
round: roundData.round,
|
round: roundData.round,
|
||||||
teamA_bank,
|
teamA_bank: t_bank,
|
||||||
teamB_bank,
|
teamB_bank: ct_bank,
|
||||||
teamA_equipment,
|
teamA_equipment: t_equipment,
|
||||||
teamB_equipment,
|
teamB_equipment: ct_equipment,
|
||||||
teamA_spent,
|
teamA_spent: t_spent,
|
||||||
teamB_spent,
|
teamB_spent: ct_spent,
|
||||||
winner: roundData.winner || 0,
|
winner: roundData.winner || 0,
|
||||||
teamA_buyType: classifyBuyType(avgTeamA_equipment),
|
teamA_buyType: classifyBuyType(avgT_equipment),
|
||||||
teamB_buyType: classifyBuyType(avgTeamB_equipment)
|
teamB_buyType: classifyBuyType(avgCT_equipment),
|
||||||
|
economyAdvantage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare chart data
|
// Prepare equipment value chart data
|
||||||
equipmentChartData = {
|
equipmentChartData = {
|
||||||
labels: teamEconomy.map((r) => `R${r.round}`),
|
labels: teamEconomy.map((r) => `R${r.round}`),
|
||||||
datasets: [
|
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
|
// Calculate summary stats
|
||||||
totalRounds = teamEconomy.length;
|
totalRounds = teamEconomy.length;
|
||||||
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
||||||
@@ -210,6 +266,57 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<!-- Economy Advantage Chart -->
|
||||||
|
<Card padding="lg">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-base-content">Economy</h2>
|
||||||
|
<p class="text-sm text-base-content/60">Net-worth differential (bank + spent)</p>
|
||||||
|
</div>
|
||||||
|
{#if economyAdvantageChartData}
|
||||||
|
<div class="relative">
|
||||||
|
<LineChart
|
||||||
|
data={economyAdvantageChartData}
|
||||||
|
height={400}
|
||||||
|
options={{
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: (context) => {
|
||||||
|
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}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute top-0 flex h-full items-center"
|
||||||
|
style="left: {(halfRoundIndex / (teamEconomy.length || 1)) * 100}%"
|
||||||
|
>
|
||||||
|
<div class="h-full w-px bg-base-content/20"></div>
|
||||||
|
<div
|
||||||
|
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded bg-base-300 px-2 py-1 text-xs font-medium text-base-content/70"
|
||||||
|
>
|
||||||
|
Half-Point
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
<!-- Summary Cards -->
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
|
|||||||
@@ -1,39 +1,14 @@
|
|||||||
import { api } from '$lib/api';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async ({ parent, params }) => {
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
const { match } = await parent();
|
// Get all data from parent layout (already loaded upfront)
|
||||||
|
const { match, rounds } = await parent();
|
||||||
|
|
||||||
// Only load rounds data if match is parsed
|
return {
|
||||||
if (!match.demo_parsed) {
|
match,
|
||||||
return {
|
roundsData: rounds,
|
||||||
match,
|
meta: {
|
||||||
roundsData: null,
|
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||||
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`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
import { matchesAPI } from '$lib/api/matches';
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ parent, params }) => {
|
export const load: PageLoad = async ({ parent }) => {
|
||||||
const { match } = await parent();
|
// Get all data from parent layout (already loaded upfront)
|
||||||
|
const { match, weapons } = await parent();
|
||||||
|
|
||||||
try {
|
return {
|
||||||
// Fetch weapons statistics for this match
|
match,
|
||||||
const weapons = await matchesAPI.getMatchWeapons(params.id);
|
weapons: weapons || { match_id: match.match_id, weapons: [] }
|
||||||
|
};
|
||||||
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: [] }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user