Progress incl. create random team names, db rework

This commit is contained in:
Kalle
2026-02-24 20:00:05 +02:00
parent dec684e489
commit f00cd26e0e
17 changed files with 2558 additions and 699 deletions

View File

@@ -40,6 +40,7 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
import { joinTeam } from "~/features/tournament/queries/joinLeaveTeam.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
@@ -283,8 +284,6 @@ function wipeDB() {
"TournamentMatchGameResult",
"TournamentTeamCheckIn",
"TournamentLFGLike",
"TournamentLFGGroupMember",
"TournamentLFGGroup",
"TournamentTeam",
"TournamentStage",
"TournamentResult",
@@ -1647,168 +1646,115 @@ async function tournamentLfgGroups() {
{ tournamentId: 3, teamId: 201 },
];
const insertMember = sql.prepare(
/* sql */ `INSERT INTO "TournamentLFGGroupMember" ("groupId", "tournamentId", "userId", "role") VALUES (@groupId, @tournamentId, @userId, @role)`,
);
const updateNote = sql.prepare(
/* sql */ `UPDATE "TournamentLFGGroupMember" SET "note" = @note WHERE "groupId" = @groupId AND "userId" = @userId`,
);
const insertLike = sql.prepare(
/* sql */ `INSERT INTO "TournamentLFGLike" ("likerGroupId", "targetGroupId") VALUES (@likerGroupId, @targetGroupId)`,
);
const insertTeam = sql.prepare(
/* sql */ `INSERT INTO "TournamentTeam" ("name", "createdAt", "tournamentId", "inviteCode") VALUES (@name, @createdAt, @tournamentId, @inviteCode)`,
);
const insertTeamMember = sql.prepare(
/* sql */ `INSERT INTO "TournamentTeamMember" ("tournamentTeamId", "userId", "isOwner", "createdAt") VALUES (@tournamentTeamId, @userId, @isOwner, @createdAt)`,
);
const updateGroupTeamId = sql.prepare(
/* sql */ `UPDATE "TournamentLFGGroup" SET "tournamentTeamId" = @teamId WHERE "id" = @groupId`,
);
const createTeamForGroup = (
groupId: number,
tournamentId: number,
ownerUserId: number,
memberUserIds: number[],
) => {
const now = dateToDatabaseTimestamp(new Date());
const { lastInsertRowid } = insertTeam.run({
name: validTournamentTeamName(),
createdAt: now,
tournamentId,
inviteCode: shortNanoid(),
});
const teamId = Number(lastInsertRowid);
insertTeamMember.run({
tournamentTeamId: teamId,
userId: ownerUserId,
isOwner: 1,
createdAt: now,
});
for (const userId of memberUserIds) {
insertTeamMember.run({
tournamentTeamId: teamId,
userId,
isOwner: 0,
createdAt: now,
});
}
updateGroupTeamId.run({ teamId, groupId });
};
let userIndex = 0;
for (const { tournamentId, teamId } of tournaments) {
const teamOwner = (
sql
.prepare(
/* sql */ `SELECT "userId" FROM "TournamentTeamMember" WHERE "tournamentTeamId" = @teamId AND "isOwner" = 1`,
)
.get({ teamId }) as { userId: number }
).userId;
const users = availableUsers.slice(userIndex, userIndex + 12);
userIndex += 12;
// Group 1: solo, has note, isStayAsSub=1
const group1 = await TournamentLFGRepository.createGroup({
tournamentId,
userId: users[0],
isStayAsSub: true,
});
updateNote.run({
note: "Looking for a team, can play any role",
groupId: group1.id,
userId: users[0],
});
// Group 2: duo, one member has note
const group2 = await TournamentLFGRepository.createGroup({
tournamentId,
userId: users[1],
});
insertMember.run({
groupId: group2.id,
tournamentId,
userId: users[2],
role: "REGULAR",
});
updateNote.run({
note: "Prefer backline weapons",
groupId: group2.id,
userId: users[2],
});
createTeamForGroup(group2.id, tournamentId, users[1], [users[2]]);
// Group 3: trio
const group3 = await TournamentLFGRepository.createGroup({
tournamentId,
userId: users[3],
});
for (const userId of [users[4], users[5]]) {
insertMember.run({
groupId: group3.id,
// Group 1: solo placeholder, has note, isStayAsSub=1
const { id: team1Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId,
role: "REGULAR",
});
}
createTeamForGroup(group3.id, tournamentId, users[3], [users[4], users[5]]);
// Group 4: full (4 members)
const group4 = await TournamentLFGRepository.createGroup({
tournamentId,
userId: users[6],
userId: users[0],
isStayAsSub: true,
},
);
await TournamentLFGRepository.updateTeamNote({
teamId: team1Id,
value: "Looking for a team, can play any role",
});
for (const userId of [users[7], users[8], users[9]]) {
insertMember.run({
groupId: group4.id,
// Group 2: duo placeholder
const { id: team2Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId,
role: "REGULAR",
});
}
createTeamForGroup(group4.id, tournamentId, users[6], [
users[7],
users[8],
users[9],
]);
// Group 5: solo
const group5 = await TournamentLFGRepository.createGroup({
userId: users[1],
},
);
joinTeam({
newTeamId: team2Id,
userId: users[2],
inGameName: null,
tournamentId,
userId: users[10],
});
// Group 6: duo linked to existing team
const group6 = await TournamentLFGRepository.createGroup({
// Group 3: trio placeholder
const { id: team3Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[3],
},
);
joinTeam({
newTeamId: team3Id,
userId: users[4],
inGameName: null,
tournamentId,
userId: teamOwner,
});
updateGroupTeamId.run({ teamId, groupId: group6.id });
insertMember.run({
groupId: group6.id,
joinTeam({
newTeamId: team3Id,
userId: users[5],
inGameName: null,
tournamentId,
});
// Group 4: full (4 members) placeholder
const { id: team4Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[6],
},
);
joinTeam({
newTeamId: team4Id,
userId: users[7],
inGameName: null,
tournamentId,
});
joinTeam({
newTeamId: team4Id,
userId: users[8],
inGameName: null,
tournamentId,
});
joinTeam({
newTeamId: team4Id,
userId: users[9],
inGameName: null,
tournamentId,
});
// Group 5: solo placeholder
const { id: team5Id } = await TournamentLFGRepository.createPlaceholderTeam(
{
tournamentId,
userId: users[10],
},
);
// Group 6: existing team set to looking
await TournamentLFGRepository.startLooking(teamId);
joinTeam({
newTeamId: teamId,
userId: users[11],
role: "REGULAR",
inGameName: null,
tournamentId,
});
// Group 1 -> Group 2 (one-way like)
insertLike.run({
likerGroupId: group1.id,
targetGroupId: group2.id,
// Team 1 -> Team 2 (one-way like)
await TournamentLFGRepository.addLike({
likerTeamId: team1Id,
targetTeamId: team2Id,
});
// Group 2 -> Group 1 (mutual — tests invitation UI)
insertLike.run({
likerGroupId: group2.id,
targetGroupId: group1.id,
// Team 2 -> Team 1 (mutual — tests invitation UI)
await TournamentLFGRepository.addLike({
likerTeamId: team2Id,
targetTeamId: team1Id,
});
// Group 3 -> Group 5 (one-way like)
insertLike.run({
likerGroupId: group3.id,
targetGroupId: group5.id,
// Team 3 -> Team 5 (one-way like)
await TournamentLFGRepository.addLike({
likerTeamId: team3Id,
targetTeamId: team5Id,
});
}
}

View File

@@ -693,29 +693,9 @@ export interface TournamentSub {
visibility: "+1" | "+2" | "+3" | "ALL";
}
export interface TournamentLFGGroup {
id: GeneratedAlways<number>;
tournamentId: number;
tournamentTeamId: number | null;
visibility: JSONColumnTypeNullable<AssociationVisibility>;
chatCode: string;
createdAt: Generated<number>;
}
// xxx: duplicate state with tournament team member... keeping these up to date might be annoying
export interface TournamentLFGGroupMember {
groupId: number;
tournamentId: number;
userId: number;
role: "OWNER" | "MANAGER" | "REGULAR";
note: string | null;
isStayAsSub: Generated<DBBoolean>;
createdAt: Generated<number>;
}
export interface TournamentLFGLike {
likerGroupId: number;
targetGroupId: number;
likerTeamId: number;
targetTeamId: number;
createdAt: Generated<number>;
}
@@ -739,6 +719,12 @@ export interface TournamentTeam {
tournamentId: number;
teamId: number | null;
avatarImgId: number | null;
isLooking: Generated<DBBoolean>;
isPlaceholder: Generated<DBBoolean>;
lfgVisibility: JSONColumnTypeNullable<AssociationVisibility>;
lfgNote: string | null;
// xxx: do we provide chat at this point? if not then remove
chatCode: string | null;
}
export interface TournamentTeamCheckIn {
@@ -756,6 +742,8 @@ export interface TournamentTeamMember {
inGameName: string | null;
tournamentTeamId: number;
userId: number;
role: Generated<"OWNER" | "MANAGER" | "REGULAR">;
isStayAsSub: Generated<DBBoolean>;
}
export interface TournamentOrganization {
@@ -1232,8 +1220,6 @@ export interface DB {
Tournament: Tournament;
TournamentStaff: TournamentStaff;
TournamentGroup: TournamentGroup;
TournamentLFGGroup: TournamentLFGGroup;
TournamentLFGGroupMember: TournamentLFGGroupMember;
TournamentLFGLike: TournamentLFGLike;
TournamentMatch: TournamentMatch;
TournamentMatchPickBanEvent: TournamentMatchPickBanEvent;

View File

@@ -13,10 +13,10 @@ const createTournament = () =>
.returning("id")
.executeTakeFirstOrThrow();
const createGroup = (tournamentId: number, userId: number) =>
TournamentLFGRepository.createGroup({ tournamentId, userId });
const createPlaceholder = (tournamentId: number, userId: number) =>
TournamentLFGRepository.createPlaceholderTeam({ tournamentId, userId });
describe("createGroup", () => {
describe("createPlaceholderTeam", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
@@ -25,23 +25,23 @@ describe("createGroup", () => {
dbReset();
});
test("creates a group with owner member", async () => {
test("creates a placeholder team with owner member", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe(group.id);
expect(groups[0].id).toBe(team.id);
});
test("owner has OWNER role", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -50,7 +50,7 @@ describe("createGroup", () => {
});
});
describe("findGroupsByTournamentId", () => {
describe("findLookingTeamsByTournamentId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
@@ -59,22 +59,22 @@ describe("findGroupsByTournamentId", () => {
dbReset();
});
test("returns groups for given tournament", async () => {
test("returns looking teams for given tournament", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createGroup(tournament.id, 2);
await createPlaceholder(tournament.id, 1);
await createPlaceholder(tournament.id, 2);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(2);
});
test("returns empty array when no groups exist", async () => {
test("returns empty array when no looking teams exist", async () => {
const tournament = await createTournament();
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
@@ -83,9 +83,9 @@ describe("findGroupsByTournamentId", () => {
test("returns groups with member data", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createPlaceholder(tournament.id, 1);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -95,13 +95,13 @@ describe("findGroupsByTournamentId", () => {
expect(members[0].role).toBe("OWNER");
});
test("does not return groups from other tournaments", async () => {
test("does not return teams from other tournaments", async () => {
const tournament1 = await createTournament();
const tournament2 = await createTournament();
await createGroup(tournament1.id, 1);
await createGroup(tournament2.id, 2);
await createPlaceholder(tournament1.id, 1);
await createPlaceholder(tournament2.id, 2);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament1.id,
);
@@ -118,62 +118,62 @@ describe("addLike / deleteLike", () => {
dbReset();
});
test("adds a like between two groups", async () => {
test("adds a like between two teams", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
expect(likes.given[0].groupId).toBe(group2.id);
expect(likes.given[0].teamId).toBe(team2.id);
});
test("duplicate like does not throw", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
});
test("deleteLike removes the like", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.deleteLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(0);
});
});
describe("allLikesByGroupId", () => {
describe("allLikesByTeamId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
@@ -184,39 +184,39 @@ describe("allLikesByGroupId", () => {
test("separates likes into given and received", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const group3 = await createGroup(tournament.id, 3);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
const team3 = await createPlaceholder(tournament.id, 3);
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group2.id,
likerTeamId: team1.id,
targetTeamId: team2.id,
});
await TournamentLFGRepository.addLike({
likerGroupId: group3.id,
targetGroupId: group1.id,
likerTeamId: team3.id,
targetTeamId: team1.id,
});
const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(1);
expect(likes.given[0].groupId).toBe(group2.id);
expect(likes.given[0].teamId).toBe(team2.id);
expect(likes.received).toHaveLength(1);
expect(likes.received[0].groupId).toBe(group3.id);
expect(likes.received[0].teamId).toBe(team3.id);
});
test("returns empty given/received when no likes", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
const likes = await TournamentLFGRepository.allLikesByGroupId(group.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team.id);
expect(likes.given).toHaveLength(0);
expect(likes.received).toHaveLength(0);
});
});
describe("morphGroups", () => {
describe("mergeTeams", () => {
beforeEach(async () => {
await dbInsertUsers(5);
});
@@ -225,41 +225,39 @@ describe("morphGroups", () => {
dbReset();
});
test("merges two groups, other group is deleted", async () => {
test("merges two teams, other team is deleted", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.morphGroups({
survivingGroupId: group1.id,
otherGroupId: group2.id,
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe(group1.id);
expect(groups[0].id).toBe(team1.id);
const members = groups[0].members;
expect(members).toHaveLength(2);
});
test("demotes other group's OWNER to MANAGER", async () => {
test("demotes other team's OWNER to MANAGER", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await TournamentLFGRepository.morphGroups({
survivingGroupId: group1.id,
otherGroupId: group2.id,
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -270,49 +268,47 @@ describe("morphGroups", () => {
test("throws when merged size exceeds maxGroupSize", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
await expect(
TournamentLFGRepository.morphGroups({
survivingGroupId: group1.id,
otherGroupId: group2.id,
TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 1,
tournamentId: tournament.id,
}),
).rejects.toThrow("Group has too many members after merge");
});
test("clears likes on surviving group after merge", async () => {
test("clears likes on surviving team after merge", async () => {
const tournament = await createTournament();
const group1 = await createGroup(tournament.id, 1);
const group2 = await createGroup(tournament.id, 2);
const group3 = await createGroup(tournament.id, 3);
const team1 = await createPlaceholder(tournament.id, 1);
const team2 = await createPlaceholder(tournament.id, 2);
const team3 = await createPlaceholder(tournament.id, 3);
await TournamentLFGRepository.addLike({
likerGroupId: group1.id,
targetGroupId: group3.id,
likerTeamId: team1.id,
targetTeamId: team3.id,
});
await TournamentLFGRepository.addLike({
likerGroupId: group3.id,
targetGroupId: group1.id,
likerTeamId: team3.id,
targetTeamId: team1.id,
});
await TournamentLFGRepository.morphGroups({
survivingGroupId: group1.id,
otherGroupId: group2.id,
await TournamentLFGRepository.mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
tournamentId: tournament.id,
});
const likes = await TournamentLFGRepository.allLikesByGroupId(group1.id);
const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id);
expect(likes.given).toHaveLength(0);
expect(likes.received).toHaveLength(0);
});
});
describe("updateMemberNote", () => {
describe("updateTeamNote", () => {
beforeEach(async () => {
await dbInsertUsers(1);
});
@@ -321,33 +317,29 @@ describe("updateMemberNote", () => {
dbReset();
});
test("sets and clears a member note", async () => {
test("sets and clears a team note", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.updateMemberNote({
groupId: group.id,
userId: 1,
await TournamentLFGRepository.updateTeamNote({
teamId: team.id,
value: "Looking for support",
});
let groups = await TournamentLFGRepository.findGroupsByTournamentId(
let groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
let members = groups[0].members;
expect(members[0].note).toBe("Looking for support");
expect(groups[0].note).toBe("Looking for support");
await TournamentLFGRepository.updateMemberNote({
groupId: group.id,
userId: 1,
await TournamentLFGRepository.updateTeamNote({
teamId: team.id,
value: null,
});
groups = await TournamentLFGRepository.findGroupsByTournamentId(
groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
members = groups[0].members;
expect(members[0].note).toBeNull();
expect(groups[0].note).toBeNull();
});
});
@@ -362,25 +354,25 @@ describe("updateMemberRole", () => {
test("changes role from REGULAR to MANAGER", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: group.id,
tournamentId: tournament.id,
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.updateMemberRole({
userId: 2,
groupId: group.id,
teamId: team.id,
role: "MANAGER",
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -393,7 +385,7 @@ describe("updateMemberRole", () => {
expect(() =>
TournamentLFGRepository.updateMemberRole({
userId: 1,
groupId: 1,
teamId: 1,
role: "OWNER",
}),
).toThrow("Can't set role to OWNER with this function");
@@ -411,10 +403,10 @@ describe("updateStayAsSub", () => {
test("toggles isStayAsSub on/off", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.updateStayAsSub({
groupId: group.id,
teamId: team.id,
userId: 1,
value: true,
});
@@ -425,7 +417,7 @@ describe("updateStayAsSub", () => {
expect(subs).toContain(1);
await TournamentLFGRepository.updateStayAsSub({
groupId: group.id,
teamId: team.id,
userId: 1,
value: false,
});
@@ -435,7 +427,7 @@ describe("updateStayAsSub", () => {
});
});
describe("leaveGroup", () => {
describe("leaveLfg", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
@@ -444,26 +436,26 @@ describe("leaveGroup", () => {
dbReset();
});
test("removes member from group", async () => {
test("removes member from placeholder team", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: group.id,
tournamentId: tournament.id,
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.leaveGroup({
await TournamentLFGRepository.leaveLfg({
userId: 2,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -472,52 +464,52 @@ describe("leaveGroup", () => {
expect(members[0].id).toBe(1);
});
test("deletes group when last member leaves", async () => {
test("deletes placeholder team when last member leaves", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createPlaceholder(tournament.id, 1);
await TournamentLFGRepository.leaveGroup({
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
});
test("promotes MANAGER to OWNER when owner leaves", async () => {
test("promotes MANAGER to OWNER when owner leaves placeholder", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await createPlaceholder(tournament.id, 1);
await db
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: group.id,
tournamentId: tournament.id,
tournamentTeamId: team.id,
userId: 2,
isOwner: 0,
role: "MANAGER",
})
.execute();
await db
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: group.id,
tournamentId: tournament.id,
tournamentTeamId: team.id,
userId: 3,
isOwner: 0,
role: "REGULAR",
})
.execute();
await TournamentLFGRepository.leaveGroup({
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
@@ -526,31 +518,50 @@ describe("leaveGroup", () => {
expect(newOwner?.role).toBe("OWNER");
});
test("promotes first member to OWNER when owner leaves and no manager exists", async () => {
test("sets isLooking=0 for non-placeholder team", async () => {
const tournament = await createTournament();
const group = await createGroup(tournament.id, 1);
const team = await db
.insertInto("TournamentTeam")
.values({
tournamentId: tournament.id,
name: "Real Team",
inviteCode: "abc",
isLooking: 1,
isPlaceholder: 0,
chatCode: "chat123",
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: group.id,
tournamentId: tournament.id,
userId: 2,
role: "REGULAR",
tournamentTeamId: team.id,
userId: 1,
isOwner: 1,
role: "OWNER",
})
.execute();
await TournamentLFGRepository.leaveGroup({
await TournamentLFGRepository.leaveLfg({
userId: 1,
tournamentId: tournament.id,
});
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournament.id,
);
const members = groups[0].members;
expect(members[0].role).toBe("OWNER");
expect(groups).toHaveLength(0);
const teamRow = await db
.selectFrom("TournamentTeam")
.select("isLooking")
.where("id", "=", team.id)
.executeTakeFirstOrThrow();
expect(teamRow.isLooking).toBe(0);
});
});
@@ -565,12 +576,12 @@ describe("getSubsForTournament", () => {
test("returns userIds with isStayAsSub", async () => {
const tournament = await createTournament();
await TournamentLFGRepository.createGroup({
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId: tournament.id,
userId: 1,
isStayAsSub: true,
});
await createGroup(tournament.id, 2);
await createPlaceholder(tournament.id, 2);
const subs = await TournamentLFGRepository.getSubsForTournament(
tournament.id,
@@ -581,7 +592,7 @@ describe("getSubsForTournament", () => {
test("returns empty when nobody opted in", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createPlaceholder(tournament.id, 1);
const subs = await TournamentLFGRepository.getSubsForTournament(
tournament.id,
@@ -590,46 +601,3 @@ describe("getSubsForTournament", () => {
expect(subs).toHaveLength(0);
});
});
describe("cleanupForTournamentStart", () => {
beforeEach(async () => {
await dbInsertUsers(2);
});
afterEach(() => {
dbReset();
});
test("deletes all groups for a tournament", async () => {
const tournament = await createTournament();
await createGroup(tournament.id, 1);
await createGroup(tournament.id, 2);
await TournamentLFGRepository.cleanupForTournamentStart(tournament.id);
const groups = await TournamentLFGRepository.findGroupsByTournamentId(
tournament.id,
);
expect(groups).toHaveLength(0);
});
test("does not affect groups from other tournaments", async () => {
const tournament1 = await createTournament();
const tournament2 = await createTournament();
await createGroup(tournament1.id, 1);
await createGroup(tournament2.id, 2);
await TournamentLFGRepository.cleanupForTournamentStart(tournament1.id);
const groups1 = await TournamentLFGRepository.findGroupsByTournamentId(
tournament1.id,
);
const groups2 = await TournamentLFGRepository.findGroupsByTournamentId(
tournament2.id,
);
expect(groups1).toHaveLength(0);
expect(groups2).toHaveLength(1);
});
});

View File

@@ -9,68 +9,48 @@ import {
userChatNameColorForJson,
} from "~/utils/kysely.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { randomTeamName } from "~/utils/team-name";
type CreateGroupFromTeamArgs = {
tournamentId: number;
tournamentTeamId: number;
members: Array<{ userId: number; isOwner: boolean }>;
};
export function createGroupFromTeam(args: CreateGroupFromTeamArgs) {
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
.insertInto("TournamentLFGGroup")
.values({
tournamentId: args.tournamentId,
tournamentTeamId: args.tournamentTeamId,
chatCode: shortNanoid(),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const member of args.members) {
await trx
.insertInto("TournamentLFGGroupMember")
.values({
groupId: createdGroup.id,
tournamentId: args.tournamentId,
userId: member.userId,
role: member.isOwner ? "OWNER" : "REGULAR",
})
.execute();
}
return createdGroup;
});
export function startLooking(teamId: number) {
return db
.updateTable("TournamentTeam")
.set({ isLooking: 1, chatCode: shortNanoid() })
.where("id", "=", teamId)
.execute();
}
type CreateGroupArgs = {
type CreatePlaceholderTeamArgs = {
tournamentId: number;
userId: number;
isStayAsSub?: boolean;
};
export function createGroup(args: CreateGroupArgs) {
export function createPlaceholderTeam(args: CreatePlaceholderTeamArgs) {
return db.transaction().execute(async (trx) => {
const createdGroup = await trx
.insertInto("TournamentLFGGroup")
const createdTeam = await trx
.insertInto("TournamentTeam")
.values({
tournamentId: args.tournamentId,
name: randomTeamName(),
inviteCode: shortNanoid(),
isPlaceholder: 1,
isLooking: 1,
chatCode: shortNanoid(),
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("TournamentLFGGroupMember")
.insertInto("TournamentTeamMember")
.values({
groupId: createdGroup.id,
tournamentId: args.tournamentId,
tournamentTeamId: createdTeam.id,
userId: args.userId,
isOwner: 1,
role: "OWNER",
isStayAsSub: args.isStayAsSub ? 1 : 0,
})
.execute();
return createdGroup;
return createdTeam;
});
}
@@ -83,39 +63,34 @@ type TournamentLFGMemberObject = {
languages: Tables["User"]["languages"];
vc: Tables["User"]["vc"];
pronouns: Tables["User"]["pronouns"];
role: Tables["TournamentLFGGroupMember"]["role"];
note: Tables["TournamentLFGGroupMember"]["note"];
isStayAsSub: Tables["TournamentLFGGroupMember"]["isStayAsSub"];
role: Tables["TournamentTeamMember"]["role"];
isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"];
weapons: Tables["User"]["qWeaponPool"];
chatNameColor: string | null;
plusTier: Tables["PlusTier"]["tier"] | null;
};
export async function findGroupsByTournamentId(tournamentId: number) {
export async function findLookingTeamsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentLFGGroup")
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentLFGGroupMember",
"TournamentLFGGroupMember.groupId",
"TournamentLFGGroup.id",
)
.innerJoin("User", "User.id", "TournamentLFGGroupMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin(
"TournamentTeam",
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
"TournamentLFGGroup.tournamentTeamId",
)
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentTeam.avatarImgId",
)
.select(({ fn, eb }) => [
"TournamentLFGGroup.id",
"TournamentLFGGroup.chatCode",
"TournamentLFGGroup.tournamentTeamId",
"TournamentLFGGroup.visibility",
"TournamentTeam.id",
"TournamentTeam.chatCode",
"TournamentTeam.isPlaceholder",
"TournamentTeam.lfgVisibility as visibility",
"TournamentTeam.lfgNote as note",
"TournamentTeam.name as teamName",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"teamAvatarUrl",
@@ -131,9 +106,8 @@ export async function findGroupsByTournamentId(tournamentId: number) {
languages: eb.ref("User.languages"),
vc: eb.ref("User.vc"),
pronouns: eb.ref("User.pronouns"),
role: eb.ref("TournamentLFGGroupMember.role"),
note: eb.ref("TournamentLFGGroupMember.note"),
isStayAsSub: eb.ref("TournamentLFGGroupMember.isStayAsSub"),
role: eb.ref("TournamentTeamMember.role"),
isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"),
weapons: eb.ref("User.qWeaponPool"),
chatNameColor: userChatNameColorForJson,
plusTier: eb.ref("PlusTier.tier"),
@@ -142,101 +116,74 @@ export async function findGroupsByTournamentId(tournamentId: number) {
.$castTo<TournamentLFGMemberObject[]>()
.as("members"),
])
.where("TournamentLFGGroup.tournamentId", "=", tournamentId)
.groupBy("TournamentLFGGroup.id")
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isLooking", "=", 1)
.groupBy("TournamentTeam.id")
.execute();
}
export function morphGroups({
survivingGroupId,
otherGroupId,
// xxx: ensure that the team size does not exceed the max, also filter out in frontend
export function mergeTeams({
survivingTeamId,
otherTeamId,
maxGroupSize,
tournamentId,
}: {
survivingGroupId: number;
otherGroupId: number;
survivingTeamId: number;
otherTeamId: number;
maxGroupSize: number;
tournamentId: number;
}) {
return db.transaction().execute(async (trx) => {
const survivingGroup = await trx
.selectFrom("TournamentLFGGroup")
.select("tournamentTeamId")
.where("id", "=", survivingGroupId)
.executeTakeFirstOrThrow();
if (!survivingGroup.tournamentTeamId) {
const createdTeam = await trx
.insertInto("TournamentTeam")
.values({
tournamentId,
name: `LFG Team ${shortNanoid()}`,
inviteCode: "",
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.updateTable("TournamentLFGGroup")
.set({ tournamentTeamId: createdTeam.id })
.where("id", "=", survivingGroupId)
.execute();
}
await trx
.updateTable("TournamentLFGGroup")
.updateTable("TournamentTeam")
.set({ chatCode: shortNanoid() })
.where("TournamentLFGGroup.id", "=", survivingGroupId)
.where("TournamentTeam.id", "=", survivingTeamId)
.execute();
const otherGroupMembers = await trx
.selectFrom("TournamentLFGGroupMember")
.select([
"TournamentLFGGroupMember.userId",
"TournamentLFGGroupMember.role",
])
.where("TournamentLFGGroupMember.groupId", "=", otherGroupId)
const otherMembers = await trx
.selectFrom("TournamentTeamMember")
.select(["TournamentTeamMember.userId", "TournamentTeamMember.role"])
.where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId)
.execute();
for (const member of otherGroupMembers) {
for (const member of otherMembers) {
await trx
.updateTable("TournamentLFGGroupMember")
.updateTable("TournamentTeamMember")
.set({
role: member.role === "OWNER" ? "MANAGER" : member.role,
groupId: survivingGroupId,
tournamentTeamId: survivingTeamId,
})
.where("TournamentLFGGroupMember.groupId", "=", otherGroupId)
.where("TournamentLFGGroupMember.userId", "=", member.userId)
.where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId)
.where("TournamentTeamMember.userId", "=", member.userId)
.execute();
}
await deleteLikesByGroupId(survivingGroupId, trx);
await deleteLikesByTeamId(survivingTeamId, trx);
await trx
.deleteFrom("TournamentLFGGroup")
.where("TournamentLFGGroup.id", "=", otherGroupId)
.deleteFrom("TournamentTeam")
.where("TournamentTeam.id", "=", otherTeamId)
.execute();
invariant(
await isGroupCorrect(survivingGroupId, trx, maxGroupSize),
await isTeamCorrect(survivingTeamId, trx, maxGroupSize),
"Group has too many members after merge",
);
});
}
export async function addLike({
likerGroupId,
targetGroupId,
likerTeamId,
targetTeamId,
}: {
likerGroupId: number;
targetGroupId: number;
likerTeamId: number;
targetTeamId: number;
}) {
try {
await db
.insertInto("TournamentLFGLike")
.values({ likerGroupId, targetGroupId })
.values({ likerTeamId, targetTeamId })
.onConflict((oc) =>
oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
oc.columns(["likerTeamId", "targetTeamId"]).doNothing(),
)
.execute();
} catch (error) {
@@ -248,100 +195,94 @@ export async function addLike({
}
export function deleteLike({
likerGroupId,
targetGroupId,
likerTeamId,
targetTeamId,
}: {
likerGroupId: number;
targetGroupId: number;
likerTeamId: number;
targetTeamId: number;
}) {
return db
.deleteFrom("TournamentLFGLike")
.where("likerGroupId", "=", likerGroupId)
.where("targetGroupId", "=", targetGroupId)
.where("likerTeamId", "=", likerTeamId)
.where("targetTeamId", "=", targetTeamId)
.execute();
}
export async function allLikesByGroupId(groupId: number) {
export async function allLikesByTeamId(teamId: number) {
const rows = await db
.selectFrom("TournamentLFGLike")
.select([
"TournamentLFGLike.likerGroupId",
"TournamentLFGLike.targetGroupId",
])
.select(["TournamentLFGLike.likerTeamId", "TournamentLFGLike.targetTeamId"])
.where((eb) =>
eb.or([
eb("TournamentLFGLike.likerGroupId", "=", groupId),
eb("TournamentLFGLike.targetGroupId", "=", groupId),
eb("TournamentLFGLike.likerTeamId", "=", teamId),
eb("TournamentLFGLike.targetTeamId", "=", teamId),
]),
)
.execute();
return {
given: rows
.filter((row) => row.likerGroupId === groupId)
.map((row) => ({ groupId: row.targetGroupId })),
.filter((row) => row.likerTeamId === teamId)
.map((row) => ({ teamId: row.targetTeamId })),
received: rows
.filter((row) => row.targetGroupId === groupId)
.map((row) => ({ groupId: row.likerGroupId })),
.filter((row) => row.targetTeamId === teamId)
.map((row) => ({ teamId: row.likerTeamId })),
};
}
export function updateMemberNote({
groupId,
userId,
export function updateTeamNote({
teamId,
value,
}: {
groupId: number;
userId: number;
teamId: number;
value: string | null;
}) {
return db
.updateTable("TournamentLFGGroupMember")
.set({ note: value })
.where("groupId", "=", groupId)
.where("userId", "=", userId)
.updateTable("TournamentTeam")
.set({ lfgNote: value })
.where("id", "=", teamId)
.execute();
}
export function updateMemberRole({
userId,
groupId,
teamId,
role,
}: {
userId: number;
groupId: number;
role: Tables["TournamentLFGGroupMember"]["role"];
teamId: number;
role: Tables["TournamentTeamMember"]["role"];
}) {
if (role === "OWNER") {
throw new Error("Can't set role to OWNER with this function");
}
return db
.updateTable("TournamentLFGGroupMember")
.updateTable("TournamentTeamMember")
.set({ role })
.where("userId", "=", userId)
.where("groupId", "=", groupId)
.where("tournamentTeamId", "=", teamId)
.execute();
}
export function updateStayAsSub({
groupId,
teamId,
userId,
value,
}: {
groupId: number;
teamId: number;
userId: number;
value: boolean;
}) {
return db
.updateTable("TournamentLFGGroupMember")
.updateTable("TournamentTeamMember")
.set({ isStayAsSub: value ? 1 : 0 })
.where("groupId", "=", groupId)
.where("tournamentTeamId", "=", teamId)
.where("userId", "=", userId)
.execute();
}
export function leaveGroup({
export function leaveLfg({
userId,
tournamentId,
}: {
@@ -349,90 +290,106 @@ export function leaveGroup({
tournamentId: number;
}) {
return db.transaction().execute(async (trx) => {
const userGroup = await trx
.selectFrom("TournamentLFGGroupMember")
const userTeam = await trx
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select([
"TournamentLFGGroupMember.groupId",
"TournamentLFGGroupMember.role",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeamMember.role",
"TournamentTeam.isPlaceholder",
])
.where("TournamentLFGGroupMember.userId", "=", userId)
.where("TournamentLFGGroupMember.tournamentId", "=", tournamentId)
.where("TournamentTeamMember.userId", "=", userId)
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isLooking", "=", 1)
.executeTakeFirstOrThrow();
await trx
.deleteFrom("TournamentLFGGroupMember")
.where("userId", "=", userId)
.where("groupId", "=", userGroup.groupId)
.execute();
const remainingMembers = await trx
.selectFrom("TournamentLFGGroupMember")
.select(["userId", "role"])
.where("groupId", "=", userGroup.groupId)
.execute();
if (remainingMembers.length === 0) {
if (!userTeam.isPlaceholder) {
await trx
.deleteFrom("TournamentLFGGroup")
.where("id", "=", userGroup.groupId)
.updateTable("TournamentTeam")
.set({ isLooking: 0 })
.where("id", "=", userTeam.tournamentTeamId)
.execute();
return;
}
if (userGroup.role === "OWNER") {
// xxx: or just call TournamentTeam.unregister?
// xxx: also leaving team should not be possible via this so remove that code
await trx
.deleteFrom("TournamentTeamMember")
.where("userId", "=", userId)
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
const remainingMembers = await trx
.selectFrom("TournamentTeamMember")
.select(["userId", "role"])
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
if (remainingMembers.length === 0) {
await trx
.deleteFrom("TournamentTeam")
.where("id", "=", userTeam.tournamentTeamId)
.execute();
return;
}
if (userTeam.role === "OWNER") {
const newOwner =
remainingMembers.find((m) => m.role === "MANAGER") ??
remainingMembers[0];
await trx
.updateTable("TournamentLFGGroupMember")
.updateTable("TournamentTeamMember")
.set({ role: "OWNER" })
.where("userId", "=", newOwner.userId)
.where("groupId", "=", userGroup.groupId)
.where("tournamentTeamId", "=", userTeam.tournamentTeamId)
.execute();
}
});
}
export function cleanupForTournamentStart(tournamentId: number) {
return db
.deleteFrom("TournamentLFGGroup")
.where("TournamentLFGGroup.tournamentId", "=", tournamentId)
.execute();
}
export async function getSubsForTournament(tournamentId: number) {
const rows = await db
.selectFrom("TournamentLFGGroupMember")
.select("TournamentLFGGroupMember.userId")
.where("TournamentLFGGroupMember.tournamentId", "=", tournamentId)
.where("TournamentLFGGroupMember.isStayAsSub", "=", 1)
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select("TournamentTeamMember.userId")
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeamMember.isStayAsSub", "=", 1)
.execute();
return rows.map((row) => row.userId);
}
function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
function deleteLikesByTeamId(teamId: number, trx: Transaction<DB>) {
return trx
.deleteFrom("TournamentLFGLike")
.where((eb) =>
eb.or([
eb("TournamentLFGLike.likerGroupId", "=", groupId),
eb("TournamentLFGLike.targetGroupId", "=", groupId),
eb("TournamentLFGLike.likerTeamId", "=", teamId),
eb("TournamentLFGLike.targetTeamId", "=", teamId),
]),
)
.execute();
}
async function isGroupCorrect(
groupId: number,
async function isTeamCorrect(
teamId: number,
trx: Transaction<DB>,
maxGroupSize: number,
): Promise<boolean> {
const members = await trx
.selectFrom("TournamentLFGGroupMember")
.select("TournamentLFGGroupMember.userId")
.where("TournamentLFGGroupMember.groupId", "=", groupId)
.selectFrom("TournamentTeamMember")
.select("TournamentTeamMember.userId")
.where("TournamentTeamMember.tournamentTeamId", "=", teamId)
.execute();
return members.length <= maxGroupSize;

View File

@@ -10,7 +10,7 @@ import { assertUnreachable } from "~/utils/types";
import { idObject } from "~/utils/zod";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
import { lookingSchema } from "../tournament-lfg-schemas.server";
import { survivingGroupId } from "../tournament-lfg-utils";
import { survivingTeamId } from "../tournament-lfg-utils";
// xxx: prevent actions in certain cases? like when tournament has started
export const action = async ({ request, params }: ActionFunctionArgs) => {
@@ -23,7 +23,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const findOwnGroup = async () => {
const groups =
await TournamentLFGRepository.findGroupsByTournamentId(tournamentId);
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
);
return groups.find((g) => g.members.some((m) => m.id === user.id));
};
@@ -49,28 +51,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const team = tournament.teamMemberOfByUser(user);
if (team) {
const groups =
await TournamentLFGRepository.findGroupsByTournamentId(tournamentId);
const teamAlreadyLinked = groups.some(
(g) => g.tournamentTeamId === team.id,
);
if (teamAlreadyLinked) return null;
const memberAlreadyInGroup = team.members.some((m) =>
groups.some((g) => g.members.some((gm) => gm.id === m.userId)),
);
if (memberAlreadyInGroup) return null;
await TournamentLFGRepository.createGroupFromTeam({
tournamentId,
tournamentTeamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
isOwner: Boolean(m.isOwner),
})),
});
await TournamentLFGRepository.startLooking(team.id);
} else {
await TournamentLFGRepository.createGroup({
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId,
userId: user.id,
isStayAsSub: data.stayAsSub ?? false,
@@ -84,8 +67,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (!ownGroup || !isGroupManager(ownGroup)) return null;
await TournamentLFGRepository.addLike({
likerGroupId: ownGroup.id,
targetGroupId: data.targetGroupId,
likerTeamId: ownGroup.id,
targetTeamId: data.targetTeamId,
});
break;
@@ -95,44 +78,48 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (!ownGroup || !isGroupManager(ownGroup)) return null;
await TournamentLFGRepository.deleteLike({
likerGroupId: ownGroup.id,
targetGroupId: data.targetGroupId,
likerTeamId: ownGroup.id,
targetTeamId: data.targetTeamId,
});
break;
}
case "ACCEPT": {
const groups =
await TournamentLFGRepository.findGroupsByTournamentId(tournamentId);
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
);
const ownGroup = groups.find((g) =>
g.members.some((m) => m.id === user.id),
);
if (!ownGroup || !isGroupManager(ownGroup)) return null;
const theirGroup = groups.find((g) => g.id === data.targetGroupId);
const theirGroup = groups.find((g) => g.id === data.targetTeamId);
if (!theirGroup) return null;
const theirLikes = await TournamentLFGRepository.allLikesByGroupId(
data.targetGroupId,
const theirLikes = await TournamentLFGRepository.allLikesByTeamId(
data.targetTeamId,
);
if (!theirLikes.given.some((like) => like.groupId === ownGroup.id)) {
if (!theirLikes.given.some((like) => like.teamId === ownGroup.id)) {
return null;
}
const surviving = survivingGroupId({
const surviving = survivingTeamId({
ourGroup: {
id: ownGroup.id,
tournamentTeamId: ownGroup.tournamentTeamId,
isPlaceholder: Boolean(ownGroup.isPlaceholder),
teamName: null,
teamAvatarUrl: null,
note: null,
members: [],
usersRole: null,
},
theirGroup: {
id: theirGroup.id,
tournamentTeamId: theirGroup.tournamentTeamId,
isPlaceholder: Boolean(theirGroup.isPlaceholder),
teamName: null,
teamAvatarUrl: null,
note: null,
members: [],
usersRole: null,
},
@@ -145,11 +132,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
user,
});
await TournamentLFGRepository.morphGroups({
survivingGroupId: surviving,
otherGroupId: otherGroup.id,
await TournamentLFGRepository.mergeTeams({
survivingTeamId: surviving,
otherTeamId: otherGroup.id,
maxGroupSize: tournament.maxMembersPerTeam,
tournamentId,
});
break;
@@ -159,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner");
await TournamentLFGRepository.updateMemberRole({
groupId: ownGroup!.id,
teamId: ownGroup!.id,
userId: data.userId,
role: "MANAGER",
});
@@ -171,7 +157,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner");
await TournamentLFGRepository.updateMemberRole({
groupId: ownGroup!.id,
teamId: ownGroup!.id,
userId: data.userId,
role: "REGULAR",
});
@@ -182,9 +168,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const ownGroup = await findOwnGroup();
if (!ownGroup) return null;
await TournamentLFGRepository.updateMemberNote({
groupId: ownGroup.id,
userId: user.id,
await TournamentLFGRepository.updateTeamNote({
teamId: ownGroup.id,
value: data.value,
});
@@ -195,7 +180,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
if (!ownGroup) return null;
await TournamentLFGRepository.updateStayAsSub({
groupId: ownGroup.id,
teamId: ownGroup.id,
userId: user.id,
value: data.value,
});
@@ -203,7 +188,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "LEAVE_GROUP": {
await TournamentLFGRepository.leaveGroup({
await TournamentLFGRepository.leaveLfg({
userId: user.id,
tournamentId,
});

View File

@@ -31,7 +31,6 @@ export type LFGGroupMember = {
vc: "YES" | "NO" | "LISTEN_ONLY" | null;
pronouns: Pronouns | null;
role: "OWNER" | "MANAGER" | "REGULAR";
note: string | null;
isStayAsSub: boolean;
weapons: Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null;
chatNameColor: string | null;
@@ -40,13 +39,15 @@ export type LFGGroupMember = {
export type LFGGroup = {
id: number;
tournamentTeamId: number | null;
isPlaceholder: boolean;
teamName: string | null;
teamAvatarUrl: string | null;
note: string | null;
members: LFGGroupMember[];
usersRole: "OWNER" | "MANAGER" | "REGULAR" | null;
};
// xxx: make sure we don't show team name if isPlaceholder
// xxx: "cancel" & "save" buttons off
export function LFGGroupCard({
group,
@@ -65,14 +66,6 @@ export function LFGGroupCard({
return (
<section className={styles.group}>
{/* {group.teamName ? (
<div className={styles.teamHeader}>
{group.teamAvatarUrl ? (
<Avatar size="xxs" url={group.teamAvatarUrl} />
) : null}
<div className={styles.teamName}>{group.teamName}</div>
</div>
) : null} */}
{group.teamName ? (
<Divider smallText className={styles.teamHeader}>
{group.teamAvatarUrl ? (
@@ -91,10 +84,13 @@ export function LFGGroupCard({
/>
))}
</div>
{isOwnGroup ? (
<LFGTeamNote note={group.note} editable={group.usersRole === "OWNER"} />
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetGroupId" value={group.id} />
<input type="hidden" name="targetTeamId" value={group.id} />
<SubmitButton
size="small"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
@@ -116,14 +112,11 @@ export function LFGGroupCard({
function LFGGroupMemberRow({
member,
showActions,
isOwnGroup,
}: {
member: LFGGroupMember;
showActions: boolean;
isOwnGroup: boolean;
}) {
const user = useUser();
return (
<div className="stack xxs">
<div className={styles.member}>
@@ -171,14 +164,11 @@ function LFGGroupMemberRow({
</div>
) : null}
</div>
{isOwnGroup ? (
<LFGMemberNote note={member.note} editable={user?.id === member.id} />
) : null}
</div>
);
}
function LFGMemberNote({
function LFGTeamNote({
note,
editable,
}: {

View File

@@ -13,16 +13,17 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId } = parseParams({ params, schema: idObject });
const rawGroups =
await TournamentLFGRepository.findGroupsByTournamentId(tournamentId);
await TournamentLFGRepository.findLookingTeamsByTournamentId(tournamentId);
const groups: LFGGroup[] = rawGroups.map((group) => {
const members = transformMembers(group.members);
return {
id: group.id,
tournamentTeamId: group.tournamentTeamId,
isPlaceholder: Boolean(group.isPlaceholder),
teamName: group.teamName ?? null,
teamAvatarUrl: group.teamAvatarUrl ?? null,
note: group.note ?? null,
members,
usersRole: members.find((m) => m.id === user?.id)?.role ?? null,
};
@@ -34,14 +35,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const otherGroups = groups.filter((g) => g.id !== ownGroup?.id);
const likes = ownGroup
? await TournamentLFGRepository.allLikesByGroupId(ownGroup.id)
? await TournamentLFGRepository.allLikesByTeamId(ownGroup.id)
: { given: [], received: [] };
const ownTeam = await resolveOwnTeam({
user,
tournamentId,
ownGroup,
groups,
});
return {
@@ -58,12 +58,10 @@ async function resolveOwnTeam({
user,
tournamentId,
ownGroup,
groups,
}: {
user: ReturnType<typeof getUser>;
tournamentId: number;
ownGroup: LFGGroup | null;
groups: LFGGroup[];
}): Promise<LFGGroup | null> {
if (!user) return null;
if (ownGroup) return null;
@@ -76,9 +74,6 @@ async function resolveOwnTeam({
const team = tournament.teamMemberOfByUser(user);
if (!team) return null;
const teamAlreadyLinked = groups.some((g) => g.tournamentTeamId === team.id);
if (teamAlreadyLinked) return null;
const members: LFGGroupMember[] = team.members.map((m) => ({
id: m.userId,
username: m.username,
@@ -89,7 +84,6 @@ async function resolveOwnTeam({
vc: null,
pronouns: null,
role: m.isOwner ? "OWNER" : "REGULAR",
note: null,
isStayAsSub: false,
weapons: null,
chatNameColor: null,
@@ -97,10 +91,11 @@ async function resolveOwnTeam({
}));
return {
id: -1,
tournamentTeamId: team.id,
id: team.id,
isPlaceholder: false,
teamName: team.name,
teamAvatarUrl: team.pickupAvatarUrl,
note: null,
members,
usersRole: members.find((m) => m.id === user.id)?.role ?? null,
};
@@ -108,7 +103,7 @@ async function resolveOwnTeam({
function transformMembers(
rawMembers: Awaited<
ReturnType<typeof TournamentLFGRepository.findGroupsByTournamentId>
ReturnType<typeof TournamentLFGRepository.findLookingTeamsByTournamentId>
>[number]["members"],
): LFGGroupMember[] {
return rawMembers.map((m) => {
@@ -130,7 +125,6 @@ function transformMembers(
vc: m.vc,
pronouns,
role: m.role,
note: m.note,
isStayAsSub: m.isStayAsSub === 1,
weapons,
chatNameColor: m.chatNameColor,

View File

@@ -58,17 +58,17 @@ function Groups() {
const neutralGroups = data.groups.filter(
(group) =>
!data.likes.given.some((like) => like.groupId === group.id) &&
!data.likes.received.some((like) => like.groupId === group.id),
!data.likes.given.some((like) => like.teamId === group.id) &&
!data.likes.received.some((like) => like.teamId === group.id),
);
const likedGroups = data.groups.filter((group) =>
data.likes.given.some((like) => like.groupId === group.id),
data.likes.given.some((like) => like.teamId === group.id),
);
const groupsReceivedLikesFrom = data.groups.filter((group) =>
data.likes.received.some((like) => like.groupId === group.id),
data.likes.received.some((like) => like.teamId === group.id),
);
const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.groupId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.teamId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`;
const invitedGroupsDesktop = (
<div className="stack sm">
@@ -157,7 +157,7 @@ function Groups() {
? data.groups.filter(
(group) =>
!data.likes.received.some(
(like) => like.groupId === group.id,
(like) => like.teamId === group.id,
),
)
: neutralGroups
@@ -166,7 +166,7 @@ function Groups() {
key={group.id}
group={group}
action={
data.likes.given.some((like) => like.groupId === group.id)
data.likes.given.some((like) => like.teamId === group.id)
? "UNLIKE"
: "LIKE"
}

View File

@@ -7,15 +7,15 @@ export const lookingSchema = z.union([
joinQueueFormSchema,
z.object({
_action: _action("LIKE"),
targetGroupId: id,
targetTeamId: id,
}),
z.object({
_action: _action("UNLIKE"),
targetGroupId: id,
targetTeamId: id,
}),
z.object({
_action: _action("ACCEPT"),
targetGroupId: id,
targetTeamId: id,
}),
z.object({
_action: _action("GIVE_MANAGER"),

View File

@@ -1,20 +1,16 @@
import type { LFGGroup } from "./components/LFGGroupCard";
export function findOwnGroup(groups: LFGGroup[], userId: number) {
return groups.find((g) => g.members.some((m) => m.id === userId)) ?? null;
}
export function survivingGroupId({
export function survivingTeamId({
ourGroup,
theirGroup,
}: {
ourGroup: LFGGroup;
theirGroup: LFGGroup;
}) {
if (ourGroup.tournamentTeamId && !theirGroup.tournamentTeamId) {
if (!ourGroup.isPlaceholder && theirGroup.isPlaceholder) {
return ourGroup.id;
}
if (!ourGroup.tournamentTeamId && theirGroup.tournamentTeamId) {
if (ourGroup.isPlaceholder && !theirGroup.isPlaceholder) {
return theirGroup.id;
}

View File

@@ -251,6 +251,7 @@ export async function findById(id: number) {
).as("team"),
])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0)
.orderBy("TournamentTeam.seed", "asc")
.orderBy("TournamentTeam.createdAt", "asc")
.orderBy("TournamentTeam.id", "asc"),
@@ -484,6 +485,7 @@ export function forShowcase() {
),
)
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where("TournamentTeam.isPlaceholder", "=", 0)
.where((eb) =>
eb.or([
eb("TournamentTeamCheckIn.checkedInAt", "is not", null),

View File

@@ -69,6 +69,8 @@ import {
import { useTournament } from "./to.$id";
export { loader, action };
// xxx: handle placeholder team made
// xxx: allow promoting members to managers
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const tournament = useTournament();

2037
app/utils/team-name-data.ts Normal file

File diff suppressed because it is too large Load Diff

12
app/utils/team-name.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ADJECTIVES, SUBTITLES_PLURALIZED } from "./team-name-data";
/** Generates a random team name by combining a Splatoon 3 title adjective with a pluralized subtitle e.g. "Prestigious Heat Haters" */
export function randomTeamName() {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const subtitle =
SUBTITLES_PLURALIZED[
Math.floor(Math.random() * SUBTITLES_PLURALIZED.length)
];
return `${adjective} ${subtitle}`;
}

Binary file not shown.

View File

@@ -1,3 +1,5 @@
// xxx: update tournament lfg stuff etc.
Note: some simple features omitted with only a few relations and no special notes
See `tables.ts` for some more documentation on column-level.

View File

@@ -1,69 +1,51 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "TournamentLFGGroup" (
"id" integer primary key,
"tournamentId" integer not null,
"tournamentTeamId" integer,
"visibility" text,
"chatCode" text not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("tournamentId") references "Tournament"("id") on delete cascade,
foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade
) strict
`,
/*sql*/ `alter table "TournamentTeam" add column "isLooking" integer not null default 0`,
).run();
// xxx: index on isLooking too to get efficient queries? problem -> we query tournamentTeamMember.userId (different table)
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "isPlaceholder" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "lfgVisibility" text`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "lfgNote" text`,
).run();
db.prepare(
/*sql*/ `alter table "TournamentTeam" add column "chatCode" text`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_group_tournament_id on "TournamentLFGGroup"("tournamentId")`,
/*sql*/ `alter table "TournamentTeamMember" add column "role" text not null default 'REGULAR'`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_group_tournament_team_id on "TournamentLFGGroup"("tournamentTeamId")`,
/*sql*/ `alter table "TournamentTeamMember" add column "isStayAsSub" integer not null default 0`,
).run();
db.prepare(
/*sql*/ `
create table "TournamentLFGGroupMember" (
"groupId" integer not null,
"tournamentId" integer not null,
"userId" integer not null,
"role" text not null,
"note" text,
"isStayAsSub" integer default 0 not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("groupId") references "TournamentLFGGroup"("id") on delete cascade,
foreign key ("userId") references "User"("id") on delete cascade,
unique("tournamentId", "userId") on conflict rollback
) strict
`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_group_member_group_id on "TournamentLFGGroupMember"("groupId")`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_group_member_user_id on "TournamentLFGGroupMember"("userId")`,
/*sql*/ `update "TournamentTeamMember" set "role" = 'OWNER' where "isOwner" = 1`,
).run();
db.prepare(
/*sql*/ `
create table "TournamentLFGLike" (
"likerGroupId" integer not null,
"targetGroupId" integer not null,
"likerTeamId" integer not null,
"targetTeamId" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("likerGroupId") references "TournamentLFGGroup"("id") on delete cascade,
foreign key ("targetGroupId") references "TournamentLFGGroup"("id") on delete cascade,
unique("likerGroupId", "targetGroupId") on conflict rollback
foreign key ("likerTeamId") references "TournamentTeam"("id") on delete cascade,
foreign key ("targetTeamId") references "TournamentTeam"("id") on delete cascade,
unique("likerTeamId", "targetTeamId") on conflict rollback
) strict
`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_like_liker_group_id on "TournamentLFGLike"("likerGroupId")`,
/*sql*/ `create index tournament_lfg_like_liker_team_id on "TournamentLFGLike"("likerTeamId")`,
).run();
db.prepare(
/*sql*/ `create index tournament_lfg_like_target_group_id on "TournamentLFGLike"("targetGroupId")`,
/*sql*/ `create index tournament_lfg_like_target_team_id on "TournamentLFGLike"("targetTeamId")`,
).run();
})();
}