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
4 changes: 4 additions & 0 deletions hasura/functions/demos/persist_imported_demo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ BEGIN
-- Per-match rank history: Wingman (6), Competitive (7/12), Premier (11).
-- previous_rank is the player's prior rank of the same type (and map, for
-- the per-map skill groups) so the per-match delta is exact.
-- Rush has no Valve skill group, so any rank on its scoreboard belongs to
-- another ladder and must not land on this match.
WITH ranked_players AS (
SELECT
(elem->>'steam_id')::bigint AS steam_id,
Expand All @@ -488,6 +490,7 @@ BEGIN
CASE WHEN (elem->>'rank_type')::int = 11 THEN NULL ELSE v_map_id END AS map_id
FROM jsonb_array_elements(COALESCE(p_parsed->'players', '[]'::jsonb)) elem
WHERE (elem->>'rank_type')::int IN (6, 7, 11, 12)
AND v_type IS DISTINCT FROM 'Rush'
AND COALESCE((elem->>'rank')::int, 0) > 0
AND elem->>'steam_id' IS NOT NULL
AND EXISTS (
Expand Down Expand Up @@ -536,6 +539,7 @@ BEGIN
(elem->>'rank')::int AS rank
FROM jsonb_array_elements(COALESCE(p_parsed->'players', '[]'::jsonb)) elem
WHERE (elem->>'rank_type')::int = 11
AND v_type IS DISTINCT FROM 'Rush'
AND COALESCE((elem->>'rank')::int, 0) > 0
AND elem->>'steam_id' IS NOT NULL
) pp
Expand Down
25 changes: 25 additions & 0 deletions src/steam-match-history/match-import.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const detectMatchType = (
player_count?: number;
max_rounds?: number;
game_mode?: number;
map_name?: string;
server_name?: string;
} = {},
) => detectMatchTypeRaw({ players, ...rules });

Expand Down Expand Up @@ -114,6 +116,29 @@ describe("MatchImportService.detectMatchType", () => {
detectMatchType(six, { player_count: 6, game_mode: 6, max_rounds: 15 }),
).toBe("Rush");
});

it("classifies a rush_ map as Rush when the demo carries no game_mode", () => {
// Rush has no skill group, so the scoreboard shows another ladder.
const six = Array.from({ length: 6 }, (_, i) => ({
steam_id: String(i),
name: "x",
rank_type: 11,
}));
expect(
detectMatchType(six, { player_count: 6, map_name: "rush_001" }),
).toBe("Rush");
});

it("does not treat the cs_rush hostage map as Rush", () => {
const five = Array.from({ length: 5 }, (_, i) => ({
steam_id: String(i),
name: "x",
rank_type: 7,
}));
expect(
detectMatchType(five, { player_count: 10, map_name: "cs_rush" }),
).toBe("Competitive");
});
});

