Skip to content
Open
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
64 changes: 62 additions & 2 deletions hasura/functions/match/match_player_elo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,60 @@ BEGIN
END;
$$ LANGUAGE plpgsql STABLE;

-- A lineup carries its substitutes whether or not they ever joined the server,
-- so lineup membership alone cannot tell who played. Anything the game server
-- recorded against a player in this match can.
CREATE OR REPLACE FUNCTION public.player_has_match_activity(
_match_id UUID,
_steam_id BIGINT
) RETURNS BOOLEAN AS $$
SELECT EXISTS (SELECT 1 FROM player_kills WHERE match_id = _match_id AND attacker_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_kills WHERE match_id = _match_id AND attacked_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_damages WHERE match_id = _match_id AND attacker_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_damages WHERE match_id = _match_id AND attacked_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_assists WHERE match_id = _match_id AND attacker_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_flashes WHERE match_id = _match_id AND attacker_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_utility WHERE match_id = _match_id AND attacker_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_objectives WHERE match_id = _match_id AND player_steam_id = _steam_id)
OR EXISTS (SELECT 1 FROM player_unused_utility WHERE match_id = _match_id AND player_steam_id = _steam_id);
$$ LANGUAGE sql STABLE;

-- The players a match is rated over: everyone who played, plus anyone who
-- abandoned it so the leaver penalty still lands on a player who never
-- connected. A lineup with no recorded activity at all (a no-show forfeit, a
-- match whose events never arrived) keeps every member, since there is nothing
-- to tell its players apart by.
CREATE OR REPLACE FUNCTION public.match_elo_participants(
_match_id UUID
) RETURNS TABLE (steam_id BIGINT, match_lineup_id UUID) AS $$
WITH lineup_players AS (
SELECT
mlp.steam_id AS player_steam_id,
mlp.match_lineup_id AS lineup_id,
public.player_has_match_activity(_match_id, mlp.steam_id) AS played
FROM matches m
JOIN match_lineup_players mlp
ON mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id)
WHERE m.id = _match_id
AND mlp.steam_id IS NOT NULL
)
SELECT DISTINCT lp.player_steam_id, lp.lineup_id
FROM lineup_players lp
WHERE lp.played
OR EXISTS (
SELECT 1
FROM abandoned_matches am
WHERE am.match_id = _match_id
AND am.steam_id = lp.player_steam_id
)
OR NOT EXISTS (
SELECT 1
FROM lineup_players teammate
WHERE teammate.lineup_id = lp.lineup_id
AND teammate.played
);
$$ LANGUAGE sql STABLE;

CREATE OR REPLACE FUNCTION get_player_elo_for_match(
match_record public.matches,
player_record public.players,
Expand Down Expand Up @@ -62,9 +116,14 @@ DECLARE
_player_map_wins INT := 0;
_player_map_losses INT := 0;
_series_multiplier INT := 1;

_participants BIGINT[];
BEGIN
SELECT "type" INTO match_type FROM match_options WHERE id = match_record.match_options_id;

SELECT array_agg(mep.steam_id) INTO _participants
FROM public.match_elo_participants(match_record.id) mep;

_seasons_enabled := seasons_enabled();

-- Get the player's current ELO value from the most recent record
Expand Down Expand Up @@ -187,6 +246,7 @@ BEGIN
match_lineup_players mlp
WHERE
mlp.match_lineup_id = _player_lineup_id
AND (mlp.steam_id IS NULL OR mlp.steam_id = ANY(_participants))
GROUP BY
mlp.steam_id
) AS team_elos;
Expand Down Expand Up @@ -241,6 +301,7 @@ BEGIN
match_lineup_players mlp
WHERE
mlp.match_lineup_id = _opponent_lineup_id
AND (mlp.steam_id IS NULL OR mlp.steam_id = ANY(_participants))
GROUP BY
mlp.steam_id
) AS team_elos;
Expand Down Expand Up @@ -482,8 +543,7 @@ BEGIN
FOR player_record IN
SELECT DISTINCT p.*
FROM players p
JOIN match_lineup_players mlp ON p.steam_id = mlp.steam_id
WHERE mlp.match_lineup_id IN (match_record.lineup_1_id, match_record.lineup_2_id)
JOIN public.match_elo_participants(_match_id) mep ON p.steam_id = mep.steam_id
LOOP
-- Calculate ELO change for this player in this match
elo_data := get_player_elo_for_match(match_record, player_record, _season_id, _is_tournament);
Expand Down
17 changes: 14 additions & 3 deletions src/matches/game-streamer/game-streamer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1865,7 +1865,9 @@ export class GameStreamerService {
}
}

