feat: Overhaul ranking system to support CS2 dual-architecture

Based on comprehensive research, CS2 implements a bifurcated ranking system:
- Premier Mode: CS Rating (numerical ELO, 0-30,000+) - launched Aug 31, 2023
- Competitive/Wingman: Skill Groups (0-18, Silver I to Global Elite)
- Legacy CS:GO: Skill Groups only (pre-Sept 27, 2023)

Changes:
- Add game_mode field to Match type ('premier' | 'competitive' | 'wingman')
- Create rankingSystem.ts utility with smart detection logic:
  * Checks match date (CS:GO legacy vs CS2)
  * Checks game_mode (Premier vs Competitive/Wingman)
  * Falls back to rating value heuristic (0-18 vs 1000+)
- Update PremierRatingBadge to use new detection logic
- Pass match context from match detail page to badge component
- Update Match and schema documentation with dual-system details
- Add research.md documenting CS2's ranking system architecture

Key dates:
- August 31, 2023: Premier Mode and CS Rating launched
- September 27, 2023: CS2 officially replaced CS:GO

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-13 00:46:28 +01:00
parent 05a6c10458
commit 3192180c60
6 changed files with 204 additions and 17 deletions

35
research.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,15 @@
<script lang="ts">
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
import { usesSkillGroup } from '$lib/utils/rankingSystem';
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
import RankIcon from './RankIcon.svelte';
import type { Match } from '$lib/types';
interface Props {
rating: number | undefined | null;
oldRating?: number | undefined | null;
/** Match data for determining ranking system (date + game_mode) */
match?: Pick<Match, 'date' | 'game_mode'>;
size?: 'sm' | 'md' | 'lg';
showTier?: boolean;
showChange?: boolean;
@@ -16,6 +20,7 @@
let {
rating,
oldRating,
match,
size = 'md',
showTier = false,
showChange = false,
@@ -23,15 +28,17 @@
class: className = ''
}: Props = $props();
// Determine if this is a legacy CS:GO match
// CS:GO skill groups are 0-18, CS2 ratings are typically 1000-30000
// For legacy matches, both rating and oldRating will be in the 0-18 range
const isLegacyRank = $derived(
rating !== undefined &&
rating !== null &&
rating >= 0 &&
rating <= 18 &&
(oldRating === undefined || oldRating === null || (oldRating >= 0 && oldRating <= 18))
/**
* Determine if this rating should be displayed as a Skill Group (0-18)
* Uses the new ranking system detection logic based on:
* 1. Match date (CS:GO legacy vs CS2)
* 2. Game mode (Premier vs Competitive/Wingman)
* 3. Fallback heuristic (0-18 = Skill Group, >1000 = CS Rating)
*/
const shouldShowSkillGroup = $derived(
match
? usesSkillGroup(match, rating)
: rating !== null && rating !== undefined && rating >= 0 && rating <= 18
);
const tierInfo = $derived(formatPremierRating(rating));
@@ -56,15 +63,14 @@
);
</script>
{#if isLegacyRank}
<!-- Show CS:GO rank icon for legacy matches -->
<!-- Use rating (rank_new) as it contains the current skill group -->
{#if shouldShowSkillGroup}
<!-- Show Skill Group icon (CS:GO legacy OR CS2 Competitive/Wingman mode) -->
<RankIcon skillGroup={rating} {size} class={className} />
{:else if !rating || rating === 0}
<!-- No rating available -->
<span class="text-sm text-base-content/50">Unranked</span>
{:else}
<!-- Show Premier rating for CS2 matches -->
<!-- Show CS Rating for CS2 Premier mode -->
<div class={classes}>
{#if showIcon}
<Trophy class={iconSizes[size]} />

View File

@@ -21,7 +21,8 @@ export const matchPlayerSchema = z.object({
score: z.number().int().nonnegative(),
kast: z.number().int().min(0).max(100).optional(),
// Rank (CS2 Premier rating: 0-30000)
// Rank (interpretation depends on game mode and date)
// Premier Mode: CS Rating (0-30000+), Competitive/Wingman: Skill Group (0-18)
rank_old: z.number().int().min(0).max(30000).optional(),
rank_new: z.number().int().min(0).max(30000).optional(),
@@ -79,6 +80,7 @@ export const matchSchema = z.object({
vac_present: z.boolean(),
gameban_present: z.boolean(),
tick_rate: z.number().positive().optional(),
game_mode: z.enum(['premier', 'competitive', 'wingman']).optional(),
players: z.array(matchPlayerSchema).optional()
});

View File

@@ -42,6 +42,15 @@ export interface Match {
/** Server tick rate (64 or 128) - optional, not always provided by API */
tick_rate?: number;
/**
* Game mode: 'premier' | 'competitive' | 'wingman'
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
* - Competitive: Uses Skill Groups (0-18, Silver I to Global Elite, per-map)
* - Wingman: Uses Skill Groups (0-18, 2v2 mode)
* Optional field - may not be present in legacy CS:GO matches
*/
game_mode?: 'premier' | 'competitive' | 'wingman';
/** Array of player statistics (optional, included in detailed match view) */
players?: MatchPlayer[];
}
@@ -100,7 +109,16 @@ export interface MatchPlayer {
/** Headshot percentage */
hs_percent?: number;
// Rank tracking (CS2 Premier rating: 0-30000)
/**
* Rank tracking - interpretation depends on game mode and date:
*
* CS2 (post-Sept 27, 2023):
* - Premier Mode: rank_new = CS Rating (0-30,000+), rank_old = previous CS Rating
* - Competitive/Wingman: rank_new = Skill Group (0-18), rank_old = previous Skill Group
*
* CS:GO Legacy (pre-Sept 27, 2023):
* - rank_new = Skill Group (0-18), rank_old = previous Skill Group
*/
rank_old?: number;
rank_new?: number;

View File

@@ -0,0 +1,126 @@
import type { Match } from '$lib/types';
/**
* CS2 Ranking System Utilities
*
* Based on research: Counter-Strike 2 implements a bifurcated ranking architecture:
* - Premier Mode: CS Rating (numerical ELO, 0-30,000+) - launched August 31, 2023
* - Competitive Mode: Skill Groups (0-18, Silver I to Global Elite) - retained from CS:GO
*
* Key dates:
* - August 31, 2023: Premier Mode and CS Rating launched
* - September 27, 2023: CS2 officially replaced CS:GO
*/
/**
* CS2 official launch date (September 27, 2023)
* Matches before this date are considered CS:GO legacy matches
*/
export const CS2_LAUNCH_DATE = new Date('2023-09-27T00:00:00Z');
/**
* Premier Mode launch date (August 31, 2023)
* CS Rating system became available on this date
*/
export const CS_RATING_LAUNCH_DATE = new Date('2023-08-31T00:00:00Z');
/**
* Ranking system type
*/
export type RankingSystem = 'cs_rating' | 'skill_group' | 'unknown';
/**
* Determines which ranking system a match uses
*
* Logic:
* 1. If match date < September 27, 2023 → CS:GO legacy (Skill Groups only)
* 2. If match date >= September 27, 2023 AND game_mode = 'premier' → CS Rating
* 3. If match date >= September 27, 2023 AND game_mode = 'competitive'/'wingman' → Skill Groups
* 4. Fallback: Use heuristic (0-18 = Skill Group, >1000 = CS Rating)
*
* @param match - Match object with date and optional game_mode
* @param rating - The rating value to check (for fallback heuristic)
* @returns The ranking system type
*/
export function getRankingSystem(
match: Pick<Match, 'date' | 'game_mode'>,
rating?: number | null
): RankingSystem {
// Parse match date (could be Unix timestamp or ISO string)
const matchDate =
typeof match.date === 'number' ? new Date(match.date * 1000) : new Date(match.date);
// Legacy CS:GO match (before CS2 launch)
if (matchDate < CS2_LAUNCH_DATE) {
return 'skill_group';
}
// CS2 match - check game mode
if (match.game_mode) {
// Premier Mode always uses CS Rating
if (match.game_mode === 'premier') {
return 'cs_rating';
}
// Competitive and Wingman always use Skill Groups
if (match.game_mode === 'competitive' || match.game_mode === 'wingman') {
return 'skill_group';
}
}
// Fallback heuristic: Check rating value
// Skill Groups are 0-18, CS Rating is typically 1000-30000+
if (rating !== undefined && rating !== null) {
if (rating >= 0 && rating <= 18) {
return 'skill_group';
}
if (rating >= 1000) {
return 'cs_rating';
}
}
return 'unknown';
}
/**
* Check if a match uses the CS Rating system (Premier Mode)
*/
export function usesCsRating(
match: Pick<Match, 'date' | 'game_mode'>,
rating?: number | null
): boolean {
return getRankingSystem(match, rating) === 'cs_rating';
}
/**
* Check if a match uses the Skill Group system (Competitive/Wingman/Legacy CS:GO)
*/
export function usesSkillGroup(
match: Pick<Match, 'date' | 'game_mode'>,
rating?: number | null
): boolean {
return getRankingSystem(match, rating) === 'skill_group';
}
/**
* Check if a match is a legacy CS:GO match (before CS2 launch)
*/
export function isLegacyMatch(match: Pick<Match, 'date'>): boolean {
const matchDate =
typeof match.date === 'number' ? new Date(match.date * 1000) : new Date(match.date);
return matchDate < CS2_LAUNCH_DATE;
}
/**
* Get human-readable description of the ranking system used
*/
export function getRankingSystemDescription(system: RankingSystem): string {
switch (system) {
case 'cs_rating':
return 'CS Rating (Premier Mode)';
case 'skill_group':
return 'Skill Group';
case 'unknown':
return 'Unknown';
}
}

View File

@@ -61,7 +61,7 @@
<div class="flex flex-col gap-2">
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
{#if teamAStats.avgRating}
<PremierRatingBadge rating={teamAStats.avgRating} size="sm" showIcon={true} />
<PremierRatingBadge rating={teamAStats.avgRating} {match} size="sm" showIcon={true} />
{/if}
</div>
<div class="font-mono text-3xl font-bold text-terrorist">{match.score_team_a}</div>
@@ -91,7 +91,7 @@
<div class="flex flex-col gap-2">
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
{#if teamBStats.avgRating}
<PremierRatingBadge rating={teamBStats.avgRating} size="sm" showIcon={true} />
<PremierRatingBadge rating={teamBStats.avgRating} {match} size="sm" showIcon={true} />
{/if}
</div>
<div class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>