describe("MatchImportService.computeStartingSides", () => {
Expand Down
21 changes: 17 additions & 4 deletions src/steam-match-history/match-import.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,12 @@ export class MatchImportService {
}

private static detectMatchType(parsed: ParsedDemo): MatchType {
if (MatchImportService.isFaceitServer(parsed.server_name)) {
return "Competitive";
if (MatchImportService.isRush(parsed)) {
return "Rush";
}

if (parsed.game_mode === 6) {
return "Rush";
if (MatchImportService.isFaceitServer(parsed.server_name)) {
return "Competitive";
}

if (MatchImportService.hasWingmanGameRules(parsed)) {
Expand Down Expand Up @@ -505,6 +505,12 @@ export class MatchImportService {
return "Competitive";
}

// Rush has no skill group, so rank_type never identifies it. cs_rush is an
// unrelated hostage map, hence the anchored prefix.
private static isRush(parsed: ParsedDemo): boolean {
return parsed.game_mode === 6 || /^rush_/i.test(parsed.map_name ?? "");
}

private static hasWingmanGameRules(parsed: ParsedDemo): boolean {
return parsed.game_mode === 2 || parsed.max_rounds === 16;
}
Expand Down Expand Up @@ -1091,6 +1097,13 @@ export class MatchImportService {
if (createdAt) {
object.created_at = createdAt;
}
// tai_match materializes a single-map seed pool (Rush) the moment the
// match is inserted, and check_match_map_count would then reject the
// demo's map as one too many for best_of 1.
await this.postgres.query(
`DELETE FROM public.match_maps WHERE match_id = $1::uuid`,
[matchId],
);
const { insert_match_maps_one } = await this.hasura.mutation({
insert_match_maps_one: {
__args: { object: object as never },
Expand Down
13 changes: 13 additions & 0 deletions src/steam-presence/presence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ describe("parseCs2Presence", () => {
expect(state.map).toBe("de_dust2");
});

it("detects an active rush match", () => {
const state = parseCs2Presence({
gameid: "730",
richPresence: {
"game:state": "game",
"game:mode": "rush",
"game:map": "mg_rush_001",
},
});
expect(state.inMatch).toBe(true);
expect(state.map).toBe("rush_001");
});

it("treats casual / deathmatch as in-game but not a match we import", () => {
const casual = parseCs2Presence({
gameid: "730",
Expand Down
3 changes: 2 additions & 1 deletion src/steam-presence/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const CS2_APP_ID = 730;
const MATCHMAKING_MODES = new Set([
"competitive",
"premier",
"rush",
"scrimcomp2v2", // wingman
"scrimcomp5v5",
"wingman",
Expand All @@ -16,7 +17,7 @@ export type Cs2PresenceState = {
// Player is in ANY active game (deathmatch, casual, custom/5stack, MM, …) as
// opposed to the main menu / lobby. Drives the "what the bot sees" display.
inGame: boolean;
// Player is in an active matchmaking match we can import (comp/premier/wingman).
// Player is in an active matchmaking match we can import (comp/premier/wingman/rush).
// Narrower than inGame — this is what triggers the history poll.
inMatch: boolean;
// Raw `game:mode` (e.g. competitive, premier, scrimcomp2v2, deathmatch, casual,
Expand Down
94 changes: 94 additions & 0 deletions test/demo-import-kills.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,100 @@ describe("persist_imported_demo kill ingestion", () => {
expect(await killRows(ctx.mapId)).toHaveLength(0);
});

describe("Valve ranks on a Rush import", () => {
const importWithRanks = async (type: string) => {
const { matchId } = await fx.bareMatch();
const optionsId = await fx.matchOptions({ type, mr: 8 });
await postgres.query(
"UPDATE matches SET match_options_id = $1 WHERE id = $2",
[optionsId, matchId],
);
// A single-map seed pool (Rush) re-materializes the match's maps.
const [map] = await postgres.query<Array<{ id: string }>>(
`SELECT id FROM match_maps WHERE match_id = $1 ORDER BY "order" LIMIT 1`,
[matchId],
);
const ctx = { matchId, mapId: map.id };
const [player] = await fx.players(1);
await importDemo(await demoFor(ctx), {
map_name: "rush_001",
tick_rate: 64,
total_ticks: 2000,
round_ticks: [{ round: 1, start_tick: 0, end_tick: 2000 }],
players: [
{
steam_id: player,
name: `p-${player}`,
rank: 15000,
rank_type: 11,
previous_rank: 14800,
},
],
kills: [],
});

const history = await postgres.query<Array<{ rank: number }>>(
"SELECT rank FROM player_premier_rank_history WHERE match_id = $1",
[ctx.matchId],
);
const [row] = await postgres.query<
Array<{ premier_rank: number | null }>
>("SELECT premier_rank FROM players WHERE steam_id = $1", [player]);
return { history, premierRank: row.premier_rank };
};

// Rush has no skill group; the scoreboard's Premier rating is not this
// match's rank.
it("records no rank history and leaves premier_rank alone", async () => {
const { history, premierRank } = await importWithRanks("Rush");
expect(history).toHaveLength(0);
expect(premierRank).toBeNull();
});

it("still records Premier ranks on a non-Rush import", async () => {
const { history, premierRank } = await importWithRanks("Competitive");
expect(history.map((h) => h.rank)).toEqual([15000]);
expect(premierRank).toBe(15000);
});
});

// MatchImportService.insertMatchMap clears these before adding the demo's
// map; without that, every Rush import is rejected as one map too many.
it("materializes a Rush import's map on insert, leaving no room for another", async () => {
const optionsId = await fx.matchOptions({ type: "Rush" });
const [l1] = await postgres.query<Array<{ id: string }>>(
"INSERT INTO match_lineups DEFAULT VALUES RETURNING id",
);
const [l2] = await postgres.query<Array<{ id: string }>>(
"INSERT INTO match_lineups DEFAULT VALUES RETURNING id",
);
const [match] = await postgres.query<Array<{ id: string }>>(
`INSERT INTO matches (source, status, lineup_1_id, lineup_2_id, match_options_id, started_at, ended_at)
VALUES ('valve', 'Finished', $1, $2, $3, now(), now()) RETURNING id`,
[l1.id, l2.id, optionsId],
);
const insertMap = () =>
postgres.query(
`INSERT INTO match_maps (match_id, map_id, "order", status)
SELECT $1, id, 0, 'Finished' FROM maps WHERE name = 'rush_001' AND type = 'Rush'`,
[match.id],
);

await expect(insertMap()).rejects.toThrow(
"Match already has the maximum number of picked maps",
);

await postgres.query("DELETE FROM match_maps WHERE match_id = $1", [
match.id,
]);
await insertMap();
const maps = await postgres.query<Array<{ order: number }>>(
`SELECT "order" FROM match_maps WHERE match_id = $1`,
[match.id],
);
expect(maps.map((m) => m.order)).toEqual([0]);
});

it("never swaps an imported Rush match's sides", async () => {
const sides = await postgres.query<
Array<{ competitive: string; rush: string }>
Expand Down
Loading