docs: Consolidate backend API documentation and feature proposals

- Merge NEW_FEATURES.md into MISSING_BACKEND_API.md as "Future Feature Proposals"
- Remove redundant NEW_FEATURES.md file
- Add CLAUDE.md to .gitignore (local development config)
- Include pending frontend component updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 21:04:13 +01:00
parent 22244e5ed7
commit f3d24e0286
7 changed files with 1193 additions and 8 deletions

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ coverage
.tmp
tmp
*.tmp
# Claude Code
CLAUDE.md

View File

@@ -449,3 +449,724 @@ Once backend provides this data, frontend will automatically display it.
}
}
```
---
# Future Feature Proposals
The following features require backend changes to implement. They are organized into tiers based on implementation complexity.
---
## Tier 3: API Enhancements (Expose Existing Data)
These features use data that's **already stored** in the database but not currently exposed through the API.
### 3.1 Per-Weapon Kill Attribution
**Current State:**
- `weapon` table stores damage per hit with `hit_group`, `weapon_id`, and `dmg`
- Weapon damage is aggregated in player meta endpoint
**Missing:**
- Kill counts per weapon (correlating damage with deaths)
- Headshot kills vs body shot kills per weapon
**Proposed API Change:**
```typescript
// Extend GET /player/:id/meta/:limit response
interface WeaponStats {
weapon_id: number;
weapon_name: string;
kills: number; // NEW: Total kills with this weapon
headshot_kills: number; // NEW: HS kills with this weapon
damage: number; // Existing
shots: number; // Existing
hits: number; // Existing
}
```
**Implementation Notes:**
- Requires correlating weapon damage events with kill events
- Could be computed at match import time and stored, or calculated on-demand
---
### 3.2 Per-Map Detailed Statistics
**Current State:**
- All player stats exist per match with `map` field
- No pre-aggregated per-map statistics
**Missing:**
- K/D ratio per map
- ADR per map
- Headshot % per map
- Win rate per map
**Proposed API Change:**
```typescript
// Extend GET /player/:id/meta/:limit response
interface MapStats {
map: string;
matches_played: number;
wins: number;
losses: number;
ties: number;
avg_kills: number;
avg_deaths: number;
avg_adr: number;
headshot_pct: number;
}
```
**Use Case:** Players can see which maps they perform best on, helping with map vetoes and practice focus.
---
### 3.3 Hit Group Aggregates
**Current State:**
- `weapon.hit_group` stores where each shot landed (0=head, 1=chest, 2=stomach, 3=left arm, 4=right arm, 5=left leg, 6=right leg)
- Individual hits are stored but not aggregated
**Missing:**
- Aggregated hit distribution across all matches
- Per-weapon accuracy by body part
**Proposed API Change:**
```typescript
// Extend GET /player/:id/meta/:limit response
interface HitGroupStats {
total_hits: number;
head: number; // Count of head hits
chest: number; // Count of chest hits
stomach: number; // Count of stomach hits
arms: number; // Combined left+right arm hits
legs: number; // Combined left+right leg hits
head_pct: number; // Calculated percentage
body_pct: number; // chest + stomach percentage
limb_pct: number; // arms + legs percentage
}
```
**Use Case:** Visual body diagram showing where player typically lands shots, identifying spray control issues.
---
### 3.4 Round Economy Timeline
**Current State:**
- `roundstats` table contains `bank`, `equipment`, `spent` per round
- Data exists but not exposed in player endpoint
**Missing:**
- Economy trends over match history
- Buy pattern analysis (eco/force/full buy distribution)
- Equipment value vs damage dealt correlation
**Proposed API Change:**
```typescript
// New endpoint: GET /player/:id/economy
interface EconomyStats {
avg_equipment_value: number;
avg_bank_at_round_start: number;
eco_round_pct: number; // % of rounds with <$2000 equipment
force_buy_pct: number; // % of rounds with $2000-$4000
full_buy_pct: number; // % of rounds with >$4000
value_per_kill: number; // avg damage dealt / avg equipment value
save_rate: number; // % of rounds where player survived with <$2000
}
```
**Use Case:** Understanding economic efficiency - are they getting value for their money?
---
### 3.5 Opponent History (Head-to-Head)
**Current State:**
- All match participants are stored
- Can identify when two players were in the same match
**Missing:**
- Head-to-head records vs specific players
- Performance when playing with/against specific teammates
**Proposed API Change:**
```typescript
// New endpoint: GET /player/:id/opponents?limit=10
interface OpponentRecord {
opponent_id: string;
opponent_name: string;
opponent_avatar: string;
matches_played: number;
wins: number;
losses: number;
avg_kills_vs: number; // Avg kills when playing against this opponent
avg_deaths_vs: number; // Avg deaths when playing against this opponent
last_match_date: string;
}
```
**Use Case:** "Rival" detection - who do they frequently play against and how do they perform?
---
### 3.6 Crosshair History
**Current State:**
- `crosshair` code stored per match
- `color` enum stored (0=default, 1=green, 2=yellow, 3=blue, 4=cyan)
**Missing:**
- Not exposed in player meta endpoint
- No history of crosshair changes
**Proposed API Change:**
```typescript
// Extend GET /player/:id/meta/:limit response
interface CrosshairInfo {
current_crosshair: string; // Most recent crosshair code
current_color: number; // Most recent color
changes_count: number; // How often they change crosshair
history: Array<{
crosshair: string;
color: number;
first_seen: string;
matches_used: number;
}>;
}
```
**Use Case:** Players curious about what crosshair settings others use, track experimentation.
---
## Tier 4: Demo Parser Additions
These features require **new data collection** from demo files during parsing.
### 4.1 Kill/Death Event Log
**What to Capture:**
```go
type KillEvent struct {
MatchID string
RoundNumber int
Tick int
Timestamp float64 // Seconds into round
KillerID string
VictimID string
AssisterID string // nullable
WeaponID int
Headshot bool
Wallbang bool
Smoke bool // Through smoke
NoScope bool
Flashed bool // Victim was flashed
Distance float32 // Distance between players
KillerX float32
KillerY float32
KillerZ float32
VictimX float32
VictimY float32
VictimZ float32
}
```
**Enables:**
- Opening kill/death statistics (first blood)
- Trade detection (kill within X seconds of teammate death)
- Kill distance analysis (long range vs close quarters)
- Clutch situation detection
- Kill position heatmaps
**Storage Estimate:** ~20-30 kills per match × 20 bytes average = ~500 bytes per match
---
### 4.2 Round Winner Attribution
**What to Capture:**
```go
type RoundResult struct {
MatchID string
RoundNumber int
WinnerTeamID int // 2 or 3
WinReason int // 1=bomb, 2=defuse, 3=elimination, 4=time
ClutchPlayerID string // nullable - player who clutched (1vX)
ClutchSize int // 1v1, 1v2, etc.
MVPPlayerID string // Round MVP
}
```
**Enables:**
- Clutch win rate (1v1, 1v2, 1v3, etc.)
- Bomb plant/defuse success rates
- Round impact scoring
- MVP frequency
---
### 4.3 Position/Heatmap Data
**What to Capture:**
```go
type PlayerPosition struct {
MatchID string
RoundNumber int
Tick int // Sample every ~32 ticks (0.5 seconds)
PlayerID string
X float32
Y float32
Z float32
ViewAngleX float32 // Where they're looking
ViewAngleY float32
IsAlive bool
}
```
**Enables:**
- Kill heatmaps (where do they get kills/die)
- Site preference analysis (A vs B)
- Positioning patterns
- 2D round replay (future)
**Storage Estimate:** High - ~2000 position samples per player per match. Consider:
- Only sample during key moments (kills, bomb plant, round end)
- Downsample to 1 sample per second
- Store as compressed binary blob
---
### 4.4 Trade Detection
**What to Capture:**
- Already covered by Kill Event Log (4.1)
- Calculate at query time: If player A dies and teammate B kills A's killer within 5 seconds = trade
**Derived Stats:**
```typescript
interface TradeStats {
trades_given: number; // Times you avenged a teammate
trades_received: number; // Times a teammate avenged you
trade_success_rate: number; // trades_given / opportunities
trading_partner_id: string; // Who they trade with most often
}
```
---
### 4.5 Utility Timing Analysis
**What to Capture:**
```go
type UtilityEvent struct {
MatchID string
RoundNumber int
Tick int
PlayerID string
UtilityType int // flash, smoke, he, molotov
ThrowX float32
ThrowY float32
ThrowZ float32
LandX float32 // Where it detonated/landed
LandY float32
LandZ float32
PlayersFlashed []FlashedPlayer // For flashbangs
}
type FlashedPlayer struct {
PlayerID string
Duration float32 // Flash duration in seconds
IsTeammate bool
}
```
**Enables:**
- Flash effectiveness (avg flash duration)
- Team flash rate (already have `dmg_team` but duration is more precise)
- Smoke lineup success
- Molotov damage tracking
---
### 4.6 First Blood Statistics
**What to Capture:**
- Mark `is_first_blood` flag in Kill Event (4.1)
- First kill of each round
**Derived Stats:**
```typescript
interface FirstBloodStats {
first_blood_attempts: number; // Rounds where they got first kill OR first death
first_bloods: number; // Times they got first kill
first_deaths: number; // Times they died first
first_blood_rate: number; // first_bloods / first_blood_attempts
opening_duel_win_rate: number; // first_bloods / (first_bloods + first_deaths)
}
```
---
### 4.7 HLTV Rating 2.0
**Formula Components:**
```
Rating 2.0 = 0.0073*KAST + 0.3591*KPR - 0.5329*DPR + 0.2372*Impact + 0.0032*ADR + 0.1587
Where:
- KAST = % of rounds with Kill, Assist, Survived, or Traded
- KPR = Kills per round
- DPR = Deaths per round
- Impact = (2.13*KPR + 0.42*Assist per Round - 0.41)
- ADR = Average damage per round
```
**What's Needed:**
- KAST requires knowing if player survived or was traded (needs trade detection)
- All other components are calculable from existing data
**Proposed Implementation:**
- Add `kast_rounds` counter to match stats
- Calculate Rating 2.0 server-side for consistency
---
## Tier 5: New Systems
These features require **significant new infrastructure**.
### 5.1 Player Comparison Tool
**Description:** Side-by-side comparison of any two players' statistics.
**Requirements:**
- New UI page: `/compare/:player1/:player2`
- API endpoint: `GET /players/compare?ids=player1,player2`
- Returns normalized stats for fair comparison
**Comparison Metrics:**
```typescript
interface PlayerComparison {
players: [PlayerMeta, PlayerMeta];
stats_comparison: {
metric: string;
player1_value: number;
player2_value: number;
player1_percentile: number; // How they rank globally
player2_percentile: number;
}[];
head_to_head?: {
matches_played: number;
player1_wins: number;
player2_wins: number;
};
common_maps: string[];
common_teammates: PlayerMeta[];
}
```
---
### 5.2 Achievement System
**Description:** Badges and milestones for player accomplishments.
**Schema:**
```sql
CREATE TABLE achievements (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
description TEXT,
icon VARCHAR(50),
category VARCHAR(50), -- 'kills', 'utility', 'milestones', 'special'
threshold INT,
stat_key VARCHAR(50) -- Which stat to check
);
CREATE TABLE player_achievements (
player_id VARCHAR(20),
achievement_id INT,
unlocked_at TIMESTAMP,
progress INT, -- For progressive achievements
PRIMARY KEY (player_id, achievement_id)
);
```
**Example Achievements:**
| Name | Description | Threshold |
|------|-------------|-----------|
| Centurion | 100 aces | 100 |
| Headhunter | 1000 headshot kills | 1000 |
| Support Main | 500 flash assists | 500 |
| Clutch Master | 50 1v3+ clutches | 50 |
| Map Scholar | Play 100 matches on each map | 700 total |
| Friendly Fire | Deal 1000 team damage (shame badge) | 1000 |
**Implementation:**
- Background job checks achievement progress after each match import
- Push notification system for newly unlocked achievements
- Achievement showcase on player profile
---
### 5.3 Global Leaderboards
**Description:** Rankings by various statistics.
**Schema:**
```sql
CREATE TABLE leaderboards (
stat_key VARCHAR(50),
time_period VARCHAR(20), -- 'all_time', 'monthly', 'weekly'
player_id VARCHAR(20),
value DECIMAL(10,2),
rank INT,
updated_at TIMESTAMP,
PRIMARY KEY (stat_key, time_period, player_id)
);
```
**Leaderboard Categories:**
- Highest K/D ratio (min 50 matches)
- Most headshot kills
- Highest ADR
- Most flash assists
- Most matches played
- Highest win rate (min 100 matches)
- Most aces
- Longest win streak
**Implementation:**
- Materialized view or dedicated table refreshed hourly/daily
- Pagination support for full leaderboard browsing
- Filter by region/rank bracket (future)
---
### 5.4 Improvement Tips Engine
**Description:** AI-generated suggestions based on weak stats.
**Rules Engine Approach:**
```typescript
interface ImprovementRule {
condition: (stats: PlayerStats) => boolean;
tip: string;
priority: number;
category: 'aim' | 'utility' | 'economy' | 'teamplay' | 'consistency';
}
const rules: ImprovementRule[] = [
{
condition: (s) => s.headshot_pct < 30,
tip: 'Your headshot percentage is below average. Focus on crosshair placement at head level.',
priority: 1,
category: 'aim'
},
{
condition: (s) => s.flash_assists < 1,
tip: "You're not getting many flash assists. Practice pop flashes for your teammates.",
priority: 2,
category: 'utility'
},
{
condition: (s) => s.team_damage_ratio > 3,
tip: 'Your team damage is high. Be more careful with utility and spray control.',
priority: 1,
category: 'teamplay'
}
// ... more rules
];
```
**Alternative - ML Approach:**
- Cluster players by playstyle
- Compare to higher-ranked players with similar style
- Identify key stat differences
- Generate personalized recommendations
---
### 5.5 Match Replay Integration (2D)
**Description:** Simple 2D overhead view of rounds.
**Requirements:**
- Position data from Tier 4.3
- Map radar images (already have some)
- WebGL or Canvas-based renderer
- Playback controls (play/pause/speed)
**Data Format:**
```typescript
interface ReplayFrame {
tick: number;
timestamp: number; // Seconds into round
players: {
id: string;
team: number;
x: number;
y: number;
angle: number;
health: number;
alive: boolean;
weapon: string;
}[];
events: {
type: 'kill' | 'plant' | 'defuse' | 'flash' | 'smoke';
tick: number;
data: any;
}[];
}
```
**Compression:**
- Delta encoding (only send changes)
- Binary format instead of JSON
- Stream in chunks for long rounds
---
### 5.6 Social Features
**Description:** Follow players, activity feed, sharing.
**Schema:**
```sql
CREATE TABLE follows (
follower_id VARCHAR(20),
following_id VARCHAR(20),
created_at TIMESTAMP,
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE activity_feed (
id SERIAL PRIMARY KEY,
player_id VARCHAR(20),
event_type VARCHAR(50), -- 'match_completed', 'achievement_unlocked', 'rank_changed'
event_data JSONB,
created_at TIMESTAMP
);
```
**Features:**
- Follow/unfollow players
- Activity feed showing followed players' recent matches
- Share profile/match links
- Privacy settings (public/private profiles)
---
## Data Schema Reference
### Current Tables Used
| Table | Key Fields | Notes |
| -------------- | ----------------------------- | --------------------- |
| `players` | id, name, avatar, tracked | Core player data |
| `matches` | match*id, map, date, score*\* | Match metadata |
| `matchplayers` | All per-player stats | Already comprehensive |
| `weapon` | hit_group, weapon_id, dmg | Hit registration |
| `roundstats` | bank, equipment, spent | Economy data |
| `spray` | spray (gob-encoded) | Spray patterns |
### Existing Fields Available but Not Exposed
| Field | Location | Potential Use |
| ----------- | ------------ | ------------------- |
| `crosshair` | matchplayers | Crosshair display |
| `color` | matchplayers | Crosshair color |
| `avg_ping` | matchplayers | Network quality |
| `hit_group` | weapon | Body part accuracy |
| `spray` | spray | Spray visualization |
---
## Priority Recommendations
### High Priority (High Value, Low Effort)
1. **3.1 Per-Weapon Kill Attribution** - Players want to know their best weapons
2. **3.2 Per-Map Detailed Statistics** - Essential for competitive players
3. **3.3 Hit Group Aggregates** - Visual appeal, easy to understand
### Medium Priority (High Value, Medium Effort)
4. **4.1 Kill/Death Event Log** - Unlocks many derived features
5. **4.6 First Blood Statistics** - Key competitive metric
6. **3.5 Opponent History** - Engaging "rivalry" feature
### Lower Priority (High Effort or Niche Appeal)
7. **5.3 Global Leaderboards** - Community engagement
8. **5.1 Player Comparison** - Nice-to-have
9. **5.2 Achievement System** - Gamification
10. **5.5 Match Replay** - High effort, impressive feature
---
## Questions for Discussion
1. **Storage constraints:** Position data (4.3) could be large. What's our storage budget?
2. **Processing time:** Kill event parsing (4.1) adds to import time. Acceptable?
3. **Caching strategy:** Meta stats are cached 30 days. Should new aggregates follow same pattern?
4. **API versioning:** Should we version the API or extend existing endpoints?
5. **Privacy considerations:** Should opponent history require consent?
---
_Document consolidated from separate feature proposals_
_Last updated: December 2024_

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { User, TrendingUp, Target } from 'lucide-svelte';
import { CircleUser, TrendingUp, Target, Gamepad2 } from 'lucide-svelte';
import type { PlayerMeta } from '$lib/types';
interface Props {
@@ -26,7 +26,7 @@
<div
class="flex h-12 w-12 items-center justify-center rounded-full border border-neon-blue/30 bg-void"
>
<User class="h-6 w-6 text-neon-blue" />
<CircleUser class="h-8 w-8 text-neon-blue/70" />
</div>
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-bold text-white">{player.name}</h3>
@@ -56,7 +56,7 @@
<div class="text-center">
<div class="mb-1 flex items-center justify-center">
<User class="mr-1 h-4 w-4 text-neon-blue" />
<Gamepad2 class="mr-1 h-4 w-4 text-neon-blue" />
</div>
<div class="font-mono text-xl font-bold text-white">{player.recent_matches}</div>
<div class="text-xs text-white/50">Matches</div>

View File

@@ -25,9 +25,29 @@
}: Props = $props();
// If using href-based tabs, derive active from current route
const isActive = (tab: Tab): boolean => {
const isActive = (tab: Tab, allTabs: Tab[]): boolean => {
if (tab.href) {
return $page.url.pathname === tab.href || $page.url.pathname.startsWith(tab.href + '/');
const currentPath = $page.url.pathname;
// Exact match always wins
if (currentPath === tab.href) return true;
// For nested routes, check if this is the most specific matching tab
// This prevents parent routes (like /match/123) from matching child routes (like /match/123/weapons)
const matchingTabs = allTabs.filter(
(t) => t.href && (currentPath === t.href || currentPath.startsWith(t.href + '/'))
);
// If multiple tabs match, only the longest (most specific) href should be active
if (matchingTabs.length > 1) {
const longestMatch = matchingTabs.reduce((a, b) =>
(a.href?.length || 0) > (b.href?.length || 0) ? a : b
);
return tab.href === longestMatch.href;
}
// Single match - check if it's this tab
return matchingTabs.some((t) => t.href === tab.href);
}
return activeTab === tab.value;
};
@@ -62,7 +82,7 @@
class="inline-flex gap-1 rounded-lg bg-void/50 p-1 backdrop-blur-sm {className}"
>
{#each tabs as tab}
{@const active = isActive(tab)}
{@const active = isActive(tab, tabs)}
{@const classes = `${baseTabClasses} ${sizeClasses[size]} ${active ? activeClasses : inactiveClasses} ${tab.disabled ? disabledClasses : ''}`}
{#if tab.href}

View File

@@ -0,0 +1,430 @@
/**
* Player statistics calculation utilities
* These functions calculate derived stats from match history for the player profile page
*/
import type { MatchPlayer } from '$lib/types';
/**
* Extended player match stats including match context
*/
export interface PlayerMatchStats extends MatchPlayer {
match_id: string;
map: string;
date: string;
won: boolean;
tied: boolean;
}
/**
* Calculated player statistics
*/
export interface CalculatedStats {
// Form indicator
formRating: 'hot' | 'cold' | 'consistent';
formDelta: number; // Percentage difference from average
// Win streaks
currentStreak: { type: 'W' | 'L' | 'T'; count: number };
longestWinStreak: number;
longestLossStreak: number;
// Side preference
tSideWinRate: number;
ctSideWinRate: number;
preferredSide: 'T' | 'CT' | 'balanced';
// Consistency (lower is more consistent)
kdConsistency: number;
consistencyRating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
// Role detection
detectedRole: 'Entry' | 'Support' | 'AWPer' | 'Lurker' | 'Flex';
roleConfidence: number;
// Team damage
avgTeamDamage: number;
teamDamageRatio: number; // Percentage of total damage that went to team
// Ping
avgPing: number;
// Time analysis
bestTimeOfDay: string;
weekendWinRate: number;
weekdayWinRate: number;
}
/**
* Calculate average K/D ratio from stats
*/
function avgKD(stats: PlayerMatchStats[]): number {
if (stats.length === 0) return 0;
const totalKills = stats.reduce((sum, s) => sum + s.kills, 0);
const totalDeaths = stats.reduce((sum, s) => sum + s.deaths, 0);
return totalDeaths > 0 ? totalKills / totalDeaths : totalKills;
}
/**
* Calculate standard deviation
*/
function standardDeviation(values: number[]): number {
if (values.length === 0) return 0;
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const squaredDiffs = values.map((v) => Math.pow(v - mean, 2));
const avgSquaredDiff = squaredDiffs.reduce((sum, v) => sum + v, 0) / values.length;
return Math.sqrt(avgSquaredDiff);
}
/**
* Calculate form rating based on recent performance vs overall
*/
export function calculateForm(stats: PlayerMatchStats[]): {
rating: 'hot' | 'cold' | 'consistent';
delta: number;
} {
if (stats.length < 5) {
return { rating: 'consistent', delta: 0 };
}
const recent5 = stats.slice(0, 5);
const overall = stats;
const recentKD = avgKD(recent5);
const overallKD = avgKD(overall);
if (overallKD === 0) {
return { rating: 'consistent', delta: 0 };
}
const delta = ((recentKD - overallKD) / overallKD) * 100;
if (delta > 15) return { rating: 'hot', delta };
if (delta < -15) return { rating: 'cold', delta };
return { rating: 'consistent', delta };
}
/**
* Calculate current win/loss streak
*/
export function calculateCurrentStreak(stats: PlayerMatchStats[]): {
type: 'W' | 'L' | 'T';
count: number;
} {
if (stats.length === 0) {
return { type: 'W', count: 0 };
}
const firstMatch = stats[0];
if (!firstMatch) {
return { type: 'W', count: 0 };
}
const firstType = firstMatch.tied ? 'T' : firstMatch.won ? 'W' : 'L';
let count = 1;
for (let i = 1; i < stats.length; i++) {
const match = stats[i];
if (!match) break;
const type = match.tied ? 'T' : match.won ? 'W' : 'L';
if (type === firstType) {
count++;
} else {
break;
}
}
return { type: firstType, count };
}
/**
* Calculate longest streak of a given type
*/
export function calculateLongestStreak(stats: PlayerMatchStats[], type: 'win' | 'loss'): number {
let maxStreak = 0;
let currentStreak = 0;
for (const match of stats) {
const isTarget = type === 'win' ? match.won : !match.won && !match.tied;
if (isTarget) {
currentStreak++;
maxStreak = Math.max(maxStreak, currentStreak);
} else {
currentStreak = 0;
}
}
return maxStreak;
}
/**
* Calculate win rate for a specific side
* Note: team_id 2 = T side at start, team_id 3 = CT side at start
* This is a simplification - actual side performance would need round-by-round data
*/
export function calculateSideWinRate(stats: PlayerMatchStats[], side: 'T' | 'CT'): number {
const targetTeamId = side === 'T' ? 2 : 3;
const sideMatches = stats.filter((s) => s.team_id === targetTeamId);
if (sideMatches.length === 0) return 0;
const wins = sideMatches.filter((s) => s.won).length;
return (wins / sideMatches.length) * 100;
}
/**
* Calculate K/D consistency (coefficient of variation)
* Lower value = more consistent
*/
export function calculateConsistency(stats: PlayerMatchStats[]): {
value: number;
rating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
} {
if (stats.length < 3) {
return { value: 0, rating: 'consistent' };
}
const kdRatios = stats.map((s) => (s.deaths > 0 ? s.kills / s.deaths : s.kills));
const mean = kdRatios.reduce((sum, v) => sum + v, 0) / kdRatios.length;
const stdDev = standardDeviation(kdRatios);
// Coefficient of variation (CV) - normalized measure of dispersion
const cv = mean > 0 ? (stdDev / mean) * 100 : 0;
// Rate consistency based on CV
// CV < 20% is very consistent, 20-35% consistent, 35-50% inconsistent, >50% very inconsistent
let rating: 'very_consistent' | 'consistent' | 'inconsistent' | 'very_inconsistent';
if (cv < 20) rating = 'very_consistent';
else if (cv < 35) rating = 'consistent';
else if (cv < 50) rating = 'inconsistent';
else rating = 'very_inconsistent';
return { value: cv, rating };
}
/**
* Detect player role based on playstyle heuristics
*/
export function detectRole(stats: PlayerMatchStats[]): {
role: 'Entry' | 'Support' | 'AWPer' | 'Lurker' | 'Flex';
confidence: number;
} {
if (stats.length < 3) {
return { role: 'Flex', confidence: 0 };
}
// Calculate averages
const avgFlashAssists = stats.reduce((sum, s) => sum + (s.flash_assists || 0), 0) / stats.length;
const avgADR = stats.reduce((sum, s) => sum + (s.adr || 0), 0) / stats.length;
const avgKills = stats.reduce((sum, s) => sum + s.kills, 0) / stats.length;
const avgDeaths = stats.reduce((sum, s) => sum + s.deaths, 0) / stats.length;
const avgMultiKills =
stats.reduce(
(sum, s) => sum + (s.mk_2 || 0) + (s.mk_3 || 0) + (s.mk_4 || 0) + (s.mk_5 || 0),
0
) / stats.length;
const avgFlashesUsed = stats.reduce((sum, s) => sum + (s.ud_flash || 0), 0) / stats.length;
// Scoring system for each role
const scores = {
Entry: 0,
Support: 0,
AWPer: 0,
Lurker: 0,
Flex: 50 // Base score for Flex
};
// Entry Fragger: High ADR, high kills, high deaths (aggressive)
if (avgADR > 80) scores.Entry += 30;
else if (avgADR > 70) scores.Entry += 15;
if (avgKills > 18) scores.Entry += 20;
if (avgDeaths > 16) scores.Entry += 10; // Often dies first
if (avgMultiKills > 2) scores.Entry += 20;
// Support: High flash assists, moderate ADR, lots of utility
if (avgFlashAssists > 2) scores.Support += 40;
else if (avgFlashAssists > 1) scores.Support += 20;
if (avgFlashesUsed > 3) scores.Support += 15;
if (avgADR >= 60 && avgADR <= 75) scores.Support += 15;
// AWPer: Would need weapon data - use high damage, fewer kills pattern
// Without weapon data, we can't reliably detect AWPers
// AWP kills typically have high damage per kill
const damagePerKill = avgKills > 0 ? avgADR / (avgKills / 20) : 0; // Rough estimate
if (damagePerKill > 100) scores.AWPer += 25;
// Lurker: Low flash usage, moderate stats, high survival
if (avgFlashesUsed < 2 && avgFlashAssists < 1) scores.Lurker += 20;
if (avgDeaths < 14 && avgKills > 14) scores.Lurker += 25; // Good K/D, survives
if (avgADR >= 65 && avgADR <= 80) scores.Lurker += 15;
// Find highest scoring role
const entries = Object.entries(scores) as [keyof typeof scores, number][];
entries.sort((a, b) => b[1] - a[1]);
const topRole = entries[0];
const secondRole = entries[1];
// Calculate confidence based on margin
const margin = topRole && secondRole ? topRole[1] - secondRole[1] : 0;
const confidence = Math.min(100, margin + 20);
return {
role: topRole ? topRole[0] : 'Flex',
confidence
};
}
/**
* Calculate team damage statistics
*/
export function calculateTeamDamage(stats: PlayerMatchStats[]): {
avgTeamDamage: number;
teamDamageRatio: number;
} {
if (stats.length === 0) {
return { avgTeamDamage: 0, teamDamageRatio: 0 };
}
const totalTeamDamage = stats.reduce((sum, s) => sum + (s.dmg_team || 0), 0);
const totalEnemyDamage = stats.reduce((sum, s) => sum + (s.dmg_enemy || 0), 0);
const totalDamage = totalTeamDamage + totalEnemyDamage;
return {
avgTeamDamage: totalTeamDamage / stats.length,
teamDamageRatio: totalDamage > 0 ? (totalTeamDamage / totalDamage) * 100 : 0
};
}
/**
* Calculate average ping
*/
export function calculateAvgPing(stats: PlayerMatchStats[]): number {
const pings = stats.filter((s) => s.avg_ping !== undefined && s.avg_ping > 0);
if (pings.length === 0) return 0;
return pings.reduce((sum, s) => sum + (s.avg_ping || 0), 0) / pings.length;
}
/**
* Analyze time-of-day performance
*/
export function calculateTimePerformance(stats: PlayerMatchStats[]): {
bestTimeOfDay: string;
weekendWinRate: number;
weekdayWinRate: number;
} {
if (stats.length === 0) {
return { bestTimeOfDay: 'N/A', weekendWinRate: 0, weekdayWinRate: 0 };
}
// Group by time of day
const timeSlots: Record<string, { wins: number; total: number }> = {
Morning: { wins: 0, total: 0 }, // 6-12
Afternoon: { wins: 0, total: 0 }, // 12-18
Evening: { wins: 0, total: 0 }, // 18-24
Night: { wins: 0, total: 0 } // 0-6
};
let weekendMatches = 0;
let weekendWins = 0;
let weekdayMatches = 0;
let weekdayWins = 0;
for (const match of stats) {
const date = new Date(match.date);
const hour = date.getHours();
const dayOfWeek = date.getDay();
// Time of day
let slot: string;
if (hour >= 6 && hour < 12) slot = 'Morning';
else if (hour >= 12 && hour < 18) slot = 'Afternoon';
else if (hour >= 18) slot = 'Evening';
else slot = 'Night';
const timeSlot = timeSlots[slot];
if (timeSlot) {
timeSlot.total++;
if (match.won) timeSlot.wins++;
}
// Weekend vs weekday (0 = Sunday, 6 = Saturday)
if (dayOfWeek === 0 || dayOfWeek === 6) {
weekendMatches++;
if (match.won) weekendWins++;
} else {
weekdayMatches++;
if (match.won) weekdayWins++;
}
}
// Find best time slot
let bestSlot = 'Evening';
let bestWinRate = 0;
for (const [slot, data] of Object.entries(timeSlots)) {
if (data.total >= 3) {
// Need at least 3 matches for meaningful data
const winRate = data.wins / data.total;
if (winRate > bestWinRate) {
bestWinRate = winRate;
bestSlot = slot;
}
}
}
return {
bestTimeOfDay: bestSlot,
weekendWinRate: weekendMatches > 0 ? (weekendWins / weekendMatches) * 100 : 0,
weekdayWinRate: weekdayMatches > 0 ? (weekdayWins / weekdayMatches) * 100 : 0
};
}
/**
* Calculate all player statistics
*/
export function calculateAllStats(stats: PlayerMatchStats[]): CalculatedStats | null {
if (stats.length === 0) {
return null;
}
const form = calculateForm(stats);
const currentStreak = calculateCurrentStreak(stats);
const longestWinStreak = calculateLongestStreak(stats, 'win');
const longestLossStreak = calculateLongestStreak(stats, 'loss');
const tSideWinRate = calculateSideWinRate(stats, 'T');
const ctSideWinRate = calculateSideWinRate(stats, 'CT');
const consistency = calculateConsistency(stats);
const role = detectRole(stats);
const teamDamage = calculateTeamDamage(stats);
const avgPing = calculateAvgPing(stats);
const timePerf = calculateTimePerformance(stats);
// Determine preferred side
let preferredSide: 'T' | 'CT' | 'balanced' = 'balanced';
const sideDiff = Math.abs(tSideWinRate - ctSideWinRate);
if (sideDiff > 10) {
preferredSide = tSideWinRate > ctSideWinRate ? 'T' : 'CT';
}
return {
formRating: form.rating,
formDelta: form.delta,
currentStreak,
longestWinStreak,
longestLossStreak,
tSideWinRate,
ctSideWinRate,
preferredSide,
kdConsistency: consistency.value,
consistencyRating: consistency.rating,
detectedRole: role.role,
roleConfidence: role.confidence,
avgTeamDamage: teamDamage.avgTeamDamage,
teamDamageRatio: teamDamage.teamDamageRatio,
avgPing,
bestTimeOfDay: timePerf.bestTimeOfDay,
weekendWinRate: timePerf.weekendWinRate,
weekdayWinRate: timePerf.weekdayWinRate
};
}

View File

@@ -54,6 +54,9 @@
// Prepare data table columns
type PlayerWithStats = (typeof playersWithStats)[0];
// SVG fallback for broken avatars
const avatarFallbackSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-6 w-6 text-white/40"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>`;
const detailsColumns = [
{
key: 'avatar' as keyof PlayerWithStats,
@@ -62,7 +65,10 @@
align: 'center' as const,
render: (_value: unknown, row: PlayerWithStats) => {
const avatarUrl = row.avatar || '';
return `<img src="${avatarUrl}" alt="${row.name}" class="h-10 w-10 rounded-full border-2 border-white/10" />`;
if (!avatarUrl) {
return `<div class="flex h-10 w-10 items-center justify-center rounded-full border-2 border-white/10 bg-void">${avatarFallbackSvg}</div>`;
}
return `<img src="${avatarUrl}" alt="${row.name}" class="h-10 w-10 rounded-full border-2 border-white/10 bg-void" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" /><div class="hidden h-10 w-10 items-center justify-center rounded-full border-2 border-white/10 bg-void">${avatarFallbackSvg}</div>`;
}
},
{

View File

@@ -2,6 +2,7 @@ import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import type { PageLoad } from './$types';
import type { PlayerMetaStats } from '$lib/types';
import { calculateAllStats, type PlayerMatchStats } from '$lib/utils/playerStats';
export const load: PageLoad = async ({ params }) => {
const playerId = params.id; // Keep as string to preserve uint64 precision
@@ -31,7 +32,7 @@ export const load: PageLoad = async ({ params }) => {
const matchesWithDetails = await Promise.all(matchDetailsPromises);
// Extract player stats from each match
const playerStats = matchesWithDetails
const playerStats: PlayerMatchStats[] = matchesWithDetails
.map((match) => {
const playerData = match.players?.find((p) => p.id === playerId);
if (!playerData) return null;
@@ -54,11 +55,15 @@ export const load: PageLoad = async ({ params }) => {
})
.filter((stat): stat is NonNullable<typeof stat> => stat !== null);
// Calculate derived statistics (form, streaks, role detection, etc.)
const calculatedStats = calculateAllStats(playerStats);
return {
profile,
recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
playerStats, // Full stats for charts
metaStats, // Pre-aggregated stats from backend (teammates, weapons, maps)
calculatedStats, // Derived analytics (form, streaks, role, etc.)
meta: {
title: `${profile.name} - Player Profile | teamflash.rip`,
description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.`