Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 91 additions & 109 deletions generated/schema.graphql

Large diffs are not rendered by default.

213 changes: 110 additions & 103 deletions generated/schema.ts

Large diffs are not rendered by default.

22,880 changes: 11,464 additions & 11,416 deletions generated/types.ts

Large diffs are not rendered by default.

30 changes: 27 additions & 3 deletions hasura/functions/match/match_player_elo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ DECLARE
_elo_change INTEGER;
_scale_factor INTEGER := 4000;
_default_elo INTEGER := 5000;
-- How much of the expected score comes from the player's own rating versus
-- their team's average. See _rating_for_expected below.
_individual_weight FLOAT := 0.5;
_rating_for_expected FLOAT;

-- Performance metrics
_player_kills INTEGER;
Expand Down Expand Up @@ -301,14 +305,30 @@ BEGIN

_performance_multiplier := _impact;

-- Calculate the expected score based on team ELO averages
-- The rating this player is judged on. Rating a player purely off their
-- team's average gives every teammate an identical expected score, so a
-- 6000 and a 4600 on the same lineup moved by the same amount no matter how
-- they were rated going in. Rating them purely off their own ELO fixes that
-- but drops any notion of who they played with, which makes carrying a
-- low-rated party member the cheapest way to inflate a rating: at full
-- individual weight a 3000 queuing with four 9000s against a 5000 lineup is
-- scored as if they beat it alone.
--
-- Blending keeps the differentiation and caps the carry. At 0.5, the 3000
-- above is rated 5400 rather than 3000, and teammates on a mixed lineup
-- still separate cleanly. Raising _individual_weight sharpens per-player
-- differences and widens the carry window; lowering it does the reverse.
_rating_for_expected :=
_individual_weight * _current_player_elo
+ (1.0 - _individual_weight) * _player_team_elo_avg;

-- ELO formula: Expected Score = 1 / (1 + 10^((Opponent Rating - Player Rating) / Scale Factor))
-- The scale factor (4000) is increased for a wider ELO range:
-- - A difference of 4000 points means the stronger player is expected to win 10 times more often
-- - A difference of 2000 points means the stronger player is expected to win 3 times more often
-- - A difference of 1000 points means the stronger player is expected to win 1.6 times more often
-- This allows for a much wider range of ratings (0-50,000+) with 28,000 being expert level
_expected_score := 1.0 / (1.0 + POWER(10.0, (_opponent_team_elo_avg - _player_team_elo_avg) / _scale_factor));
_expected_score := 1.0 / (1.0 + POWER(10.0, (_opponent_team_elo_avg - _rating_for_expected) / _scale_factor));

-- Determine the actual score based on match result
-- 1.0 for a win, 0.0 for a loss
Expand All @@ -333,7 +353,9 @@ BEGIN
'elo_change', _elo_change, -- The change in ELO rating for the player after the match
'player_team_elo_avg', _player_team_elo_avg, -- The average ELO rating of the player's team before the match
'opponent_team_elo_avg', _opponent_team_elo_avg, -- The average ELO rating of the opponent's team before the match
'expected_score', _expected_score, -- The expected score for the player's team based on ELO ratings
'rating_for_expected', _rating_for_expected, -- The blend of own rating and team average this player was judged on
'individual_weight', _individual_weight, -- How much of that blend came from the player's own rating
'expected_score', _expected_score, -- The expected score for this player, from rating_for_expected vs the opponent average
'actual_score', _actual_score, -- The actual score for the player's team based on the match result
'k_factor', _k_factor, -- The K-factor used in the calculation
'kills', _player_kills,
Expand Down Expand Up @@ -475,6 +497,7 @@ BEGIN
k_factor,
player_team_elo_avg,
opponent_team_elo_avg,
rating_for_expected,
kills,
deaths,
assists,
Expand All @@ -500,6 +523,7 @@ BEGIN
(elo_data->>'k_factor')::integer,
(elo_data->>'player_team_elo_avg')::double precision,
(elo_data->>'opponent_team_elo_avg')::double precision,
(elo_data->>'rating_for_expected')::double precision,
(elo_data->>'kills')::integer,
(elo_data->>'deaths')::integer,
(elo_data->>'assists')::integer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ select_permissions:
- player_name
- player_steam_id
- player_team_elo_avg
- rating_for_expected
- season_id
- series_multiplier
- team_avg_kda
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.player_elo
DROP COLUMN IF EXISTS rating_for_expected;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.player_elo
ADD COLUMN IF NOT EXISTS rating_for_expected double precision;
1 change: 1 addition & 0 deletions hasura/views/v_player_elo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ SELECT
pe.change::INTEGER AS elo_change,
pe.player_team_elo_avg,
pe.opponent_team_elo_avg,
pe.rating_for_expected,
pe.expected_score,
pe.actual_score,
pe.k_factor,
Expand Down
109 changes: 107 additions & 2 deletions test/elo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ import {
SqlTestDb,
} from "./utils/sql-test-db";

// Mirrors the blend in get_player_elo_for_match. Kept here rather than derived
// so a change to the SQL has to be made deliberately in both places.
const SCALE_FACTOR = 4000;
const INDIVIDUAL_WEIGHT = 0.5;

const expectedScore = (own: number, teamAvg: number, opponentAvg: number) => {
const rating = INDIVIDUAL_WEIGHT * own + (1 - INDIVIDUAL_WEIGHT) * teamAvg;
return 1 / (1 + Math.pow(10, (opponentAvg - rating) / SCALE_FACTOR));
};

