Conventions › Match Generation
Status: Reference — Spine established 2026-07-14 (owner-directed). Only the recording axis is active; every other axis is modeled but returns its default until its feature lands.
Decided, not yet built (owner review 48, batch 4, 2026-10-29; the lead's call for veto): a tournament becomes stages of its session's own matches (예선 리그 groups, then a 본선 knockout), so its matches live in
matcheswith a stage and a group and the scorecard and match board show them;/tournament-boardand its winner picks retire (research § 8, the library's § 10 Live pages). This page describes the code as it runs today.
Match generation is one pipeline, parameterized by a policy — not a set of separate flows. Every way a club runs matches (generate ahead / at kickoff / mid-session; auto or manual; everyone or a hand-picked few; recorded or casual; rotation or bracket) is the same pipeline read with a different GenerationPolicy. Build the axes as an explicit policy; each new club preference becomes a policy value, not a new code path.
The invariant pipeline
① 대상 Pool → ② 구성 Configure → ③ 생성 Generate → ④ 검토 Review → ⑤ 확정 Commit → ⑥ 시작 Activate → ⑦ 기록 RecordThese stages never change. What varies is who/when triggers them and what policy governs each.
The six axes (GenerationPolicy, generation-policy.entity.ts)
| Axis | Field | Values | Active today |
|---|---|---|---|
| 1 · Timing | timing | ahead · at_start · during | at_start (default) |
| 2 · Trigger | trigger | manual · auto_on_confirm · auto_on_deadline | manual (default) |
| 3 · Lifecycle | lifecycle | live · frozen | frozen (default) |
| 4 · Pool | poolMode | all_confirmed · host_selected · opt_in | all_confirmed (default) · host_selected (the picked list) |
| 5 · Recording | recording | recorded · casual | derives from session.isFriendly (00047: friendly ≠ ELO) |
| 6 · Composition | composition | rotation · manual · seeded | rotation (default) |
Plus styleId (the 방식 rotation preset, null = format default) and lockAt (when a live draft freezes).
DEFAULT_GENERATION_POLICY encodes the shipped behavior: a host manually makes a frozen draw from all confirmed players via rotation, recorded, at session start. deriveGenerationPolicy(session) returns that default with only recording swapped for friendly sessions. It reads only existing session fields — no persisted policy column, no UI, no behavior change.
Use cases are coordinates, not flows
| Use case | timing | trigger | lifecycle | pool | recording | composition |
|---|---|---|---|---|---|---|
| 시작 시 생성 (shipped S2a) | at_start | manual | frozen | all_confirmed | recorded | rotation |
| 사전 자동 라이브 (UC1) | ahead | auto_on_confirm + deadline lock | live | all_confirmed | recorded | rotation |
| 진행 중 부분 생성 (UC3) | during | manual | frozen | host_selected + opt-out | mixed | rotation/manual |
| 킹오브코트 · 래더 (shipped P6)* | during | manual | rolling | all_confirmed | recorded | n/a — via styleId |
| 시드 브라켓 (tournament) | ahead/at_start | manual | frozen | all_confirmed | recorded | seeded |
| 기록·캐주얼 혼합 | any | any | any | any | per-match (nature) | any |
* King-of-Court/ladder shipped 2026-07-16 via the pre-existing Session.generationStyleId → STRATEGY_REGISTRY seam (registry.ts) + the king_of_court strategy's own live family:'ladder' field — it bypasses the trigger/composition axes entirely rather than setting a GenerationPolicy value. The trigger/composition results_driven members that were reserved for this use case were retired 2026-07-18 as a result (T-17); nothing ever assigned or read them.
Activation path (build one axis at a time)
- Pool + Recording (UC3) — mid-session subset + per-player opt-out/casual. Mostly UI; reuses
isFriendly(session) +nature(per-match). Highest-value, lowest-cost. - Recurrence template — persist a policy, reuse it across a session recurrence.
- Timing + Trigger (UC1) — auto-generation. Needs a regen trigger (server hook on RSVP change, or an edge fn at the deadline) + lock semantics + member-facing provisional draws. Build when a club asks.
- Composition — seeded brackets —
seeded(tournament) draws. Thebracket.rules/swiss.rules/tournament.rulesengine strategies already exist. (King-of-Court/ladder was originally planned to activate via this axis'sresults_drivenvalue; it instead shipped 2026-07-16 via the pre-existingstyleId→STRATEGY_REGISTRYseam + each strategy's ownfamilyfield — P6, superseding this plan. See the use-case table footnote above and T-17.)
Principle: the engine (generateAllRounds, pure) stays composition-agnostic; the policy makes the axes explicit; orchestration invokes the engine per policy; surfaces (참여 CTA · preview · board) adapt to the policy. One implementation per pattern.
Formats: every generator covers singles and doubles
The owner, 2026-10-31: "make it possible for most match generators, unless they are specific to a format, to be able to cover both singles and doubles." Every registered round strategy reads the court size from the config's format (playersPerCourt: 2 in singles, 4 in doubles) and seats one player a side in singles and two in doubles: rotation (its presets have singles versions), americano (KDK), mexicano (순위매칭전, adjacent ranks in singles) and king_of_court (코트승급전, a ladder of one player a side in singles). The tournament engines take an entrant as a player or a pair. The one format-specific style is the 고정 복식 preset, since fixed pairs are doubles by definition, so presetsForSessionFormat offers it to doubles only. A floor that depends on the court size is derived from it, never typed (kingOfCourtEligible: 2 courts, so 8 players in doubles and 4 in singles). matchmaking/format-coverage.test.ts runs every registered round strategy in both formats; a new strategy that ignores the format fails there, and one specific to a format must be excluded there by name, with its reason.
Session completion mode
Who ends a session is a per-row fact, sessions.auto_complete_matches (00683), never a function of the style id. generate_session_round (the only round-commit path, via createBatch(matches, sessionId)) sets it false atomically with the round; private.session_matches_can_complete and the cron/cascade paths read only that column. Sessions that already used a rolling style were backfilled once in 00683; that SQL list is mirrored by INCREMENTAL_ROUND_STYLE_IDS (generation-policy.entity.ts) behind incremental-round-styles-parity.test.ts. A new rolling style needs no migration: committing its rounds through generate_session_round is what marks it.
Strategy & preset copy (LOCALIZED-TEXT)
Rotation-preset (RotationPreset, the 방식 picker) and tournament-strategy (TournamentStrategy) display copy is never a { ko, en } literal carried on the domain object. The domain carries an id only; the words live in the i18n registry keyed by that id (matchBoard.presetLabel/presetDescription, tournaments.strategyLabel/strategyDescription). The ONE lookup is packages/app/src/presentation/utils/matchmaking-copy.ts (rotationPresetLabel/rotationPresetDescription/tournamentStrategyLabel/tournamentStrategyDescription) — never a per-screen locale ternary (getLocale() === 'ko' ? … : …) or a second id→label map. A strategy registered before its copy lands degrades to its raw id as the label (or nothing for the description) instead of throwing. This replaced three hand-written locale ternaries and a dead duplicate preset table (owner ruling, improvement plan 2026-09-23 — evidence: decision log, ROUND 3).
Full rationale + the architecture artifact: the S2 design discussion (2026-07-14).
Persisted on the session (00708, 2026-11-02)
sessions.rotation_preset_idholds the chosen preset (social,competitive,fixed_doubles,random,singles_social,singles_competitive,singles_random; CHECK mirrored bycheck-rotation-preset-parity.mjs);generation_style_idkeeps meaning the engine (americano,mexicano,king_of_court,round_robin_groups,bracket,swiss,compass; CHECK + parity).- Of the policy axes only what a screen reads is persisted:
generation_timing(ahead | at_start | during),generation_pool_mode(all_confirmed | host_selected) withgeneration_pool_user_ids(ids only).trigger,lockAtandlifecyclestay derived until a consumer exists; the entity carries eight fields (the table above lists six:lifecyclealso knowsrolling, andrecordingis derived fromis_friendly). - A tournament's matches are rows in
matcheswithstage(group | knockout | swiss | compass),group_labelandscheduled_start; the queue is a scheduled match on court 0;create_tournament_groupswrites the group stage,make_knockoutthe first knockout round (1A–2B, 1C–2D, 1B–2A, 1D–2C) and locks the group stage (group_stage_locked); later rounds and Swiss/Compass pairings go throughgenerate_session_roundwith a stage.tournamentsis the header (format, config, status, current_round); its JSONBstateis gone. - A player's window (
available_from_round..available_until_round, derived on write fromarrives_at/leaves_at) bounds the rounds they play; outside it they are benched without a fairness slot. - The pool axis is written by one RPC (00749, F3b).
public.set_session_generation_pool(p_session_id, p_mode, p_user_ids)setsgeneration_pool_modeandgeneration_pool_user_idsunderprivate.lock_round_session(the helpergenerate_session_roundcalls: roster lock, session row lock,private.is_session_staff, a draft, open, locked or in-progress session). The picked list is non-empty, distinct, every person going, at most the session's capacity;all_confirmedstores'{}'(the column is NOT NULL). Hints:pool_mode_invalid,pool_users_invalid,pool_user_not_going,pool_over_capacity,session_not_active. The write is online only (the picker's footer and the 전원 option carrywrite). The draw reads the list at draw time: exactly the picked people who are still going and settled; a picked person who cancelled drops out, a later answer is not added, the next round reuses the saved list. The people picker is the kit'sPeoplePickerSheet, the same body as 단톡방 만들기; never a second picker. - 진출 인원 is 1 or 2 (00748, F3b). A 대회 with groups advances one or two per group; three would need byes, which nothing draws. One group advances two; two, four or eight groups advance one or two; never more than the smallest group less one (
advancersAllowed(groupCount, smallestGroupSize)). The server refuses a shapemake_knockoutcould not pair (private.knockout_shape_valid,private.assert_tournament_shape, hinttournament_config_invalid; a single group of two teams is refused: the app'sGROUP_SIZE_MINis 3). The crossing exists twice,private.knockout_pair_slotsandknockoutShape(bracket-slots.ts), held equal by one case table (knockout-cases.ts, the pgTAP copy between itsCASE TABLEmarkers) andcheck:knockout-pairs-parity; the two-advancer pairing is the 00708 crossing, unchanged. The page holds a group stage the server would refuse (batch 52).groupStageFits(plan)(besideadvancersAllowed, the same arithmetic) is false where no advancing count fits the groups: two teams at any group size, and 7, 13, 14 or 15 teams at a group size of 3. The 구성 step then draws the Notice 이 조 구성으로는 본선을 만들 수 없어요 (sessionDraw.shapeRefused, after the minimum and before the waiting scores), the band's 토너먼트 만들기 is soft, and the press makes nothing; changing the method or the 조별 인원 clears it. The server'stournament_config_invalidkeeps its own line under the band (a stale view or a direct call).advancersAllowedandassert_tournament_shapeare held equal by one case table (group-stage-shape-cases.ts, the pgTAP copy ingroup_stage_shape_parity.test.sql) andcheck:group-stage-shape-parity;groupStageFitsis true exactly where the table lists a count.