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:
35
research.md
Normal file
35
research.md
Normal file
File diff suppressed because one or more lines are too long
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
|
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
|
||||||
|
import { usesSkillGroup } from '$lib/utils/rankingSystem';
|
||||||
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
|
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
|
||||||
import RankIcon from './RankIcon.svelte';
|
import RankIcon from './RankIcon.svelte';
|
||||||
|
import type { Match } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rating: number | undefined | null;
|
rating: number | undefined | null;
|
||||||
oldRating?: 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';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
showTier?: boolean;
|
showTier?: boolean;
|
||||||
showChange?: boolean;
|
showChange?: boolean;
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
let {
|
let {
|
||||||
rating,
|
rating,
|
||||||
oldRating,
|
oldRating,
|
||||||
|
match,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
showTier = false,
|
showTier = false,
|
||||||
showChange = false,
|
showChange = false,
|
||||||
@@ -23,15 +28,17 @@
|
|||||||
class: className = ''
|
class: className = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Determine if this is a legacy CS:GO match
|
/**
|
||||||
// CS:GO skill groups are 0-18, CS2 ratings are typically 1000-30000
|
* Determine if this rating should be displayed as a Skill Group (0-18)
|
||||||
// For legacy matches, both rating and oldRating will be in the 0-18 range
|
* Uses the new ranking system detection logic based on:
|
||||||
const isLegacyRank = $derived(
|
* 1. Match date (CS:GO legacy vs CS2)
|
||||||
rating !== undefined &&
|
* 2. Game mode (Premier vs Competitive/Wingman)
|
||||||
rating !== null &&
|
* 3. Fallback heuristic (0-18 = Skill Group, >1000 = CS Rating)
|
||||||
rating >= 0 &&
|
*/
|
||||||
rating <= 18 &&
|
const shouldShowSkillGroup = $derived(
|
||||||
(oldRating === undefined || oldRating === null || (oldRating >= 0 && oldRating <= 18))
|
match
|
||||||
|
? usesSkillGroup(match, rating)
|
||||||
|
: rating !== null && rating !== undefined && rating >= 0 && rating <= 18
|
||||||
);
|
);
|
||||||
|
|
||||||
const tierInfo = $derived(formatPremierRating(rating));
|
const tierInfo = $derived(formatPremierRating(rating));
|
||||||
@@ -56,15 +63,14 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isLegacyRank}
|
{#if shouldShowSkillGroup}
|
||||||
<!-- Show CS:GO rank icon for legacy matches -->
|
<!-- Show Skill Group icon (CS:GO legacy OR CS2 Competitive/Wingman mode) -->
|
||||||
<!-- Use rating (rank_new) as it contains the current skill group -->
|
|
||||||
<RankIcon skillGroup={rating} {size} class={className} />
|
<RankIcon skillGroup={rating} {size} class={className} />
|
||||||
{:else if !rating || rating === 0}
|
{:else if !rating || rating === 0}
|
||||||
<!-- No rating available -->
|
<!-- No rating available -->
|
||||||
<span class="text-sm text-base-content/50">Unranked</span>
|
<span class="text-sm text-base-content/50">Unranked</span>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Show Premier rating for CS2 matches -->
|
<!-- Show CS Rating for CS2 Premier mode -->
|
||||||
<div class={classes}>
|
<div class={classes}>
|
||||||
{#if showIcon}
|
{#if showIcon}
|
||||||
<Trophy class={iconSizes[size]} />
|
<Trophy class={iconSizes[size]} />
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const matchPlayerSchema = z.object({
|
|||||||
score: z.number().int().nonnegative(),
|
score: z.number().int().nonnegative(),
|
||||||
kast: z.number().int().min(0).max(100).optional(),
|
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_old: z.number().int().min(0).max(30000).optional(),
|
||||||
rank_new: 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(),
|
vac_present: z.boolean(),
|
||||||
gameban_present: z.boolean(),
|
gameban_present: z.boolean(),
|
||||||
tick_rate: z.number().positive().optional(),
|
tick_rate: z.number().positive().optional(),
|
||||||
|
game_mode: z.enum(['premier', 'competitive', 'wingman']).optional(),
|
||||||
players: z.array(matchPlayerSchema).optional()
|
players: z.array(matchPlayerSchema).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ export interface Match {
|
|||||||
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
/** Server tick rate (64 or 128) - optional, not always provided by API */
|
||||||
tick_rate?: number;
|
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) */
|
/** Array of player statistics (optional, included in detailed match view) */
|
||||||
players?: MatchPlayer[];
|
players?: MatchPlayer[];
|
||||||
}
|
}
|
||||||
@@ -100,7 +109,16 @@ export interface MatchPlayer {
|
|||||||
/** Headshot percentage */
|
/** Headshot percentage */
|
||||||
hs_percent?: number;
|
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_old?: number;
|
||||||
rank_new?: number;
|
rank_new?: number;
|
||||||
|
|
||||||
|
|||||||
126
src/lib/utils/rankingSystem.ts
Normal file
126
src/lib/utils/rankingSystem.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
|
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
|
||||||
{#if teamAStats.avgRating}
|
{#if teamAStats.avgRating}
|
||||||
<PremierRatingBadge rating={teamAStats.avgRating} size="sm" showIcon={true} />
|
<PremierRatingBadge rating={teamAStats.avgRating} {match} size="sm" showIcon={true} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono text-3xl font-bold text-terrorist">{match.score_team_a}</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">
|
<div class="flex flex-col gap-2">
|
||||||
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
|
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
|
||||||
{#if teamBStats.avgRating}
|
{#if teamBStats.avgRating}
|
||||||
<PremierRatingBadge rating={teamBStats.avgRating} size="sm" showIcon={true} />
|
<PremierRatingBadge rating={teamBStats.avgRating} {match} size="sm" showIcon={true} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>
|
<div class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user