Give every lobby set fresh team chats

This commit is contained in:
Kalle
2026-10-27 21:00:46 +02:00
parent 4215d448b0
commit e22b2c9beb
12 changed files with 267 additions and 73 deletions

View File

@@ -1426,9 +1426,6 @@ export interface Lobby {
roomSize: LobbyRoomSize;
/** Common chat used while pending and by spectators */
chatRoomId: number | null;
/** Team chat of the alpha side of the current set */
alphaChatRoomId: number | null;
bravoChatRoomId: number | null;
/** Marker for the check-in opened notification having been sent */
checkInNotifiedAt: number | null;
/** Since when the started lobby has had no set being played, `null` while one is (or before the start) */
@@ -1470,6 +1467,9 @@ export interface LobbySet {
disputedAt: number | null;
confirmedAt: number | null;
confirmedByUserId: number | null;
/** Team chat of the set's alpha side */
alphaChatRoomId: number | null;
bravoChatRoomId: number | null;
createdAt: Generated<number>;
}

View File

@@ -153,8 +153,8 @@ export async function findAllOpenRoomIdsByUserId(
eb
.case()
.when("LobbySetParticipant.side", "=", "ALPHA")
.then(eb.ref("Lobby.alphaChatRoomId"))
.else(eb.ref("Lobby.bravoChatRoomId"))
.then(eb.ref("LobbySet.alphaChatRoomId"))
.else(eb.ref("LobbySet.bravoChatRoomId"))
.end()
.as("id"),
)
@@ -452,8 +452,8 @@ type ChatRoomOwnerColumn =
| "TournamentTeam.chatRoomId"
| "ScrimPost.chatRoomId"
| "Lobby.chatRoomId"
| "Lobby.alphaChatRoomId"
| "Lobby.bravoChatRoomId";
| "LobbySet.alphaChatRoomId"
| "LobbySet.bravoChatRoomId";
/** The one table that can own a room of each type, and the columns of it that point to the room. */
const OWNER_COLUMNS_BY_ROOM_TYPE = {
@@ -463,7 +463,7 @@ const OWNER_COLUMNS_BY_ROOM_TYPE = {
TOURNAMENT_TEAM: ["TournamentTeam.chatRoomId"],
SCRIM: ["ScrimPost.chatRoomId"],
LOBBY: ["Lobby.chatRoomId"],
LOBBY_TEAM: ["Lobby.alphaChatRoomId", "Lobby.bravoChatRoomId"],
LOBBY_TEAM: ["LobbySet.alphaChatRoomId", "LobbySet.bravoChatRoomId"],
} as const satisfies Record<ChatRoomType, ReadonlyArray<ChatRoomOwnerColumn>>;
function noOwner(

View File

@@ -31,9 +31,6 @@ describe("findAllTeamChatsByChatRoomIds", () => {
);
await LobbyProgression.advance(lobby.id);
const [set] = await LobbyMatchRepository.findAllSetsByLobbyId(lobby.id);
const { alphaChatRoomId } = (await withUserId(hostId(), () =>
LobbyRepository.findById(lobby.id),
))!;
const [leaverId, ...stayingIds] = LobbySet.playersOf(set, "ALPHA");
// a set without a map reported would be called off
await withUserId(leaverId, () =>
@@ -51,11 +48,45 @@ describe("findAllTeamChatsByChatRoomIds", () => {
});
const [teamChat] = await LobbyMatchRepository.findAllTeamChatsByChatRoomIds(
[alphaChatRoomId!],
[set.alphaChatRoomId!],
);
expect(teamChat.userIds).toEqual(expect.arrayContaining(stayingIds));
expect(teamChat.userIds).toHaveLength(stayingIds.length);
});
test("every set has its own team chats, the finished set's inactive", async () => {
const lobby = await LobbyFactory.create(
{
hostUserId: hostId(),
startsAt: dateToDatabaseTimestamp(subMinutes(new Date(), 1)),
},
{
signedUpUserIds: users.ids(8).slice(1),
checkedInUserIds: users.ids(8),
playedSetCount: 1,
},
);
const [previousSet, currentSet] =
await LobbyMatchRepository.findAllSetsByLobbyId(lobby.id);
const teamChats = await LobbyMatchRepository.findAllTeamChatsByChatRoomIds([
previousSet.alphaChatRoomId!,
currentSet.alphaChatRoomId!,
]);
expect(currentSet.alphaChatRoomId).not.toBe(previousSet.alphaChatRoomId);
expect(
teamChats.map(({ chatRoomId, isInactive }) => ({
chatRoomId,
isInactive,
})),
).toEqual(
expect.arrayContaining([
{ chatRoomId: previousSet.alphaChatRoomId, isInactive: true },
{ chatRoomId: currentSet.alphaChatRoomId, isInactive: false },
]),
);
});
});
describe("confirmReadyChecks", () => {

View File

@@ -2,6 +2,7 @@ import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import * as ChatRepository from "~/features/chat/ChatRepository.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
@@ -10,6 +11,7 @@ import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import * as LobbySet from "./core/LobbySet";
import type { LobbySide } from "./lobbies-constants";
import { lobbyChatRoomExpiresAt } from "./lobbies-utils";
/** A set of the lobby with its games and participants, see {@link findAllSetsByLobbyId}. */
export type LobbySetWithGames = Unwrapped<typeof findAllSetsByLobbyId>;
@@ -24,6 +26,8 @@ export function findAllSetsByLobbyId(lobbyId: number) {
"LobbySet.forfeitingSide",
"LobbySet.disputedAt",
"LobbySet.confirmedAt",
"LobbySet.alphaChatRoomId",
"LobbySet.bravoChatRoomId",
"LobbySet.createdAt",
jsonArrayFrom(
eb
@@ -59,32 +63,28 @@ export function findAllSetsByLobbyId(lobbyId: number) {
}
/**
* Lobbies owning the given team chat rooms, with the players of the side the chat room is for in the latest
* set who are still in the room: team chats follow the current set.
* Sets owning the given team chat rooms, with the players of the side the chat room is for who are still in the
* lobby's room. A finished set's team chats are inactive.
*/
export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) {
if (chatRoomIds.length === 0) return [];
const lobbies = await db
.selectFrom("Lobby")
const sets = await db
.selectFrom("LobbySet")
.innerJoin("Lobby", "Lobby.id", "LobbySet.lobbyId")
.select((eb) => [
"Lobby.id",
"Lobby.name",
"Lobby.alphaChatRoomId",
"Lobby.bravoChatRoomId",
"Lobby.id as lobbyId",
"Lobby.name as lobbyName",
"Lobby.endedAt",
"LobbySet.alphaChatRoomId",
"LobbySet.bravoChatRoomId",
"LobbySet.confirmedAt",
"LobbySet.forfeitingSide",
jsonArrayFrom(
eb
.selectFrom("LobbySetParticipant")
.select(["LobbySetParticipant.userId", "LobbySetParticipant.side"])
.where(
"LobbySetParticipant.setId",
"=",
eb
.selectFrom("LobbySet")
.select((innerEb) => innerEb.fn.max("LobbySet.id").as("id"))
.whereRef("LobbySet.lobbyId", "=", "Lobby.id"),
)
.whereRef("LobbySetParticipant.setId", "=", "LobbySet.id")
.where((innerEb) =>
innerEb.exists(
innerEb
@@ -99,30 +99,33 @@ export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) {
.where("LobbyParticipant.status", "=", "IN_ROOM"),
),
),
).as("latestSetParticipants"),
).as("participants"),
])
.where((eb) =>
eb.or([
eb("Lobby.alphaChatRoomId", "in", chatRoomIds),
eb("Lobby.bravoChatRoomId", "in", chatRoomIds),
eb("LobbySet.alphaChatRoomId", "in", chatRoomIds),
eb("LobbySet.bravoChatRoomId", "in", chatRoomIds),
]),
)
.execute();
return lobbies.flatMap((lobby) =>
return sets.flatMap((set) =>
(["ALPHA", "BRAVO"] as const).flatMap((side) => {
const chatRoomId =
side === "ALPHA" ? lobby.alphaChatRoomId : lobby.bravoChatRoomId;
side === "ALPHA" ? set.alphaChatRoomId : set.bravoChatRoomId;
if (chatRoomId === null || !chatRoomIds.includes(chatRoomId)) return [];
return [
{
chatRoomId,
side,
lobbyId: lobby.id,
lobbyName: lobby.name,
isInactive: lobby.endedAt !== null,
userIds: lobby.latestSetParticipants
lobbyId: set.lobbyId,
lobbyName: set.lobbyName,
isInactive:
set.endedAt !== null ||
set.confirmedAt !== null ||
set.forfeitingSide !== null,
userIds: set.participants
.filter((participant) => participant.side === side)
.map((participant) => participant.userId),
},
@@ -132,7 +135,7 @@ export async function findAllTeamChatsByChatRoomIds(chatRoomIds: number[]) {
}
/**
* Starts a set in the room: who plays on which side (the rest sit out) and the maps. Returns the set id.
* Starts a set in the room: who plays on which side (the rest sit out), the maps and fresh team chats. Returns the set id.
*/
export function insertSet({
lobbyId,
@@ -150,9 +153,23 @@ export function insertSet({
sittingOut: number[];
}) {
return db.transaction().execute(async (trx) => {
const lobby = await trx
.selectFrom("Lobby")
.select("Lobby.startsAt")
.where("Lobby.id", "=", lobbyId)
.executeTakeFirstOrThrow();
const [alphaChatRoomId, bravoChatRoomId] = await ChatRepository.insertRooms(
{
type: "LOBBY_TEAM",
expiresAt: lobbyChatRoomExpiresAt(lobby.startsAt),
count: 2,
},
trx,
);
const set = await trx
.insertInto("LobbySet")
.values({ lobbyId, bestOf })
.values({ lobbyId, bestOf, alphaChatRoomId, bravoChatRoomId })
.returning("id")
.executeTakeFirstOrThrow();

View File

@@ -1,4 +1,3 @@
import { addHours } from "date-fns";
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
import { sql } from "kysely";
import { db } from "~/db/sql";
@@ -7,7 +6,7 @@ import type { AssociationVisibility } from "~/features/associations/associations
import * as Visibility from "~/features/associations/core/Visibility";
import { actorIdOrNull } from "~/features/auth/core/user.server";
import * as ChatRepository from "~/features/chat/ChatRepository.server";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { databaseTimestampNow } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import {
commonUserSelect,
@@ -20,10 +19,10 @@ import { toDBBoolean } from "~/utils/sql";
import type { Unwrapped } from "~/utils/types";
import * as LobbyMatchRepository from "./LobbyMatchRepository.server";
import {
CHAT_ROOM_LIFESPAN_HOURS,
CHECK_IN_LEAD_MINUTES,
type LobbyParticipantStatus,
} from "./lobbies-constants";
import { lobbyChatRoomExpiresAt } from "./lobbies-utils";
const INDEX_LIST_LIMIT = 50;
@@ -61,8 +60,6 @@ export async function findById(
"Lobby.modes",
"Lobby.roomSize",
"Lobby.chatRoomId",
"Lobby.alphaChatRoomId",
"Lobby.bravoChatRoomId",
"Lobby.hostUserId",
"Lobby.pendingSince",
"Lobby.endedAt",
@@ -591,7 +588,7 @@ export async function findAllUnendedIdsCheckInOpened() {
type InsertArgs = Omit<
TablesInsertable["Lobby"],
"chatRoomId" | "alphaChatRoomId" | "bravoChatRoomId" | "modes" | "shareKey"
"chatRoomId" | "modes" | "shareKey"
> & {
modes: Tables["Lobby"]["modes"];
/** `LINK_ONLY` = seen only through the share link, by those not participating */
@@ -600,7 +597,7 @@ type InsertArgs = Omit<
hostStatus?: Extract<LobbyParticipantStatus, "REGISTERED" | "CHECKED_IN">;
};
/** Inserts the lobby with its chat rooms and visibility, the host as its first participant. Returns the id. */
/** Inserts the lobby with its chat room and visibility, the host as its first participant. Returns the id. */
export function insert({
visibility,
modes,
@@ -608,16 +605,8 @@ export function insert({
...args
}: InsertArgs) {
return db.transaction().execute(async (trx) => {
const expiresAt = addHours(
databaseTimestampToDate(args.startsAt),
CHAT_ROOM_LIFESPAN_HOURS,
);
const chatRoom = await ChatRepository.insertRoom(
{ type: "LOBBY", expiresAt },
trx,
);
const [alphaChatRoomId, bravoChatRoomId] = await ChatRepository.insertRooms(
{ type: "LOBBY_TEAM", expiresAt, count: 2 },
{ type: "LOBBY", expiresAt: lobbyChatRoomExpiresAt(args.startsAt) },
trx,
);
@@ -627,8 +616,6 @@ export function insert({
...args,
modes: JSON.stringify(modes),
chatRoomId: chatRoom.id,
alphaChatRoomId,
bravoChatRoomId,
shareKey: visibility === "LINK_ONLY" ? shortNanoid() : null,
})
.returning(["id", "createdAt"])
@@ -825,16 +812,20 @@ export function end(id: number) {
const lobby = await trx
.selectFrom("Lobby")
.select([
"Lobby.chatRoomId",
"Lobby.alphaChatRoomId",
"Lobby.bravoChatRoomId",
])
.select("Lobby.chatRoomId")
.where("Lobby.id", "=", id)
.executeTakeFirstOrThrow();
const sets = await trx
.selectFrom("LobbySet")
.select(["LobbySet.alphaChatRoomId", "LobbySet.bravoChatRoomId"])
.where("LobbySet.lobbyId", "=", id)
.execute();
await ChatRepository.updateRoomsInactive(
[lobby.chatRoomId, lobby.alphaChatRoomId, lobby.bravoChatRoomId],
[
lobby.chatRoomId,
...sets.flatMap((set) => [set.alphaChatRoomId, set.bravoChatRoomId]),
],
true,
trx,
);

View File

@@ -81,8 +81,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const viewerTeamChatRoomId = isPlayingUnfinishedSet
? viewerSide === "ALPHA"
? lobby.alphaChatRoomId
: lobby.bravoChatRoomId
? latestSet.alphaChatRoomId
: latestSet.bravoChatRoomId
: null;
const userIds = R.unique([

View File

@@ -1,5 +1,8 @@
import { addHours } from "date-fns";
import { CHANNEL_PREFIX } from "~/features/events/events-types";
import { databaseTimestampToDate } from "~/utils/dates";
import { lobbyPage, SENDOU_INK_BASE_URL } from "~/utils/urls";
import { CHAT_ROOM_LIFESPAN_HOURS } from "./lobbies-constants";
import { lobbySearchParams } from "./lobbies-search-params";
/** Topic the lobby's page revalidates on: every change to its participants and sets. */
@@ -15,3 +18,7 @@ export const lobbyShareLink = ({
shareKey: string;
}) =>
`${SENDOU_INK_BASE_URL}${lobbySearchParams.href(lobbyPage(lobbyId), { key: shareKey })}`;
/** When the lobby's chat rooms, its own and its sets' team chats, expire. */
export const lobbyChatRoomExpiresAt = (startsAt: number) =>
addHours(databaseTimestampToDate(startsAt), CHAT_ROOM_LIFESPAN_HOURS);

View File

@@ -49,8 +49,8 @@ export default function LobbyPage() {
useVoiceFollow({
roomIds: [
data.lobby.chatRoomId,
data.lobby.alphaChatRoomId,
data.lobby.bravoChatRoomId,
data.sets.at(-1)?.alphaChatRoomId ?? null,
data.sets.at(-1)?.bravoChatRoomId ?? null,
].filter((id) => id !== null),
targetRoomId: data.voiceRoomId,
});

View File

@@ -768,8 +768,8 @@ describe("MapList.generate() with history", () => {
return gen.next({ amount }).value;
}
function szHistory(stageIds: StageId[]): ModeWithStage[] {
return stageIds.map((stageId) => ({ mode: "SZ", stageId }));
function szHistory(historyStageIds: StageId[]): ModeWithStage[] {
return historyStageIds.map((stageId) => ({ mode: "SZ", stageId }));
}
test("does not start with the last played mode", () => {

View File

@@ -46,8 +46,8 @@ export function useVoiceRoom(room: {
/**
* Keeps the user's call following `targetRoomId` among related rooms: when the target changes (say the user's
* team for a lobby's next set) a call in any of `roomIds` moves to it. Joining the target in the first place
* stays the user's choice, and so does switching between the rooms while the target holds.
* team for a lobby's next set) a call in any of `roomIds`, or in the previous target, moves to it. Joining the
* target in the first place stays the user's choice, and so does switching between the rooms while the target holds.
*/
export function useVoiceFollow({
roomIds,
@@ -66,7 +66,11 @@ export function useVoiceFollow({
if (targetRoomId === null) return;
void voiceClient.follow({
roomIds: roomIdsKey.split(",").map(Number),
// the previous target may be gone from `roomIds`, like a lobby's previous set's team chat
roomIds: [
...roomIdsKey.split(",").map(Number),
...(followedRoomId === null ? [] : [followedRoomId]),
],
targetRoomId,
});
}, [targetRoomId, followedRoomId, roomIdsKey]);

View File

@@ -1338,7 +1338,8 @@ test.describe("Lobbies", () => {
/**
* A lobby has a chat for everyone in it and one per team, a team's chat reaching only its
* players. The lobby page opens a player's two chats side by side.
* players. Every set starts with fresh team chats while the lobby's chat carries on. The
* lobby page opens a player's two chats side by side.
*/
test.describe("Chat", () => {
test("a team's chat reaches only its players", async ({
@@ -1397,6 +1398,85 @@ test.describe("Lobbies", () => {
await expect(chat.locators.roomRows).toContainText("Lobby · Team");
});
});
test("every set starts with fresh team chats", async ({
page,
factories,
}) => {
const { lobby, players, alphaPlayers, bravoPlayers } = await startedLobby(
page,
factories,
);
const sender = alphaPlayers[0];
const lobbyPage = new LobbyPage(page);
const chat = new ChatSidebar(page);
await test.step("An Alpha player writes to their team and to the lobby in the first set", async () => {
await impersonate(page, sender.id);
await lobbyPage.goto(lobby.id);
await chat.selectRoomTab("Team");
await chat.chat().send("first set plan");
await expect(chat.chat().message("first set plan")).toBeVisible();
await chat.selectRoomTab("Lobby");
await chat.chat().send("hello lobby");
await expect(chat.chat().message("hello lobby")).toBeVisible();
});
await playSet(page, {
lobbyId: lobby.id,
alphaPlayer: sender,
bravoPlayer: bravoPlayers[0],
});
await expect(lobbyPage.locators.reportScoreButton).toBeVisible();
const nextAlphaIds = await rosterIds(lobbyPage, "Alpha", players);
const senderTeamIds = nextAlphaIds.includes(sender.id)
? nextAlphaIds
: idsOf(players).filter((id) => !nextAlphaIds.includes(id));
const newTeammate = players.find(
(player) =>
player.id !== sender.id && senderTeamIds.includes(player.id),
)!;
// the teams were reshuffled, so someone of the first set's team now plays against the sender
const formerTeammate = alphaPlayers.find(
(player) => !senderTeamIds.includes(player.id),
)!;
await test.step("The sender's team chat of the next set starts empty", async () => {
await impersonate(page, sender.id);
await lobbyPage.goto(lobby.id);
await chat.selectRoomTab("Team");
await chat.chat().send("second set plan");
await expect(chat.chat().message("second set plan")).toBeVisible();
await expect(chat.chat().message("first set plan")).toHaveCount(0);
await chat.selectRoomTab("Lobby");
await expect(chat.chat().message("hello lobby")).toBeVisible();
});
await test.step("Their new teammate reads only the next set's message", async () => {
await impersonate(page, newTeammate.id);
await lobbyPage.goto(lobby.id);
await chat.selectRoomTab("Team");
await expect(chat.chat().message("second set plan")).toBeVisible();
await expect(chat.chat().message("first set plan")).toHaveCount(0);
});
await test.step("A former teammate now on the other team reads neither", async () => {
await impersonate(page, formerTeammate.id);
await lobbyPage.goto(lobby.id);
await chat.selectRoomTab("Team");
await chat.chat().send("other team plan");
await expect(chat.chat().message("other team plan")).toBeVisible();
await expect(chat.chat().message("second set plan")).toHaveCount(0);
await expect(chat.chat().message("first set plan")).toHaveCount(0);
});
});
});
/**

View File

@@ -0,0 +1,64 @@
import { type Kysely, sql } from "kysely";
/** Moves the lobby team chat rooms from `Lobby` to `LobbySet`, so every set starts with fresh team chats. */
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("LobbySet")
.addColumn("alphaChatRoomId", "integer", (col) =>
col.references("ChatRoom.id").onDelete("set null"),
)
.execute();
await trx.schema
.alterTable("LobbySet")
.addColumn("bravoChatRoomId", "integer", (col) =>
col.references("ChatRoom.id").onDelete("set null"),
)
.execute();
await sql`
update "LobbySet"
set
"alphaChatRoomId" = (select "alphaChatRoomId" from "Lobby" where "Lobby"."id" = "LobbySet"."lobbyId"),
"bravoChatRoomId" = (select "bravoChatRoomId" from "Lobby" where "Lobby"."id" = "LobbySet"."lobbyId")
where "LobbySet"."id" = (
select max("Latest"."id") from "LobbySet" as "Latest" where "Latest"."lobbyId" = "LobbySet"."lobbyId"
)
`.execute(trx);
for (const column of ["alphaChatRoomId", "bravoChatRoomId"]) {
await sql
.raw(`drop trigger "lobby_${snakeCase(column)}_deletes_chat_room"`)
.execute(trx);
await trx.schema.dropIndex(`lobby_${snakeCase(column)}`).execute();
await trx.schema.alterTable("Lobby").dropColumn(column).execute();
await trx.schema
.createIndex(`lobby_set_${snakeCase(column)}`)
.on("LobbySet")
.column(column)
.unique()
.where(sql.ref(column), "is not", null)
.execute();
await sql
.raw(
`create trigger "lobby_set_${snakeCase(column)}_deletes_chat_room" after delete on "LobbySet"
when old."${column}" is not null
begin delete from "ChatRoom" where "id" = old."${column}"; end`,
)
.execute(trx);
}
// team rooms of lobbies that never got to a set
await sql`
delete from "ChatRoom"
where "type" = 'LOBBY_TEAM'
and not exists (select 1 from "LobbySet" where "alphaChatRoomId" = "ChatRoom"."id")
and not exists (select 1 from "LobbySet" where "bravoChatRoomId" = "ChatRoom"."id")
`.execute(trx);
});
}
function snakeCase(value: string) {
return value.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
}