// Exercises the ELO engine (generate_player_elo_for_match /
// get_player_elo_for_match): the 5000 baseline, rating chaining across
// matches, series-differential scaling, recompute idempotency, the
// source/winner guards, per-season ladder isolation, the tournament track,
// and the loss-protection transform for strong performers on losing teams.
// the own-rating/team-average blend behind the expected score, and the
// loss-protection transform for strong performers on losing teams.
describe("ELO engine (SQL-driven)", () => {
let db: SqlTestDb;
let postgres: PostgresService;
Expand Down Expand Up @@ -74,6 +85,45 @@ describe("ELO engine (SQL-driven)", () => {
return match;
};

// A finished 2v2, so the two players on a lineup share a team average while
// holding different ratings of their own.
const wingman = async (
teamA: Array<string>,
teamB: Array<string>,
{ winner = "a", endedDaysAgo = 1 }: { winner?: "a" | "b"; endedDaysAgo?: number } = {},
) => {
const match = await fx.match({ type: "Wingman" });
for (const steamId of teamA) {
await fx.lineupPlayer(match.lineup_1_id, steamId);
}
for (const steamId of teamB) {
await fx.lineupPlayer(match.lineup_2_id, steamId);
}
await postgres.query(
`UPDATE matches SET winning_lineup_id = ${
winner === "a" ? "lineup_1_id" : "lineup_2_id"
}, ended_at = now() - make_interval(days => $2) WHERE id = $1`,
[match.id, endedDaysAgo],
);
return match;
};

// Standing ratings for a type, hung off one finished match old enough to be
// picked up as "the rating going in" by the match under test.
const seedRatings = async (
ratings: Record<string, number>,
{ type = "Wingman", daysAgo = 30 }: { type?: string; daysAgo?: number } = {},
) => {
const { matchId } = await fx.bareMatch();
for (const [steamId, current] of Object.entries(ratings)) {
await postgres.query(
`INSERT INTO player_elo (steam_id, match_id, "type", current, change, created_at)
VALUES ($1, $2, $3, $4, 0, now() - make_interval(days => $5))`,
[steamId, matchId, type, current, daysAgo],
);
}
};

const generate = async (matchId: string) => {
const [row] = await postgres.query<
Array<{ generate_player_elo_for_match: number }>
Expand All @@ -87,6 +137,7 @@ describe("ELO engine (SQL-driven)", () => {
change: number;
actual_score: number;
expected_score: number;
rating_for_expected: number | null;
series_multiplier: number;
performance_multiplier: number;
season_id: string | null;
Expand All @@ -95,7 +146,8 @@ describe("ELO engine (SQL-driven)", () => {
const eloRows = (matchId: string) =>
postgres.query<Array<EloRow>>(
`SELECT steam_id, current, change, actual_score, expected_score,
series_multiplier, performance_multiplier, season_id
rating_for_expected, series_multiplier, performance_multiplier,
season_id
FROM player_elo WHERE match_id = $1 ORDER BY steam_id`,
[matchId],
);
Expand Down Expand Up @@ -261,6 +313,59 @@ describe("ELO engine (SQL-driven)", () => {
expect(rows.every((r) => r.season_id === null)).toBe(true);
});

// A 7000 and a 3000 on one lineup average out to the 5000 they are playing,
// so a team-average-only expected score cannot tell them apart.
const mixedLineup = async () => {
const [strong, weak, oppOne, oppTwo] = await fx.players(4);
await seedRatings({
[strong]: 7000,
[weak]: 3000,
[oppOne]: 5000,
[oppTwo]: 5000,
});
const match = await wingman([strong, weak], [oppOne, oppTwo]);
await generate(match.id);
const rows = await eloRows(match.id);
return {
strong: rows.find((r) => r.steam_id === strong)!,
weak: rows.find((r) => r.steam_id === weak)!,
opponent: rows.find((r) => r.steam_id === oppOne)!,
};
};

it("rates teammates off their own ratings, not one shared team average", async () => {
const { strong, weak, opponent } = await mixedLineup();

expect(strong.expected_score).toBeCloseTo(expectedScore(7000, 5000, 5000));
expect(weak.expected_score).toBeCloseTo(expectedScore(3000, 5000, 5000));

// Persisted so the badge can show what each was actually judged on, and so
// the row stays readable if _individual_weight is ever retuned.
expect(Number(strong.rating_for_expected)).toBeCloseTo(6000);
expect(Number(weak.rating_for_expected)).toBeCloseTo(4000);

// The team averages are level, so team-average-only rating would have put
// both of them — and both opponents — at exactly 0.5.
expect(strong.expected_score).toBeGreaterThan(0.5);
expect(weak.expected_score).toBeLessThan(0.5);
expect(opponent.expected_score).toBeCloseTo(0.5);

// Both won, and both took the same (statless) performance multiplier, so
// the gap in change comes purely from what each was expected to do.
expect(Number(strong.change)).toBeGreaterThan(0);
expect(Number(weak.change)).toBeGreaterThan(Number(strong.change));
});

it("keeps the team average in the expected score so carries are not free", async () => {
const { weak } = await mixedLineup();

// Judging the 3000 purely on their own rating would score this as beating
// a 5000 lineup alone. The team term is what stops a low-rated player from
// farming a rating off stronger teammates.
const ownRatingOnly = 1 / (1 + Math.pow(10, (5000 - 3000) / SCALE_FACTOR));
expect(weak.expected_score).toBeGreaterThan(ownRatingOnly);
});

it("protects strong performers on losing teams", async () => {
// Two identical 1v1 losses; in the second, the loser at least got kills
// and damage in. The loss-transform maps better impact to a smaller cut.
Expand Down
Loading