From 235ef65556136464bb259bb1f6f376fb41f0d412 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 7 Dec 2025 19:58:06 +0100 Subject: [PATCH] feat: Merge economy and rounds pages with unified economy utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create economyUtils.ts with team-aware buy type classification (CT has higher thresholds due to M4 cost) - Add Economy Overview toggle to rounds page with charts - Resolve player names/avatars in round economy display - Remove standalone Economy tab (merged into Rounds) - Document missing backend API data (round winner, win reason) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- MISSING_BACKEND_API.md | 107 +++ src/lib/api/transformers/roundsTransformer.ts | 26 +- src/lib/types/RoundStats.ts | 9 + src/lib/utils/economyUtils.ts | 214 +++++ src/routes/match/[id]/+layout.svelte | 73 +- src/routes/match/[id]/economy/+page.svelte | 513 ------------ src/routes/match/[id]/economy/+page.ts | 14 - src/routes/match/[id]/rounds/+page.svelte | 751 ++++++++++++++++++ 8 files changed, 1151 insertions(+), 556 deletions(-) create mode 100644 MISSING_BACKEND_API.md create mode 100644 src/lib/utils/economyUtils.ts delete mode 100644 src/routes/match/[id]/economy/+page.svelte delete mode 100644 src/routes/match/[id]/economy/+page.ts create mode 100644 src/routes/match/[id]/rounds/+page.svelte diff --git a/MISSING_BACKEND_API.md b/MISSING_BACKEND_API.md new file mode 100644 index 0000000..f020614 --- /dev/null +++ b/MISSING_BACKEND_API.md @@ -0,0 +1,107 @@ +# Missing Backend API Data + +This document outlines data that the frontend is ready to display but is not currently provided by the backend API. These features would enhance the match analysis experience. + +## Round Winner & Win Reason + +**Endpoint**: `GET /match/{id}/rounds` + +**Currently Returns**: + +```json +{ + "0": { "player_id": [bank, equipment, spent] }, + "1": { "player_id": [bank, equipment, spent] } +} +``` + +**Missing Fields Needed**: + +| Field | Type | Description | +| ------------ | -------- | -------------------------------------- | +| `winner` | `number` | Team ID that won the round (1=T, 2=CT) | +| `win_reason` | `string` | How the round ended | + +**Win Reason Values**: + +- `elimination` - All enemies killed +- `bomb_defused` - CT defused the bomb +- `bomb_exploded` - Bomb detonated successfully +- `time` - Round timer expired (CT win) +- `target_saved` - Hostage rescued (rare mode) + +**Expected Response Format**: + +```json +{ + "0": { + "winner": 1, + "win_reason": "elimination", + "players": { + "player_id": [bank, equipment, spent] + } + } +} +``` + +**Implementation**: Demo parser needs to capture `RoundEnd` game events and extract: + +- `winner` field from the event +- `reason` field from the event + +--- + +## Per-Round Player Stats + +**Missing Fields per Player per Round**: + +| Field | Type | Description | +| ------------------ | -------- | -------------------------------------------- | +| `kills_in_round` | `number` | Kills this player got in this specific round | +| `damage_in_round` | `number` | Total damage dealt this round | +| `assists_in_round` | `number` | Assists this round | + +**Implementation**: During demo parse, aggregate `player_death` and `player_hurt` events per round, grouped by attacking player. + +--- + +## Loss Bonus Tracking + +**Missing Fields per Team per Round**: + +| Field | Type | Description | +| ------------- | -------- | ------------------------------ | +| `loss_streak` | `number` | Consecutive round losses (0-4) | +| `loss_bonus` | `number` | Current loss bonus amount | + +**CS2 Loss Bonus Scale**: +| Consecutive Losses | Bonus | +|-------------------|-------| +| 0 | $1,400 | +| 1 | $1,900 | +| 2 | $2,400 | +| 3 | $2,900 | +| 4+ | $3,400 | + +**Implementation**: Track `RoundEnd` events and maintain loss counter per team, resetting on win. + +--- + +## Frontend Readiness + +The frontend already has: + +- Type definitions for all missing fields (`src/lib/types/RoundStats.ts`) +- Zod validation schemas (`src/lib/schemas/roundStats.schema.ts`) +- UI components ready to display win reasons with icons +- Mock handlers showing expected data structure (`src/mocks/handlers/matches.ts`) + +Once the backend provides this data, the frontend will automatically display it. + +--- + +## Priority + +1. **High**: Round winner & win reason - enables win/loss correlation analysis +2. **Medium**: Per-round player stats - enables round-by-round performance tracking +3. **Low**: Loss bonus tracking - nice-to-have for economy analysis diff --git a/src/lib/api/transformers/roundsTransformer.ts b/src/lib/api/transformers/roundsTransformer.ts index 1002a39..c030214 100644 --- a/src/lib/api/transformers/roundsTransformer.ts +++ b/src/lib/api/transformers/roundsTransformer.ts @@ -20,11 +20,15 @@ export function transformRoundsResponse( ): MatchRoundsResponse { const rounds: RoundDetail[] = []; - // Create player ID to team mapping for potential future use - const playerTeamMap = new Map(); + // Create player lookup map for name, team, and avatar resolution + const playerInfoMap = new Map(); if (match?.players) { for (const player of match.players) { - playerTeamMap.set(player.id, player.team_id); + playerInfoMap.set(player.id, { + name: player.name, + team_id: player.team_id, + avatar: player.avatar + }); } } @@ -39,17 +43,29 @@ export function transformRoundsResponse( const players: RoundStats[] = []; - // Convert player data + // Convert player data with name resolution for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) { + const playerInfo = playerInfoMap.get(playerId); players.push({ round: roundNum + 1, // API uses 0-indexed, we use 1-indexed bank, equipment, spent, - player_id: Number(playerId) + player_id: Number(playerId), + player_name: playerInfo?.name, + team_id: playerInfo?.team_id, + avatar: playerInfo?.avatar }); } + // Sort players by team (CT first, then T) for consistent display + players.sort((a, b) => { + if (a.team_id !== b.team_id) { + return (a.team_id ?? 0) - (b.team_id ?? 0); + } + return (a.player_name ?? '').localeCompare(b.player_name ?? ''); + }); + rounds.push({ round: roundNum + 1, // Round winner data not available from backend API diff --git a/src/lib/types/RoundStats.ts b/src/lib/types/RoundStats.ts index 8474ee1..3e7182b 100644 --- a/src/lib/types/RoundStats.ts +++ b/src/lib/types/RoundStats.ts @@ -26,6 +26,15 @@ export interface RoundStats { /** Player ID for this round data */ player_id?: number; + + /** Player display name (resolved from match.players) */ + player_name?: string; + + /** Player's team ID (2 = T, 3 = CT) */ + team_id?: number; + + /** Player avatar URL */ + avatar?: string; } /** diff --git a/src/lib/utils/economyUtils.ts b/src/lib/utils/economyUtils.ts new file mode 100644 index 0000000..791489b --- /dev/null +++ b/src/lib/utils/economyUtils.ts @@ -0,0 +1,214 @@ +/** + * CS2 Economy Utilities + * + * Unified economy classification and display utilities for consistent + * buy type detection across the application. + * + * Thresholds based on: + * - Leetify economy groupings + * - Steam community guides + * - Professional CS2 analysis standards + */ + +export type BuyType = 'pistol' | 'eco' | 'force' | 'full'; +export type TeamSide = 'T' | 'CT'; +export type EconomyHealth = 'healthy' | 'tight' | 'broken'; + +export interface BuyTypeConfig { + label: string; + color: string; + bgColor: string; + borderColor: string; +} + +export interface EconomyHealthConfig { + label: string; + color: string; + bgColor: string; + description: string; +} + +/** + * Buy type thresholds based on average equipment value per player + * CT side has higher thresholds due to more expensive rifles (M4 vs AK) + */ +const BUY_THRESHOLDS = { + T: { + eco: 1500, + force: 3500, + full: 3500 + }, + CT: { + eco: 1500, + force: 4000, + full: 4000 + } +} as const; + +/** + * Economy health thresholds based on average bank per player + */ +const ECONOMY_HEALTH_THRESHOLDS = { + healthy: 4000, // Can full-buy next round + tight: 2000 // Force-buy possible but risky + // Below tight = broken +} as const; + +/** + * Pistol round starting money + */ +export const PISTOL_ROUND_MONEY = 800; + +/** + * Visual configuration for each buy type + */ +export const BUY_TYPE_CONFIG: Record = { + pistol: { + label: 'Pistol', + color: 'text-neon-purple', + bgColor: 'bg-neon-purple/20', + borderColor: 'border-neon-purple' + }, + eco: { + label: 'Eco', + color: 'text-red-400', + bgColor: 'bg-red-500/20', + borderColor: 'border-red-500' + }, + force: { + label: 'Force', + color: 'text-yellow-400', + bgColor: 'bg-yellow-500/20', + borderColor: 'border-yellow-500' + }, + full: { + label: 'Full Buy', + color: 'text-green-400', + bgColor: 'bg-green-500/20', + borderColor: 'border-green-500' + } +}; + +/** + * Visual configuration for economy health status + */ +export const ECONOMY_HEALTH_CONFIG: Record = { + healthy: { + label: 'Healthy', + color: 'text-green-400', + bgColor: 'bg-green-500/20', + description: 'Can full-buy next round' + }, + tight: { + label: 'Tight', + color: 'text-yellow-400', + bgColor: 'bg-yellow-500/20', + description: 'Force-buy possible, risky' + }, + broken: { + label: 'Broken', + color: 'text-red-400', + bgColor: 'bg-red-500/20', + description: 'Must eco or half-buy' + } +}; + +/** + * Determine buy type based on average equipment value + * + * @param avgEquipment - Average equipment value per player + * @param teamSide - Team side ('T' or 'CT') + * @param isPistolRound - Whether this is a pistol round + * @returns Buy type classification + */ +export function getBuyType( + avgEquipment: number, + teamSide: TeamSide, + isPistolRound: boolean = false +): BuyType { + if (isPistolRound) { + return 'pistol'; + } + + const thresholds = BUY_THRESHOLDS[teamSide]; + + if (avgEquipment < thresholds.eco) { + return 'eco'; + } + if (avgEquipment < thresholds.force) { + return 'force'; + } + return 'full'; +} + +/** + * Get visual configuration for a buy type + */ +export function getBuyTypeConfig(buyType: BuyType): BuyTypeConfig { + return BUY_TYPE_CONFIG[buyType]; +} + +/** + * Determine economy health based on average bank per player + * + * @param avgBank - Average bank per player + * @returns Economy health status + */ +export function getEconomyHealth(avgBank: number): EconomyHealth { + if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.healthy) { + return 'healthy'; + } + if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.tight) { + return 'tight'; + } + return 'broken'; +} + +/** + * Get visual configuration for economy health + */ +export function getEconomyHealthConfig(health: EconomyHealth): EconomyHealthConfig { + return ECONOMY_HEALTH_CONFIG[health]; +} + +/** + * Check if a round is a pistol round + * + * @param roundNumber - Current round number (1-indexed) + * @param halftimeRound - The halftime round number (12 for MR12, 15 for MR15) + * @returns Whether this is a pistol round + */ +export function isPistolRound(roundNumber: number, halftimeRound: number): boolean { + return roundNumber === 1 || roundNumber === halftimeRound + 1; +} + +/** + * Get the halftime round based on max rounds + * + * @param maxRounds - Maximum rounds in the match (24 for MR12, 30 for MR15) + * @returns Halftime round number + */ +export function getHalftimeRound(maxRounds: number): number { + return maxRounds === 30 ? 15 : 12; +} + +/** + * Calculate total team economy (bank + equipment value) + * + * @param totalBank - Total team bank + * @param totalEquipment - Total team equipment value + * @returns Combined economy value + */ +export function calculateTeamEconomy(totalBank: number, totalEquipment: number): number { + return totalBank + totalEquipment; +} + +/** + * Format money value for display + * + * @param value - Money value + * @returns Formatted string with $ prefix and comma separators + */ +export function formatMoney(value: number): string { + return `$${value.toLocaleString()}`; +} diff --git a/src/routes/match/[id]/+layout.svelte b/src/routes/match/[id]/+layout.svelte index e7d56d9..1dfe61c 100644 --- a/src/routes/match/[id]/+layout.svelte +++ b/src/routes/match/[id]/+layout.svelte @@ -1,7 +1,15 @@ - -{#if !roundsData} - -
- -

