Skip to content

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 matches with a stage and a group and the scorecard and match board show them; /tournament-board and 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 → ⑦ 기록 Record

These stages never change. What varies is who/when triggers them and what policy governs each.

The six axes (GenerationPolicy, generation-policy.entity.ts) ​

AxisFieldValuesActive today
1 · Timingtimingahead · at_start · duringat_start (default)
2 · Triggertriggermanual · auto_on_confirm · auto_on_deadlinemanual (default)
3 · Lifecyclelifecyclelive · frozenfrozen (default)
4 · PoolpoolModeall_confirmed · host_selected · opt_inall_confirmed (default) · host_selected (the picked list)
5 · Recordingrecordingrecorded · casualderives from session.isFriendly (00047: friendly ≠ ELO)
6 · Compositioncompositionrotation · manual · seededrotation (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 casetimingtriggerlifecyclepoolrecordingcomposition
시작 시 생성 (shipped S2a)at_startmanualfrozenall_confirmedrecordedrotation
사전 자동 라이브 (UC1)aheadauto_on_confirm + deadline lockliveall_confirmedrecordedrotation
진행 중 부분 생성 (UC3)duringmanualfrozenhost_selected + opt-outmixedrotation/manual
킹오브코트 · 래더 (shipped P6)*duringmanualrollingall_confirmedrecordedn/a — via styleId
시드 브라켓 (tournament)ahead/at_startmanualfrozenall_confirmedrecordedseeded
기록·캐주얼 혼합anyanyanyanyper-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) ​

  1. Pool + Recording (UC3) — mid-session subset + per-player opt-out/casual. Mostly UI; reuses isFriendly (session) + nature (per-match). Highest-value, lowest-cost.
  2. Recurrence template — persist a policy, reuse it across a session recurrence.
  3. 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.
  4. Composition — seeded brackets — seeded (tournament) draws. The bracket.rules / swiss.rules / tournament.rules engine strategies already exist. (King-of-Court/ladder was originally planned to activate via this axis's results_driven value; it instead shipped 2026-07-16 via the pre-existing styleId → STRATEGY_REGISTRY seam + each strategy's own family field — 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_id holds the chosen preset (social, competitive, fixed_doubles, random, singles_social, singles_competitive, singles_random; CHECK mirrored by check-rotation-preset-parity.mjs); generation_style_id keeps 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) with generation_pool_user_ids (ids only). trigger, lockAt and lifecycle stay derived until a consumer exists; the entity carries eight fields (the table above lists six: lifecycle also knows rolling, and recording is derived from is_friendly).
  • A tournament's matches are rows in matches with stage (group | knockout | swiss | compass), group_label and scheduled_start; the queue is a scheduled match on court 0; create_tournament_groups writes the group stage, make_knockout the 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 through generate_session_round with a stage. tournaments is the header (format, config, status, current_round); its JSONB state is gone.
  • A player's window (available_from_round .. available_until_round, derived on write from arrives_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) sets generation_pool_mode and generation_pool_user_ids under private.lock_round_session (the helper generate_session_round calls: 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_confirmed stores '{}' (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 carry write). 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's PeoplePickerSheet, 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 shape make_knockout could not pair (private.knockout_shape_valid, private.assert_tournament_shape, hint tournament_config_invalid; a single group of two teams is refused: the app's GROUP_SIZE_MIN is 3). The crossing exists twice, private.knockout_pair_slots and knockoutShape (bracket-slots.ts), held equal by one case table (knockout-cases.ts, the pgTAP copy between its CASE TABLE markers) and check: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) (beside advancersAllowed, 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's tournament_config_invalid keeps its own line under the band (a stale view or a direct call). advancersAllowed and assert_tournament_shape are held equal by one case table (group-stage-shape-cases.ts, the pgTAP copy in group_stage_shape_parity.test.sql) and check:group-stage-shape-parity; groupStageFits is true exactly where the table lists a count.

Markdown remains the source of truth. Run yarn docs:check before handoff.