Files
GmRelayBot/docs/superpowers/specs/2026-05-28-game-catalog-showcase-design.md

7.8 KiB

Game Catalog and One-Shot Showcase — Design Spec

Issue #39: feat: добавить каталог игр и витрину ваншотов


Goal

Build a public /showcase page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.


Architecture

Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in ISessionStore, and new Razor pages in GmRelay.Web. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.


Tech Stack

  • .NET 10, Blazor Server, Dapper.AOT, Npgsql
  • Existing: PublicLayout, ISessionStore, SessionService, SessionCapacityRules
  • New: GameSystem enum, ShowcaseFilter record, ShowcaseSessionDto

Data Model

New Fields on sessions (Migration V027)

Column Type Constraints Description
is_one_shot BOOLEAN NOT NULL DEFAULT false One-shot or campaign
system VARCHAR(50) nullable Game system name (enum value or custom)
description TEXT nullable Short description for card
cover_image_url TEXT nullable Cover image URL
duration_minutes INTEGER nullable Duration in minutes
format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')), nullable Session format
allow_direct_registration BOOLEAN NOT NULL DEFAULT false Allow direct registration from showcase

GameSystem Enum

public enum GameSystem
{
    Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
    OldSchoolEssentials, Dragonbane, BladesInTheDark,
    Daggerheart, CyberpunkRed, Mothership, AlienRpg,
    WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
    Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
    Ironsworn, Other
}

Stored as VARCHAR(50) in DB (not native enum) to allow future extension without migration.

DTOs

public sealed record ShowcaseSessionDto(
    Guid Id,
    Guid GroupId,
    string GroupName,
    string? GroupSlug,
    string Title,
    DateTime ScheduledAt,
    string Status,
    string? System,
    bool IsOneShot,
    string? Format,
    int? DurationMinutes,
    string? CoverImageUrl,
    int? MaxPlayers,
    int ActivePlayerCount,
    int WaitlistedPlayerCount,
    bool AllowDirectRegistration);

public sealed record ShowcaseFilter(
    DateFilter Date = DateFilter.All,
    SeatFilter Seats = SeatFilter.Any,
    GameSystem? System = null,
    bool? IsOneShot = null,
    string? Format = null);

public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }

UI Design

/showcase — Catalog Page

Layout:

  • Hero with title "Каталог игр"
  • Sticky filter bar (horizontal on desktop, collapsible on mobile)
  • Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
  • Pagination (page + pageSize = 12)

Filters:

  • Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
  • Seats: "Есть места" | "Waitlist" | "Любое"
  • System: dropdown with all GameSystem values
  • Type: "Ваншот" | "Кампания" | "Любое"
  • Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"

Card Design:

  • Cover image (fallback: colored placeholder with initials)
  • Title
  • System badge
  • Date + time (MSK)
  • Duration (e.g. "3 часа")
  • Format badge
  • Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
  • Club name (link to /club/{slug})
  • Buttons: "Подробнее" → /s/{id}, "Записаться" (if AllowDirectRegistration)

/s/{id} — Public Session Detail (Updated)

New fields added to existing page:

  • Cover image (full-width hero)
  • System badge
  • Description block
  • Duration + format
  • GM contact (always visible: Telegram username or Discord tag)
  • If allow_direct_registration:
    • "Записаться" button → Telegram Mini App deeplink or Discord OAuth
    • Direct registration into session_participants via SessionCapacityRules

Backend

ISessionStore Methods

Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
    ShowcaseFilter filter, int page, int pageSize);

Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);

Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);

GetShowcaseSessionsAsync query:

  • Cross-group (all clubs with public_schedule_enabled = true)
  • Only is_public = true sessions
  • scheduled_at > now() - interval '4 hours'
  • status <> 'Cancelled'
  • Apply filters in SQL WHERE clause
  • Order by scheduled_at ASC
  • Offset/limit pagination

RegisterFromShowcaseAsync:

  • Check allow_direct_registration = true
  • Load session with FOR UPDATE
  • Count active + waitlisted participants
  • Use SessionCapacityRules.DecideJoinStatus
  • Insert participant with appropriate registration_status
  • Return true on success, false if full and no waitlist allowed

Bot Integration

Telegram Bot

During CreateSessionCommand flow, after title/link/time input:

  1. "Выберите систему:" inline keyboard with GameSystem values + "Другое"
  2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
  3. "Описание игры (краткое):" — text input, optional (skip button)
  4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
  5. "Продолжительность (в часах):" — int input, optional
  6. "Обложка (URL или пропустить):" — text input, optional

During /publish flow:

  • "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)

Discord Bot

Same flow adapted for Discord interactions:

  • Slash command options or button menus for system/format
  • Modal input for description, duration, cover URL
  • Fuzzy matching on free-text system input

Migration V027

ALTER TABLE sessions
    ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN system VARCHAR(50),
    ADD COLUMN description TEXT,
    ADD COLUMN cover_image_url TEXT,
    ADD COLUMN duration_minutes INTEGER,
    ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
    ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;

CREATE INDEX ix_sessions_showcase
    ON sessions (scheduled_at, system, is_one_shot, format)
    WHERE is_public = true AND status <> 'Cancelled';

Testing Strategy

  1. Unit tests: SessionCapacityRules with showcase registration scenarios
  2. Integration tests: GetShowcaseSessionsAsync with each filter combination
  3. UI tests: Showcase.razor rendering with/without cover images, filters applied
  4. Bot tests: Fuzzy matching algorithm for GameSystem resolution

Version Bump

Issue label: type:featureminor bump Current: 3.3.0 → Next: 3.4.0

Files to sync:

  • Directory.Build.props
  • compose.yaml (bot, discord, web image tags)
  • .gitea/workflows/deploy.yml (VERSION env)
  • src/GmRelay.Web/Components/Layout/NavMenu.razor

Acceptance Criteria (from Issue #39)

  • User can find a published game without accessing a private dashboard
  • Registration does not bypass existing seat/waitlist limits
  • Owner/co-GM controls what appears in the showcase via is_public + allow_direct_registration
  • Filters work: date, seats, system, type, format
  • GM contact is always visible on public session detail
  • Direct registration respects SessionCapacityRules