9.7 KiB
Tournament LFG Implementation Plan
Overview
Add a new /to/:id/looking route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before the tournament starts.
Phase 1: Database Migration
File: migrations/118-tournament-lfg.js
Create three new tables:
TournamentLFGGroup
| Column | Type | Notes |
|---|---|---|
| id | integer primary key | Auto-increment |
| tournamentId | integer not null | FK to Tournament |
| tournamentTeamId | integer | FK to TournamentTeam (null for unregistered groups) |
| visibility | text | JSON (AssociationVisibility) |
| chatCode | text not null | Unique room code for group chat |
| createdAt | integer | Default now |
TournamentLFGGroupMember
| Column | Type | Notes |
|---|---|---|
| groupId | integer not null | FK to TournamentLFGGroup |
| userId | integer not null | FK to User |
| role | text not null | OWNER / MANAGER / REGULAR |
| note | text | Public note |
| isStayAsSub | integer | Boolean (0/1), default 0 |
| createdAt | integer | Default now |
TournamentLFGLike
| Column | Type | Notes |
|---|---|---|
| likerGroupId | integer not null | FK to TournamentLFGGroup |
| targetGroupId | integer not null | FK to TournamentLFGGroup |
| createdAt | integer | Default now |
All tables use STRICT mode, ON DELETE CASCADE for FKs, and have indexes on FK columns.
Phase 2: TypeScript Types
File: app/db/tables.ts
Add interfaces for the three new tables following existing patterns (GeneratedAlways, Generated, JSONColumnTypeNullable).
Add to the DB interface:
TournamentLFGGroupTournamentLFGGroupMemberTournamentLFGLike
Phase 3: Feature File Structure
app/features/tournament-lfg/
├── TournamentLFGRepository.server.ts # Database operations
├── tournament-lfg-types.ts # TypeScript types (LFGGroup, LFGGroupMember, etc.)
├── tournament-lfg-schemas.server.ts # Zod validation schemas
├── tournament-lfg-constants.ts # Constants (note max length, etc.)
├── tournament-lfg-utils.ts # Utility functions
├── routes/
│ └── to.$id.looking.tsx # Main route (loader + action + component)
├── loaders/
│ └── to.$id.looking.server.ts # Data loader
├── actions/
│ └── to.$id.looking.server.ts # Action handler
└── components/
└── LFGGroupCard.tsx # Group card (mirrors SendouQ GroupCard pattern)
Phase 4: Repository Implementation
File: app/features/tournament-lfg/TournamentLFGRepository.server.ts
Mirror SQGroupRepository.server.ts pattern. Key functions:
Group Management:
findGroupsByTournamentId(tournamentId)- Get all active groupsaddMember(groupId, { userId, role, stayAsSub? })- Add member to groupmorphGroups({ survivingGroupId, otherGroupId })- Merge two groups
Likes:
addLike({ likerGroupId, targetGroupId })- Add likedeleteLike({ likerGroupId, targetGroupId })- Remove likeallLikesByGroupId(groupId)- Get { given: [], received: [] }
Member Management:
updateMemberNote({ groupId, userId, value })- Update public noteupdateMemberRole({ userId, groupId, role })- Change roleupdateStayAsSub({ groupId, userId, value })- Toggle sub preferencekickMember({ groupId, userId })- Owner kicks member
Tournament Integration:
cleanupForTournamentStart(tournamentId)- Delete groups, preserve stayAsSub membersgetSubsForTournament(tournamentId)- Get users who opted to stay as sub
Phase 5: Route Implementation
5.1 Route Registration
File: app/routes.ts
Add inside /to/:id children:
route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"),
5.2 Loader
File: app/features/tournament-lfg/loaders/to.$id.looking.server.ts
Returns:
groups- All visible groups (filtered by visibility)ownGroup- User's current group (if any)likes- { given: [], received: [] } for own groupprivateNotes- User's private notes on other players (reuse from SendouQ)lastUpdated- Timestamp for auto-refresh
5.3 Action
File: app/features/tournament-lfg/actions/to.$id.looking.server.ts
Actions:
JOIN_QUEUE- Create new groupLIKE/UNLIKE- Like/unlike another groupACCEPT- Accept mutual like (triggers team creation/merge)LEAVE_GROUP- Leave current groupKICK_FROM_GROUP- Owner kicks memberGIVE_MANAGER/REMOVE_MANAGER- Role managementUPDATE_NOTE- Update public noteUPDATE_STAY_AS_SUB- Toggle sub preferenceREFRESH_GROUP- Refresh activity timestamp
ACCEPT Action Flow:
- Verify mutual like exists
- Check if either group has
tournamentTeamId - If neither: Create new
TournamentTeam(use auto-generated name) - Merge groups (use
morphGroups) - Add all members to
TournamentTeamMember - Send
TO_LFG_TEAM_FORMEDnotification - If team reaches
maxMembersPerTeam: delete the LFG group
5.4 Component
File: app/features/tournament-lfg/routes/to.$id.looking.tsx
Structure (mirror /q/looking):
- Three-column desktop layout: My Group | Groups | Invitations
- Tab structure on mobile
- Reuse
GroupCarddisplay pattern (weapons, VC, tier) - Reuse
MemberAdderfor quick-add trusted players - Reuse
GroupLeavercomponent - Chat integration for groups with 2+ members
- "Stay as sub" checkbox in join form
Phase 6: Tournament Integration
6.1 Add "Looking" Tab
File: app/features/tournament/routes/to.$id.tsx
Add new SubNavLink (show only before tournament starts, not for invitationals):
{!tournament.hasStarted && !tournament.isInvitational && (
<SubNavLink to="looking">{t("tournament:tabs.looking")}</SubNavLink>
)}
6.2 Tournament.ts Getter
File: app/features/tournament-bracket/core/Tournament.ts
Add:
get lfgEnabled() {
return !this.isInvitational && !this.hasStarted && this.subsFeatureEnabled;
}
6.3 Auto-cleanup on Tournament Start
When tournament bracket starts, call TournamentLFGRepository.cleanupForTournamentStart(tournamentId):
- Delete all
TournamentLFGGrouprecords - Preserve
TournamentLFGGroupMemberrecords wherestayAsSub = 1for subs list
Phase 7: Notifications
File: app/features/notifications/notifications-types.ts
Add three new notification types:
| NotificationItem<
"TO_LFG_LIKED",
{
tournamentId: number;
tournamentName: string;
likerUsername: string;
}
>
| NotificationItem<
"TO_LFG_TEAM_FORMED",
{
tournamentId: number;
tournamentName: string;
teamName: string;
tournamentTeamId: number;
}
>
| NotificationItem<
"TO_LFG_CHAT_MESSAGE",
{
tournamentId: number;
tournamentName: string;
teamName: string;
tournamentTeamId: number;
}
>
File: app/features/notifications/notifications-utils.ts
Add notification link handlers and icon mappings.
Phase 8: Translations
File: public/locales/en/tournament.json
Add keys:
tabs.lookinglfg.join.header,lfg.join.stayAsSub,lfg.join.visibilitylfg.myGroup.header,lfg.myGroup.emptylfg.groups.header,lfg.groups.emptylfg.invitations.header,lfg.invitations.empty,lfg.invitations.acceptlfg.actions.like,lfg.actions.unlike,lfg.actions.leave
Run npm run i18n:sync after adding.
Phase 9: Group Merging Logic
When two groups merge via ACCEPT:
| Group A has team | Group B has team | Result |
|---|---|---|
| No | No | Create new TournamentTeam, both join it |
| Yes | No | B's members join A's team |
| No | Yes | A's members join B's team |
| Yes | Yes | Accepting team absorbs liker team (accepting team name persists) |
Auto-generated team name format: "<owner_username>'s Team" (e.g., "Sendou's Team" - can be changed later on registration page)
After merge:
- Combined group stays in LFG queue
- When
maxMembersPerTeamreached, group is auto-removed from queue
Key Files to Reference
| Purpose | File |
|---|---|
| Repository pattern | app/features/sendouq/SQGroupRepository.server.ts |
| Route pattern | app/features/sendouq/routes/q.looking.tsx |
| Action pattern | app/features/sendouq/actions/q.looking.server.ts |
| GroupCard UI | app/features/sendouq/components/GroupCard.tsx |
| Tournament tabs | app/features/tournament/routes/to.$id.tsx |
| Team creation | app/features/tournament/TournamentTeamRepository.server.ts |
| Notification types | app/features/notifications/notifications-types.ts |
| Visibility type | app/features/associations/associations-types.ts |
Implementation Order
- Migration (100-tournament-lfg.js)
- Types (tables.ts + tournament-lfg-types.ts)
- Repository (TournamentLFGRepository.server.ts)
- Schemas (tournament-lfg-schemas.server.ts)
- Loader (to.$id.looking.server.ts)
- Action (to.$id.looking.server.ts)
- Route component (to.$id.looking.tsx)
- Tournament integration (tab, cleanup hook)
- Notifications (types + utils)
- Translations
- Testing
Verification
-
Manual Testing:
- Join LFG as solo player
- Create group with 2 players
- Like another group, verify notification sent
- Accept mutual like, verify team created
- Verify team appears on registration page
- Test "stay as sub" checkbox
- Test visibility filtering
-
Unit Tests:
- Repository functions (create, merge, delete, likes)
- Visibility filtering logic
-
E2E Tests:
- Full flow: join -> like -> accept -> team formed
- Leave group
- Kick from group
-
Run checks:
npm run checks