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
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,22 @@ LANGUAGE plpgsql
AS $$
DECLARE
source_bracket_id uuid;
source_stage_type text;
BEGIN
SELECT tb.id INTO source_bracket_id
SELECT tb.id, ts.type INTO source_bracket_id, source_stage_type
FROM tournament_brackets tb
JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id
WHERE tb.match_id = _match_id
LIMIT 1;

IF source_bracket_id IS NULL THEN
RETURN;
END IF;

IF source_stage_type NOT IN ('SingleElimination', 'DoubleElimination') THEN
RAISE EXCEPTION 'only elimination stage matches can be reset' USING ERRCODE = '22000';
END IF;

RETURN QUERY
WITH RECURSIVE chain AS (
SELECT source_bracket_id AS id, 0 AS depth
Expand Down
29 changes: 27 additions & 2 deletions hasura/functions/tournaments/reset_tournament_match.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ LANGUAGE plpgsql
AS $$
DECLARE
slot_position int;
target_bracket tournament_brackets%ROWTYPE;
BEGIN
SELECT * INTO target_bracket
FROM tournament_brackets
WHERE id = _target_bracket_id;

-- Same feeder ordering as assign_team_to_bracket_slot, including the
-- WB-before-LB key that disambiguates the grand final's two feeders.
SELECT ranked.pos INTO slot_position
Expand All @@ -25,6 +30,19 @@ BEGIN
) ranked
WHERE ranked.id = _source_bracket_id;

-- Mirror assign_team_to_bracket_slot's bye promotion: when a pruned
-- round-1 bye pushed its seed into the target, the surviving feeder owns
-- the opposite slot — never clear the seed-placed bye team.
IF slot_position IS NOT NULL THEN
IF target_bracket.team_1_seed IS NOT NULL
AND target_bracket.team_2_seed IS NULL THEN
slot_position := 2;
ELSIF target_bracket.team_2_seed IS NOT NULL
AND target_bracket.team_1_seed IS NULL THEN
slot_position := 1;
END IF;
END IF;

IF slot_position = 1 THEN
UPDATE tournament_brackets
SET tournament_team_id_1 = NULL
Expand Down Expand Up @@ -52,6 +70,7 @@ DECLARE
source_match matches%ROWTYPE;
affected_match_id uuid;
source_tournament_id uuid;
source_stage_type text;
feeder record;
BEGIN
IF _reset_status NOT IN ('Scheduled', 'WaitingForCheckIn') THEN
Expand All @@ -68,12 +87,18 @@ BEGIN
RAISE EXCEPTION 'match is not linked to a tournament bracket' USING ERRCODE = '22000';
END IF;

SELECT ts.tournament_id
INTO source_tournament_id
SELECT ts.tournament_id, ts.type
INTO source_tournament_id, source_stage_type
FROM tournament_stages ts
WHERE ts.id = source_bracket.tournament_stage_id
LIMIT 1;

-- Swiss/RoundRobin results drive pool assignment and standings-based
-- advancement that a parent-chain unwind cannot restore.
IF source_stage_type NOT IN ('SingleElimination', 'DoubleElimination') THEN
RAISE EXCEPTION 'only elimination stage matches can be reset' USING ERRCODE = '22000';
END IF;