public async stopLiveIfRunning(matchId: string) {
public async getLiveStreamMode(
matchId: string,
): Promise<"live" | "tv" | null> {
const { match_streams } = await this.hasura.query({
match_streams: {
__args: {
Expand All @@ -1875,15 +1877,24 @@ export class GameStreamerService {
},
limit: 1,
},
id: true,
mode: true,
},
});

if (!match_streams?.length) {
return;
return null;
}

return match_streams[0].mode === "tv" ? "tv" : "live";
}

public async stopLiveIfRunning(matchId: string): Promise<boolean> {
if (!(await this.getLiveStreamMode(matchId))) {
return false;
}

await this.stopLive(matchId);
return true;
}

public async switchLive(
Expand Down
30 changes: 30 additions & 0 deletions src/matches/jobs/CancelExpiredMatches.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,36 @@ describe("CancelExpiredMatches", () => {
expect(penalised).not.toContain("showed-up");
});

it("does not penalise the benched substitute of a side that showed up in full", async () => {
tournamentMatches = [
expiredTournamentMatch({
is_tournament_match: false,
options: { match_mode: "auto", type: "Wingman" },
lineup_1: {
id: "lineup-1",
is_ready: true,
lineup_players: [
{ steam_id: "starter-a", is_connected: true },
{ steam_id: "starter-b", is_connected: true },
{ steam_id: "benched-sub", is_connected: false },
],
},
lineup_2: {
id: "lineup-2",
is_ready: false,
lineup_players: [
{ steam_id: "showed-up", is_connected: true },
{ steam_id: "no-show", is_connected: false },
],
},
}),
];

await job.process();

expect(abandonedFor()).toEqual(["no-show"]);
});

it("penalises nobody when no server was ever assigned", async () => {
tournamentMatches = [
expiredTournamentMatch({
Expand Down
23 changes: 17 additions & 6 deletions src/matches/jobs/CancelExpiredMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AppConfig } from "../../configs/types/AppConfig";
import { RconService } from "../../rcon/rcon.service";
import { DISCORD_COLORS } from "../../notifications/utilities/constants";
import { MatchAssistantService } from "../match-assistant/match-assistant.service";
import { ExpectedPlayers } from "../../discord-bot/enums/ExpectedPlayers";

@UseQueue("Matches", MatchQueues.ScheduledMatches)
export class CancelExpiredMatches extends WorkerHost {
Expand Down Expand Up @@ -310,12 +311,21 @@ export class CancelExpiredMatches extends WorkerHost {
return;
}

const noShows = [
...(match.lineup_1.lineup_players ?? []),
...(match.lineup_2.lineup_players ?? []),
].filter(
(lineupPlayer) => lineupPlayer.steam_id && !lineupPlayer.is_connected,
);
// A side that had a full team connected never needed its substitutes, so
// a benched sub who stayed away did not no-show.
const fullSide = ExpectedPlayers[match.options.type] / 2;
const noShows = [match.lineup_1, match.lineup_2].flatMap((lineup) => {
const lineupPlayers = lineup.lineup_players ?? [];
const connected = lineupPlayers.filter(
(lineupPlayer) => lineupPlayer.is_connected,
).length;
if (connected >= fullSide) {
return [];
}
return lineupPlayers.filter(
(lineupPlayer) => lineupPlayer.steam_id && !lineupPlayer.is_connected,
);
});

if (noShows.length === 0) {
return;
Expand Down Expand Up @@ -529,6 +539,7 @@ export class CancelExpiredMatches extends WorkerHost {
winning_lineup_id: true,
},
options: {
type: true,
match_mode: true,
},
lineup_1: {
Expand Down
79 changes: 79 additions & 0 deletions src/matches/jobs/StopMatchBroadcast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
jest.mock("@kubernetes/client-node", () => ({
BatchV1Api: class BatchV1Api {},
CoreV1Api: class CoreV1Api {},
KubeConfig: class KubeConfig {},
Exec: class Exec {},
}));

import { StopMatchBroadcast } from "./StopMatchBroadcast";

describe("StopMatchBroadcast", () => {
let matchAssistant: { hasMatchEnded: jest.Mock };
let gameStreamer: Record<string, jest.Mock>;
let matchRelay: { removeBroadcast: jest.Mock };
let clips: { resumeAllPausedBatches: jest.Mock };
let job: StopMatchBroadcast;

beforeEach(() => {
matchAssistant = { hasMatchEnded: jest.fn(async () => true) };
gameStreamer = {
stopLiveIfRunning: jest.fn(async () => true),
promotePendingLiveStreams: jest.fn(async () => ({
promoted: [] as string[],
})),
};
matchRelay = { removeBroadcast: jest.fn() };
clips = { resumeAllPausedBatches: jest.fn() };
job = new StopMatchBroadcast(
{ log: jest.fn() } as any,
matchAssistant as any,
gameStreamer as any,
matchRelay as any,
clips as any,
);
});

const run = () => job.process({ data: { matchId: "match-1" } } as any);

it("stops the stream and drops the relay broadcast once the match is over", async () => {
await run();

expect(matchRelay.removeBroadcast).toHaveBeenCalledWith("match-1");
expect(gameStreamer.stopLiveIfRunning).toHaveBeenCalledWith("match-1");
});

it("hands the freed GPU to a waiting stream first", async () => {
gameStreamer.promotePendingLiveStreams.mockResolvedValue({
promoted: ["match-2"],
});

await run();

expect(gameStreamer.promotePendingLiveStreams).toHaveBeenCalled();
expect(clips.resumeAllPausedBatches).not.toHaveBeenCalled();
});

it("hands the freed GPU to paused renders when no stream is waiting", async () => {
await run();

expect(clips.resumeAllPausedBatches).toHaveBeenCalled();
});

it("frees nothing when the stream was already gone", async () => {
gameStreamer.stopLiveIfRunning.mockResolvedValue(false);

await run();

expect(gameStreamer.promotePendingLiveStreams).not.toHaveBeenCalled();
expect(clips.resumeAllPausedBatches).not.toHaveBeenCalled();
});

it("leaves a match that was started again during the delay alone", async () => {
matchAssistant.hasMatchEnded.mockResolvedValue(false);

await run();

expect(matchRelay.removeBroadcast).not.toHaveBeenCalled();
expect(gameStreamer.stopLiveIfRunning).not.toHaveBeenCalled();
});
});
44 changes: 44 additions & 0 deletions src/matches/jobs/StopMatchBroadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Job } from "bullmq";
import { Logger } from "@nestjs/common";
import { WorkerHost } from "@nestjs/bullmq";
import { UseQueue } from "../../utilities/QueueProcessors";
import { MatchQueues } from "../enums/MatchQueues";
import { MatchAssistantService } from "../match-assistant/match-assistant.service";
import { GameStreamerService } from "../game-streamer/game-streamer.service";
import { MatchRelayService } from "../match-relay/match-relay.service";
import { ClipsService } from "../clips/clips.service";

@UseQueue("Matches", MatchQueues.ScheduledMatches)
export class StopMatchBroadcast extends WorkerHost {
constructor(
private readonly logger: Logger,
private readonly matchAssistant: MatchAssistantService,
private readonly gameStreamer: GameStreamerService,
private readonly matchRelay: MatchRelayService,
private readonly clips: ClipsService,
) {
super();
}

async process(job: Job<{ matchId: string }>): Promise<void> {
const { matchId } = job.data;

if (!(await this.matchAssistant.hasMatchEnded(matchId))) {
this.logger.log(
`[${matchId}] match was started again, leaving its broadcast running`,
);
return;
}

this.matchRelay.removeBroadcast(matchId);

if (!(await this.gameStreamer.stopLiveIfRunning(matchId))) {
return;
}

const { promoted } = await this.gameStreamer.promotePendingLiveStreams();
if (promoted.length === 0) {
await this.clips.resumeAllPausedBatches();
}
}
}
2 changes: 1 addition & 1 deletion src/matches/match-assistant/match-assistant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,7 +1399,7 @@ export class MatchAssistantService {
});
}

private async hasMatchEnded(matchId: string): Promise<boolean> {
public async hasMatchEnded(matchId: string): Promise<boolean> {
const { matches_by_pk } = await this.hasura.query({
matches_by_pk: {
__args: {
Expand Down
11 changes: 11 additions & 0 deletions src/matches/match-relay/match-relay.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ describe("MatchRelayService", () => {

// CS2 sends tps as a decimal (64.0), not an integer, so the coercion has to
// accept a fractional part or clients get tps back as a string.
it("reports how long clients keep playing once the server stops posting", async () => {
await startBroadcastAt(42);

// Clients sit 7 fragments behind the newest and still have that one to play.
expect(service.playoutSeconds(matchId)).toBe(8 * 3);
});

it("has nothing to play out for a broadcast it does not hold", () => {
expect(service.playoutSeconds(matchId)).toBe(0);
});

it("reports a fractional tps as a number", async () => {
await post("start", 42, {
tick: "100",
Expand Down
Loading
Loading