diff --git a/hasura/functions/demos/persist_imported_demo.sql b/hasura/functions/demos/persist_imported_demo.sql index c94a47e2..746e7931 100644 --- a/hasura/functions/demos/persist_imported_demo.sql +++ b/hasura/functions/demos/persist_imported_demo.sql @@ -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, @@ -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 ( @@ -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 diff --git a/src/steam-match-history/match-import.service.spec.ts b/src/steam-match-history/match-import.service.spec.ts index 0353d563..d8300ebb 100644 --- a/src/steam-match-history/match-import.service.spec.ts +++ b/src/steam-match-history/match-import.service.spec.ts @@ -16,6 +16,8 @@ const detectMatchType = ( player_count?: number; max_rounds?: number; game_mode?: number; + map_name?: string; + server_name?: string; } = {}, ) => detectMatchTypeRaw({ players, ...rules }); @@ -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", () => { diff --git a/src/steam-match-history/match-import.service.ts b/src/steam-match-history/match-import.service.ts index 98e24d1a..099d6216 100644 --- a/src/steam-match-history/match-import.service.ts +++ b/src/steam-match-history/match-import.service.ts @@ -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)) { @@ -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; } @@ -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 }, diff --git a/src/steam-presence/presence.spec.ts b/src/steam-presence/presence.spec.ts index d06a06c6..34d6ba13 100644 --- a/src/steam-presence/presence.spec.ts +++ b/src/steam-presence/presence.spec.ts @@ -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", diff --git a/src/steam-presence/presence.ts b/src/steam-presence/presence.ts index 89448908..fbf52cca 100644 --- a/src/steam-presence/presence.ts +++ b/src/steam-presence/presence.ts @@ -5,6 +5,7 @@ export const CS2_APP_ID = 730; const MATCHMAKING_MODES = new Set([ "competitive", "premier", + "rush", "scrimcomp2v2", // wingman "scrimcomp5v5", "wingman", @@ -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, diff --git a/test/demo-import-kills.spec.ts b/test/demo-import-kills.spec.ts index b8f63701..65d67a8f 100644 --- a/test/demo-import-kills.spec.ts +++ b/test/demo-import-kills.spec.ts @@ -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>( + `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>( + "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>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + const [l2] = await postgres.query>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + const [match] = await postgres.query>( + `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>( + `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 }>