SELECT *
INTO source_match
FROM matches
Expand Down
61 changes: 58 additions & 3 deletions src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export class ChatService {
}
break;
case ChatLobbyType.Draft: {
if (isRoleAbove(user.role, "match_organizer")) {
break;
}

const { draft_games } = await this.hasuraService.query({
draft_games: {
__args: {
Expand Down Expand Up @@ -267,8 +271,14 @@ export class ChatService {

private async canSendDraftMessage(
id: string,
steamId: string,
player: User,
): Promise<boolean> {
if (isRoleAbove(player.role, "match_organizer")) {
return true;
}

const steamId = player.steam_id;

const { draft_games_by_pk } = await this.hasuraService.query({
draft_games_by_pk: {
__args: { id },
Expand All @@ -278,6 +288,7 @@ export class ChatService {
players: {
steam_id: true,
status: true,
lineup: true,
},
},
});
Expand All @@ -298,10 +309,12 @@ export class ChatService {
return true;
}

// A waitlisted backup moved into a lineup plays in the match but keeps
// their Waitlist status, so lineup membership counts too.
return (draft_games_by_pk.players || []).some(
(draftPlayer) =>
String(draftPlayer.steam_id) === String(steamId) &&
draftPlayer.status === "Accepted",
(draftPlayer.status === "Accepted" || draftPlayer.lineup != null),
);
}

Expand All @@ -321,7 +334,7 @@ export class ChatService {

if (
type === ChatLobbyType.Draft &&
!(await this.canSendDraftMessage(id, player.steam_id))
!(await this.canSendDraftMessage(id, player))
) {
return;
}
Expand Down Expand Up @@ -635,4 +648,46 @@ export class ChatService {
const sessionKeys = await this.redis.keys(`${lobbyKey}:sessions:*`);
await this.redis.del(lobbyKey, ...sessionKeys);
}

public async migrateLobbyMessages(
fromType: ChatLobbyType,
fromId: string,
toType: ChatLobbyType,
toId: string,
) {
const fromKey = `chat_${fromType}_${fromId}`;
const toKey = `chat_${toType}_${toId}`;

const messagesObject = await this.redis.hgetall(fromKey);

for (const [field, message] of Object.entries(messagesObject)) {
await this.redis.hset(toKey, field, message);
await this.redis.sendCommand(
new Redis.Command("HEXPIRE", [
toKey,
this.expiresIn,
"FIELDS",
1,
field,
]),
);
}

await this.redis.del(fromKey);
await this.removeLobby(fromType, fromId);

if (Object.keys(messagesObject).length === 0) {
return;
}

const merged = await this.redis.hgetall(toKey);
const messages = Object.values(merged)
.map((value) => JSON.parse(value))
.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);

void this.to(toType, toId, "messages", { id: toId, messages });
}
}
2 changes: 2 additions & 0 deletions src/draft-games/draft-games.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { loggerFactory } from "../utilities/LoggerFactory";
import { HasuraModule } from "src/hasura/hasura.module";
import { RedisModule } from "src/redis/redis.module";
import { CacheModule } from "src/cache/cache.module";
import { ChatModule } from "src/chat/chat.module";
import { MatchesModule } from "src/matches/matches.module";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { BullBoardModule } from "@bull-board/nestjs";
Expand All @@ -23,6 +24,7 @@ import { CleanExpiredDraftGames } from "./jobs/CleanExpiredDraftGames";
RedisModule,
HasuraModule,
CacheModule,
ChatModule,
forwardRef(() => MatchesModule),
BullModule.registerQueue({
name: DraftGameQueues.DraftGames,
Expand Down
12 changes: 12 additions & 0 deletions src/draft-games/draft-match.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { e_map_pool_types_enum } from "generated";
import { HasuraService } from "src/hasura/hasura.service";
import { CacheService } from "src/cache/cache.service";
import { MatchAssistantService } from "src/matches/match-assistant/match-assistant.service";
import { ChatService } from "src/chat/chat.service";
import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes";
import { DraftGameService } from "./draft-game.service";
import { DraftGame } from "./types/DraftGame";

Expand All @@ -14,6 +16,7 @@ export class DraftMatchService {
public readonly hasura: HasuraService,
public readonly cache: CacheService,
public readonly matchAssistant: MatchAssistantService,
private readonly chat: ChatService,
@Inject(forwardRef(() => DraftGameService))
private readonly draftGameService: DraftGameService,
) {}
Expand Down Expand Up @@ -67,6 +70,15 @@ export class DraftMatchService {

await this.ensureLineups(draftGame, match);

// The room's conversation continues in the match chat: carry the history
// over and tear down the draft lobby so non-participants drop out.
await this.chat.migrateLobbyMessages(
ChatLobbyType.Draft,
draftGameId,
ChatLobbyType.Match,
match.id,
);

const beforeComplete =
await this.draftGameService.getDraftGame(draftGameId);
if (!beforeComplete || beforeComplete.status === "Canceled") {
Expand Down
Loading
Loading