Match Not Parsed

-

- This match hasn't been parsed yet, so detailed economy data is not available. The evidence - of everyone's financial decisions remains hidden. -

- Demo parsing required -
-
-{:else} -
- - -
-
- -
-
-

Economy Flow

-

- Net-worth differential (bank + spent) - The money story -

-
-
- {#if economyAdvantageChartData} -
- { - if (context.tick.value === 0) { - return 'rgba(255, 255, 255, 0.3)'; - } - return 'rgba(255, 255, 255, 0.05)'; - }, - lineWidth: (context) => { - return context.tick.value === 0 ? 2 : 1; - } - }, - ticks: { - color: 'rgba(255, 255, 255, 0.5)' - } - }, - x: { - grid: { - color: 'rgba(255, 255, 255, 0.05)' - }, - ticks: { - color: 'rgba(255, 255, 255, 0.5)' - } - } - }, - interaction: { - mode: 'index', - intersect: false - }, - plugins: { - legend: { - labels: { - color: 'rgba(255, 255, 255, 0.7)' - } - } - } - }} - /> - {#if halfRoundIndex > 0} -
-
-
- Half-Time -
-
- {/if} -
- {/if} -
- - -
- -
-
- -
-
-
Total Rounds
-
{totalRounds}
-
-
-
- {match.score_team_a} - {match.score_team_b} -
-
- - -
-
- -
-
-
T Full Buys
-
{teamA_fullBuys}
-
-
-
{teamA_ecos} poverty rounds
-
- - -
-
- -
-
-
CT Full Buys
-
{teamB_fullBuys}
-
-
-
{teamB_ecos} poverty rounds
-
-
- - - -
-
- -
-
-

Equipment Value Over Time

-

Total equipment value for each team across all rounds

-
-
- {#if equipmentChartData} - - {/if} -
- - - -
-

Round-by-Round Economy

-

- Detailed breakdown of buy types and equipment values - Where did all the money go? -

-
- - -
- - - -

- Buy Type Classification (A Financial Guide) -

-
-
- - Eco - - < $1,500 avg - "{buyTypeLabels['Eco']}" -
-
- - Semi-Eco - - $1,500 - $2,500 - "{buyTypeLabels['Semi-Eco']}" -
-
- - Force - - $2,500 - $3,500 - "{buyTypeLabels['Force']}" -
-
- - Full Buy - - > $3,500 - "{buyTypeLabels['Full Buy']}" -
-
-
-
-{/if} diff --git a/src/routes/match/[id]/economy/+page.ts b/src/routes/match/[id]/economy/+page.ts deleted file mode 100644 index 866e874..0000000 --- a/src/routes/match/[id]/economy/+page.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PageLoad } from './$types'; - -export const load: PageLoad = async ({ parent }) => { - // Get all data from parent layout (already loaded upfront) - const { match, rounds } = await parent(); - - return { - match, - roundsData: rounds, - meta: { - title: `${match.map || 'Match'} Economy - Match ${match.match_id} - teamflash.rip` - } - }; -}; diff --git a/src/routes/match/[id]/rounds/+page.svelte b/src/routes/match/[id]/rounds/+page.svelte new file mode 100644 index 0000000..30d0435 --- /dev/null +++ b/src/routes/match/[id]/rounds/+page.svelte @@ -0,0 +1,751 @@ + + +
+ {#if hasRoundData} + +
+
+ + +
+
+ + {#if showCharts} + +
+ +
+ +
+
+ +
+
+
Total Rounds
+
{totalRounds}
+
+
+
+ + +
+
+ +
+
+
T Full Buys
+
{fullBuyCount.t}
+
+
+
{ecoCount.t} eco rounds
+
+ + +
+
+ +
+
+
CT Full Buys
+
{fullBuyCount.ct}
+
+
+
{ecoCount.ct} eco rounds
+
+
+ + + {#if economyFlowChartData} + +
+
+ +
+
+

Economy Flow

+

Net-worth differential over time

+
+
+
+ + {#if totalRounds > halftimeRound} +
+
+
+ Half +
+
+ {/if} +
+
+ {/if} + + + {#if equipmentChartData} + +
+
+ +
+
+

Equipment Value

+

Total equipment value per team across rounds

+
+
+ +
+ {/if} +
+ {:else} + + + +
+
+
+

Round Economy

+

+ Track team economy and buy patterns throughout the match +

+
+
+ +
+ Round {selectedRound} + / {totalRounds} +
+ +
+
+ + +
+
+ {#if economyAdvantage > 1000} + + T Advantage: {formatMoney(Math.abs(economyAdvantage))} + {:else if economyAdvantage < -1000} + + CT Advantage: {formatMoney(Math.abs(economyAdvantage))} + {:else} + + Economy Even + {/if} +
+
+ + +
+ {#each { length: totalRounds } as _, index} + {@const roundNum = index + 1} + {@const isHalftime = roundNum === halftimeRound && totalRounds > halftimeRound} + {@const isSelected = roundNum === selectedRound} + {@const isFirstHalf = roundNum <= halftimeRound} + {@const roundIsPistol = checkIsPistolRound(roundNum, halftimeRound)} + + + + {#if isHalftime} +
+ HT +
+ {/if} + {/each} +
+
+
+ + +
+ + + +
+
+

Terrorists

+ + {teamABuyConfig.label} + +
+
+
+ {teamAHealthConfig.label} +
+
+ {formatMoney(teamAEconomy.totalBank)} +
+
+
+ + +
+
+
Total Bank
+
+ {formatMoney(teamAEconomy.totalBank)} +
+
+
+
Equipment
+
+ {formatMoney(teamAEconomy.totalEquipment)} +
+
+
+
Spent
+
+ {formatMoney(teamAEconomy.totalSpent)} +
+
+
+ + + +
+ + + + +
+
+

Counter-Terrorists

+ + {teamBBuyConfig.label} + +
+
+
+ {teamBHealthConfig.label} +
+
+ {formatMoney(teamBEconomy.totalBank)} +
+
+
+ + +
+
+
Total Bank
+
+ {formatMoney(teamBEconomy.totalBank)} +
+
+
+
Equipment
+
+ {formatMoney(teamBEconomy.totalEquipment)} +
+
+
+
Spent
+
+ {formatMoney(teamBEconomy.totalSpent)} +
+
+
+ + + +
+
+ + + +
+
+ Buy Type Classification (based on avg equipment per player) +
+
+
+ Pistol + Round 1 & {halftimeRound + 1} +
+
+ Eco + < $1,500 +
+
+ Force + $1,500 - $3,500 (T) / $4,000 (CT) +
+
+ Full Buy + > $3,500 (T) / $4,000 (CT) +
+
+
+
+ {/if} + {:else} + + +
+
+ +
+

Round Data Not Available

+

+ {#if !match.demo_parsed} + The demo for this match is still being processed. Round-by-round economy data will be + available once parsing is complete. + {:else} + Round economy data is not available for this match. + {/if} +

+
+
+ {/if} +