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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ coverage
|
||||
.tmp
|
||||
tmp
|
||||
*.tmp
|
||||
|
||||
# Claude Code
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
430
src/lib/utils/playerStats.ts
Normal file
430
src/lib/utils/playerStats.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.`
|
||||
|
||||
Reference in New Issue
Block a user