sendou.ink/TOURNAMENT_LFG_PLAN.md
Kalle fef1ffc955
Design refresh + a bunch of stuff (#2864)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-03-19 17:51:42 +02:00

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:

  • TournamentLFGGroup
  • TournamentLFGGroupMember
  • TournamentLFGLike

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 groups
  • addMember(groupId, { userId, role, stayAsSub? }) - Add member to group
  • morphGroups({ survivingGroupId, otherGroupId }) - Merge two groups

Likes:

  • addLike({ likerGroupId, targetGroupId }) - Add like
  • deleteLike({ likerGroupId, targetGroupId }) - Remove like
  • allLikesByGroupId(groupId) - Get { given: [], received: [] }

Member Management:

  • updateMemberNote({ groupId, userId, value }) - Update public note
  • updateMemberRole({ userId, groupId, role }) - Change role
  • updateStayAsSub({ groupId, userId, value }) - Toggle sub preference
  • kickMember({ groupId, userId }) - Owner kicks member

Tournament Integration:

  • cleanupForTournamentStart(tournamentId) - Delete groups, preserve stayAsSub members
  • getSubsForTournament(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 group
  • privateNotes - 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 group
  • LIKE / UNLIKE - Like/unlike another group
  • ACCEPT - Accept mutual like (triggers team creation/merge)
  • LEAVE_GROUP - Leave current group
  • KICK_FROM_GROUP - Owner kicks member
  • GIVE_MANAGER / REMOVE_MANAGER - Role management
  • UPDATE_NOTE - Update public note
  • UPDATE_STAY_AS_SUB - Toggle sub preference
  • REFRESH_GROUP - Refresh activity timestamp

ACCEPT Action Flow:

  1. Verify mutual like exists
  2. Check if either group has tournamentTeamId
  3. If neither: Create new TournamentTeam (use auto-generated name)
  4. Merge groups (use morphGroups)
  5. Add all members to TournamentTeamMember
  6. Send TO_LFG_TEAM_FORMED notification
  7. 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 GroupCard display pattern (weapons, VC, tier)
  • Reuse MemberAdder for quick-add trusted players
  • Reuse GroupLeaver component
  • 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 TournamentLFGGroup records
  • Preserve TournamentLFGGroupMember records where stayAsSub = 1 for 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.looking
  • lfg.join.header, lfg.join.stayAsSub, lfg.join.visibility
  • lfg.myGroup.header, lfg.myGroup.empty
  • lfg.groups.header, lfg.groups.empty
  • lfg.invitations.header, lfg.invitations.empty, lfg.invitations.accept
  • lfg.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 maxMembersPerTeam reached, 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

  1. Migration (100-tournament-lfg.js)
  2. Types (tables.ts + tournament-lfg-types.ts)
  3. Repository (TournamentLFGRepository.server.ts)
  4. Schemas (tournament-lfg-schemas.server.ts)
  5. Loader (to.$id.looking.server.ts)
  6. Action (to.$id.looking.server.ts)
  7. Route component (to.$id.looking.tsx)
  8. Tournament integration (tab, cleanup hook)
  9. Notifications (types + utils)
  10. Translations
  11. Testing

Verification

  1. 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
  2. Unit Tests:

    • Repository functions (create, merge, delete, likes)
    • Visibility filtering logic
  3. E2E Tests:

    • Full flow: join -> like -> accept -> team formed
    • Leave group
    • Kick from group
  4. Run checks:

    npm run checks