Skip to main content

Proposal: Quests — Technical Design

Status — entities + CRM authoring shipped; enforcement/client not shipped

Owner: Tom · 2026-06-18. This is the entities / enforcement half of the quests thread. It sits alongside:

The canonical schema home for anything accepted here remains playroll-cpp/supabase/migrations. The entity layout below still needs to be reconciled against the in-flight Data Model section before publishing.

Shipped so far (2026-07-03):

  • Entities (§3) — all quest tables and enums are live in prod (baseline schema). One delta vs this doc: quest_payout_state shipped as pending | paid | failed (no granted step — §9's pending → granted → paid is the aspirational flow).
  • CRM authoring (§7) — live at network-crm /p2e/quests behind the new quests.authoring RBAC resource (admin/cm read+write, vc/sales read). Writes go through the transactional service-role RPC crm_upsert_quest(); lifecycle transitions follow §8's state machine. See playroll-cpp/supabase/migrations/20260703160000_quests_authoring_rbac.sql.
  • Not shipped: client RPCs (§4), enforcement gates (§5), playroll-ui surfaces (§6), progress/rewards workers (§9).
Updated 2026-06-19 — quests are now the cash engine

The Reward Economy decision makes cash the primary quest reward, now — not a future "post-training era". Read the reward sections below with three deltas: (1) kind='cash' is the mainline payout, priced per quest to its buyer value (see Quests as a Data Product); the "pre-training game keys vs post-training cash" era split is collapsed — cash is the model. (2) kind='xp' rewards now feed the Season tier track (cosmetics), not a cash level — EXP is fully decoupled from money. (3) Referrals are no longer hours-based; a referrer earns a set cash bounty when their referee completes a first quest. The entity model below already supports all of this (quest_reward_kind includes cash/xp/cosmetic); only the framing changes.

Quests are bespoke, time-boxed missions: "Play this game, in this window, doing this, with a group of N." They carry rewards, and they are the thing we want to push hardest in-product. This document specifies the data model, enforcement, client UX, and the standalone CRM authoring tool.

The guiding decision of this design: Quests do not invent a new group or recording-gate system — they ride on top of the lobby/game-binding spine we already shipped (lobbies, lobby_participants, validate_lobby_recording, and the s3_uploads BEFORE-INSERT trigger from 20260528170000_lobby_game_binding.sql). A "quest group" is a lobby with a quest_id attached. This keeps the blast radius small and reuses the hardest part — the tamper-resistant enforcement — unchanged.


0. Vocabulary (shared language)

The Slack thread used "group" two different ways. To stay aligned with Vincenzo and Alex, we fix four distinct terms:

  • Party (a.k.a. Lobby): the actual people recording together for one quest attempt — the live squad bound to a single game via the existing lobbies table. "Group of 5" / "MinPeople" refers to the party.
  • Audience: who a quest is offered to — a targeting set, not a squad. "All" and "Reliable" from the thread are audiences. An audience is defined by a rule (open to everyone, or "tier ≥ Reliable", or "has the top-hardware badge").
  • Tier: a player's progression level — ordered, earned, gamified (e.g. Rookie → Reliable → Pro). A player has exactly one current tier. Tier is what most audiences filter on.
  • Badge: an orthogonal achievement on top of tier ("reliable player", "top hardware", "best driver"). A player can hold many; badges can also be used as audience predicates.

So Tom's "groups = All / Reliable" are audiences; Marco's "player-tier" is the tier ladder those audiences mostly key off; "reliable" is both a tier and (via a rule) an audience. The squad you record with is the party.


1. Goals and non-goals

In scope: authoring quests in a CRM and pushing them to Supabase; the client pulling and listing active + upcoming quests; eligibility checks; selecting a quest and joining/creating a quest-tagged group; constraining recording to the quest's marked game(s) with both a client gate and a server-side hard boundary; attributing recordings to a quest and crediting rewards.

Out of scope for v1: quest-to-quest dependencies/chains, real-money payouts (rewards are defined and credited as ledger entries; disbursement reuses the existing payments path), social/leaderboard surfaces beyond a group roster, and matchmaking (players bring their own group via the existing invite-code flow).


2. Concepts and the relationship to lobbies

A Quest is an authored mission row with: a set of eligible games, an active window (starts_at / ends_at), group-size bounds (min_party / max_party), a free-form objective ("doing this"), structured requirements the backend can evaluate, and one or more rewards.

A quest group is a lobby with quest_id set. We reuse lobbies wholesale:

  • A lobby already binds to exactly one game (lobbies.game_slug, FK to eligible_games.slug) and already enforces that everyone in it records that game. Multi-POV co-recording requires the whole group on one game anyway, so a quest group is single-game by construction.
  • A multi-game quest is expressed as a quest whose eligible_games lists several slugs. When a player creates a group for that quest, they pick which of the quest's games this group is for; that choice becomes the lobby's game_slug. Different groups under the same quest can target different games.

This means the existing create_lobby / join_lobby / change_lobby_game / validate_lobby_recording machinery and the s3_uploads trigger keep working; quests add a thin attribution + eligibility + rewards layer around them.

Single-party invariant (important — there is no separate "quest group"). A user is only ever in one party at a time, and that party is a lobby. A quest party is simply a lobby with quest_id set; the existing standalone lobby ("play with friends, no quest") is the same row with quest_id NULL. So the UI must not present two parallel memberships: creating/joining a quest party routes through the same create_lobby / join_lobby path (auto-leaving any current lobby first, which create_lobby already does), just with the quest attached. Practically this means the client's createQuestGroup / joinQuestGroup are thin wrappers over the lobby IPC that already exists — when we wire the real backend, the quest "party" and the lobby become one object, and the lobby chip/entry simply shows the quest context when quest_id is set. (In the current fixture build the quest party is mocked separately from the live lobby state; unifying them is the first task of the IPC-wiring phase.)

Quest (CRM-authored)
├─ eligible_games: [cs2, valorant] ← multi-game allowed at quest level
├─ window: [starts_at, ends_at]
├─ party: [min_party, max_party]
├─ objective + requirements (JSON)
└─ rewards: [ ... ]

│ player selects quest → creates/joins a group

Lobby (quest_id set, game_slug = one of the quest's eligible_games)
├─ lobby_participants (the party)
└─ recording_sessions (quest_id stamped, game_slug bound) ← existing spine


s3_uploads ← existing BEFORE-INSERT trigger = hard boundary

3. Data model

New tables live in the canonical home — playroll-cpp/supabase/migrations — as timestamped, idempotent migrations (per the workspace migration-first rule). ALTER TYPE ... ADD VALUE is split into its own earlier migration from any migration that inserts rows using the new value.

3.1 quests

CREATE TABLE public.quests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL, -- stable handle for analytics
title TEXT NOT NULL,
subtitle TEXT, -- one-line hook for the card
description TEXT, -- markdown, shown on detail
objective TEXT NOT NULL, -- "doing this"
cover_url TEXT,
status quest_status NOT NULL DEFAULT 'draft',
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
min_party INT NOT NULL DEFAULT 1 CHECK (min_party >= 1),
max_party INT NOT NULL DEFAULT 5 CHECK (max_party >= min_party),
requirements JSONB NOT NULL DEFAULT '{}'::jsonb, -- machine-evaluable rules
audience_match audience_match NOT NULL DEFAULT 'any', -- ALL vs ANY across linked audiences (§3.5)
difficulty quest_difficulty, -- optional, Tom's "maybe"
reward_phase reward_phase NOT NULL DEFAULT 'post_training', -- keys vs cash era
priority INT NOT NULL DEFAULT 0, -- ordering / "push hardest"
capacity INT, -- NULL = unlimited parties
created_by TEXT NOT NULL, -- crm_user_roles.email
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (ends_at > starts_at)
);

-- enums (own earlier migration):
-- quest_status AS ENUM ('draft','scheduled','active','paused','ended','archived');
-- quest_difficulty AS ENUM ('easy','medium','hard');
-- reward_phase AS ENUM ('pre_training','post_training','any');
-- audience_match AS ENUM ('all','any'); -- AND vs OR across linked audiences

The reward_phase column captures Marco's split: pre-training quests reward game keys, post-training quests reward cash. It also lets the same quest template be reused as the program moves between phases, and lets analytics separate the two reward economies.

requirements JSONB — the pipeline-quest contract (implemented)

The free-form requirements JSONB is the bridge to the validation pipeline: the eval step (Pipeline Outputs) reads it through pipeline/quest_mapping.py (quest_from_db()). Recognized shape:

{
"min_qualified_minutes": 25, // product-level minimum
"min_party": 3, // mirror of the column (column wins)
"vlm_context": "…", // optional — primes the VLM per scenario
"semantic": { // optional — the qualitative / KPI layer
"extra_fields": [ { "name": "building_active", "type": "boolean",
"prompt": "…", "difficulty": "easy",
"use_as_kpi_gate": true } ],
"kpis": [ { "kpi_id": "building_active",
"expression": "building_active == true",
"aggregation": "duration_ratio" } ],
"thresholds": { "min_building_active_ratio": 0.3 }
},
"multi_pov": { "min_synced_povs": 3, "max_clock_skew_ms": 50 }, // axis B — not consumed by L0 single eval yet
"voice_required": true // axis A — not consumed yet
}

A quest with no semantic block is a numeric-tier quest (certified on fps / validated minutes / party size alone). Gate KPIs only on difficulty: easy fields — validate_pipeline_quest() warns otherwise and errors on thresholds that reference a non-existent KPI. This answers the v1 "how expressive does requirements need to be" open item: scalar minimums now, the semantic layer for qualitative quests, the rest forward-declared.

status is the authoring/lifecycle state (CRM-controlled). The display state the client shows (active vs upcoming) is derived from status + now() vs the window — see §6 — so a clock tick never needs a write.

3.2 quest_games — eligible games per quest

A junction table rather than a JSON array, so we get an FK to eligible_games.slug (the same integrity guarantee lobbies already have) and clean joins in the CRM.

CREATE TABLE public.quest_games (
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
game_slug TEXT NOT NULL REFERENCES eligible_games(slug)
ON UPDATE CASCADE ON DELETE RESTRICT,
PRIMARY KEY (quest_id, game_slug)
);

3.3 quest_rewards

CREATE TABLE public.quest_rewards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
kind quest_reward_kind NOT NULL, -- xp|cash|game_key|cosmetic|badge|tier_progress
amount NUMERIC, -- xp pts / cash / tier pts, by kind
currency TEXT, -- for cash rewards
award_badge_id UUID REFERENCES badges(id), -- for kind='badge' (§3.5)
label TEXT NOT NULL, -- "500 XP", "$10 bonus", "Steam key"
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
grant_policy quest_grant_policy NOT NULL DEFAULT 'on_completion',
speed_bonus JSONB -- optional fast-delivery bonus, see below
);
-- enums (own earlier migration):
-- quest_reward_kind AS ENUM ('xp','cash','game_key','cosmetic','badge','tier_progress')
-- quest_grant_policy AS ENUM ('on_completion','per_qualified_session')

kind = 'game_key' / 'cash' covers the pre/post-training split; 'badge' awards a badge (§3.5); 'tier_progress' is the small reward that nudges players up the tier ladder. speed_bonus encodes the "extra loot for delivering inside X" incentive Tom and Marco agreed on — e.g. {"within_hours": 6, "multiplier": 1.5} — so quick engagement can be rewarded either by a short window or by a speed bonus (the thread concluded those are "the same thing"; this supports both).

3.4 Attribution columns on existing tables

ALTER TABLE lobbies
ADD COLUMN quest_id UUID REFERENCES quests(id) ON DELETE SET NULL;

ALTER TABLE recording_sessions
ADD COLUMN quest_id UUID REFERENCES quests(id) ON DELETE SET NULL;

recording_sessions.quest_id is stamped at session-start from the lobby's quest_id (immutable for that recording, exactly like game_slug / lobby_id). It's the join key for progress and rewards.

3.5 Players: tiers, audiences, badges (the Marco/Tom layer)

This is the structured form of the Slack discussion. Tiers are the progression ladder, audiences decide who a quest is offered to (mostly by tier), and badges are orthogonal achievements.

-- Ordered progression ladder. Seeded with at least 'rookie' and 'reliable'.
CREATE TABLE public.player_tiers (
key TEXT PRIMARY KEY, -- 'rookie','reliable','pro',...
name TEXT NOT NULL,
rank INT NOT NULL UNIQUE, -- order; higher = more advanced
description TEXT,
thresholds JSONB NOT NULL DEFAULT '{}'::jsonb -- promote/demote rules (§ below)
);

-- One current-tier row per player + the metrics that drive it.
CREATE TABLE public.player_tier_state (
user_id TEXT PRIMARY KEY, -- playroll_users id
tier_key TEXT NOT NULL REFERENCES player_tiers(key) DEFAULT 'rookie',
responsiveness_p50_h NUMERIC, -- rolling median lead time (hours), see 3.5b
validated_hours NUMERIC NOT NULL DEFAULT 0, -- replaces uploaded-hours
completion_rate NUMERIC, -- quests completed / joined
tier_changed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Reusable named targeting rules. Each audience is ONE rule (a predicate or an
-- AND-of-predicates). 'all' has an empty rule = everyone.
CREATE TABLE public.audiences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL, -- 'all','reliable','top_drivers',...
name TEXT NOT NULL,
rule JSONB NOT NULL DEFAULT '{}'::jsonb -- e.g. {"min_tier":"reliable"} or {"min_tier":"pro","region":"EU"}
);

-- A quest links to one OR MANY audiences; quests.audience_match decides whether
-- the player must satisfy ALL of them (AND) or ANY of them (OR).
CREATE TABLE public.quest_audiences (
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
audience_id UUID NOT NULL REFERENCES audiences(id) ON DELETE RESTRICT,
PRIMARY KEY (quest_id, audience_id)
);

-- Orthogonal achievements.
CREATE TABLE public.badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL, -- 'reliable_player','top_hardware','best_driver'
name TEXT NOT NULL,
description TEXT,
icon_url TEXT,
criteria JSONB NOT NULL DEFAULT '{}'::jsonb -- optional auto-award rule
);

CREATE TABLE public.player_badges (
user_id TEXT NOT NULL,
badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE,
awarded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, badge_id)
);

A quest links to one or many audiences via quest_audiences, combined by quests.audience_match:

  • any (OR) — qualifies if the player matches at least one linked audience. E.g. link reliable + top_drivers with any → "Reliable players or anyone with the best-driver badge."
  • all (AND) — must match every linked audience. E.g. link reliable + eu_region with all → "Reliable players in the EU."

Each audience is itself a rule that can carry several predicates (evaluated as AND within the audience), so two levels of logic are expressible without a rule tree: any-of-audiences where each audience is an and-of-predicates, or all-of-audiences. "All" is the default audience with an empty rule (everyone); "Reliable" is {"min_tier":"reliable"}. The get_quests RPC (§4.1) evaluates this for the calling user, so the client never re-implements targeting. Marco owns the values in player_tiers.thresholds and audiences.rule; the schema just gives him typed places to put them. (Arbitrary nested AND/OR trees are deliberately out of scope until a real quest needs them — see §12.)

3.5b Participation — the metric fact table

This is the backbone for Marco's responsiveness metric and for the org-level question "if we require custom-sourced data, what's the timeline range to execute?" One row per player per quest attempt:

CREATE TABLE public.quest_participations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quest_id UUID NOT NULL REFERENCES quests(id),
user_id TEXT NOT NULL,
lobby_id UUID REFERENCES lobbies(id),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
first_validated_at TIMESTAMPTZ, -- first session that passed validation
validated_seconds BIGINT NOT NULL DEFAULT 0,
status quest_part_state NOT NULL DEFAULT 'joined',
-- 'joined' | 'recording' | 'delivered' | 'completed' | 'expired' | 'abandoned'
UNIQUE (quest_id, user_id, lobby_id)
);
-- quest_part_state enum in its own earlier migration.

Responsiveness / lead time is derived here, not stored as truth: lead_time = first_validated_at − quests.starts_at (and we can also track first_validated_at − joined_at to separate "slow to start" from "slow to deliver" — Marco to pick which feeds the tier). Rolling these per player gives player_tier_state.responsiveness_p50_h; rolling them per quest/audience answers the timeline-range business question directly. validated_seconds is the validated-hours source that replaces the current uploaded-hours footer bar.

3.6 Progress and completion

CREATE TABLE public.quest_group_progress (
lobby_id UUID PRIMARY KEY REFERENCES lobbies(id) ON DELETE CASCADE,
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
state quest_group_state NOT NULL DEFAULT 'forming',
-- 'forming' | 'in_progress' | 'completed' | 'expired' | 'abandoned'
qualified_seconds BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE public.quest_completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quest_id UUID NOT NULL REFERENCES quests(id),
lobby_id UUID REFERENCES lobbies(id),
user_id TEXT NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rewards_state quest_payout_state NOT NULL DEFAULT 'pending',
UNIQUE (quest_id, user_id, lobby_id)
);

Progress is computed server-side from recording_sessions rows that carry the quest_id and have passed uploadability (see §5) — never trusted from the client. A scheduled worker (or a trigger on recording_sessions status change) rolls qualified seconds up to quest_group_progress and, when the quest's requirements are satisfied, writes quest_completions and enqueues rewards.

3.7 RLS posture

  • quests, quest_games, quest_rewards, audiences, quest_audiences, player_tiers, badges: read for authenticated; write only via the service role (the CRM) — no direct client writes.
  • player_tier_state, player_badges, quest_participations: a player reads only their own rows (user_id = auth.uid()::text); writes are SECURITY DEFINER RPC / service-role only (tier recompute, badge award).
  • quest_group_progress, quest_completions: a participant may read rows for lobbies they belong to (mirror the existing lobby_participants membership predicate); writes are SECURITY DEFINER RPC / service-role only.
  • Authoring writes are gated by crm_user_roles (see §7), not RLS, because the CRM uses the service-role key.

4. RPCs (the client/server contract)

All follow the house pattern from the lobby migration: SECURITY DEFINER, auth.uid()::TEXT for identity, and a JSONB return with an error/code on failure. Granted to authenticated.

4.1 get_quests(p_scope TEXT DEFAULT 'visible') → JSONB

Returns the quests the calling user should see, each with: derived display state (active | upcoming | ended), eligibility verdict for this user (eligible bool + reason), eligible games (joined to eligible_games for display name + cover), rewards, party bounds, window, and the user's current group for that quest if any. This is the single call the client home/Quests view makes; it does the eligibility evaluation server-side so the client never re-implements the rules.

4.2 create_quest_group(p_quest_id UUID, p_game_slug TEXT) → JSONB

Thin wrapper that (a) re-checks eligibility + window + capacity, (b) verifies p_game_slug ∈ quest_games, then (c) delegates to the existing create_lobby logic and sets lobby.quest_id. Returns the same lobby shape the client already consumes, plus quest_id. Reuses the existing rate-limit and unique-code generation.

4.3 join_quest_group(p_code TEXT) → JSONB

Wraps join_lobby. After the join succeeds it asserts the lobby actually belongs to the quest the UI thinks it does and that the user is eligible + the quest window is open + max_party not exceeded (the quest cap can be tighter than lobbies.max_players). Rejects with a clean code otherwise.

4.4 validate_quest_recording(p_lobby_id UUID, p_game_slug TEXT) → JSONB

The quest-aware superset of the existing validate_lobby_recording. It calls the lobby validation first (participant? active? game matches the lobby's bound game?), then adds: quest is active and within window; user is still eligible; p_game_slug ∈ quest_games. Returns { ok, quest_id, lobby_game_slug } or a specific failure code (QUEST_ENDED, QUEST_GAME_NOT_ALLOWED, NOT_ELIGIBLE, …). This is what the C++ core calls before arming capture.


5. Enforcement — "record only the marked game(s)"

The chosen posture is client gate + server validation, achieved by extending the two layers that already exist rather than adding new ones.

Layer 1 — client gate (playroll-cpp recording pipeline)

The recorder already runs an ordered gate pipeline (makeRecordingStartGatePipeline() in include/service/recording_start_gates.hpp): AuthStartGate → LifecycleStartGate → GameDetectedGate → SettingsPolicyStartGate → CaptureAvailableStartGate → VoicePrerequisiteStartGate. The session starter (src/service/recording_session_starter.cpp) already calls hasLobbyGameMismatch(slug) and rejects with ErrorCode::LobbyGameMismatch.

We add one gate and widen the mismatch check:

  1. QuestGameGate inserted right after GameDetectedGate. When the active lobby snapshot carries a quest_id, the gate confirms the detected game_slug is in the quest's eligible set (from the locally cached quest payload — see config delivery below) and that the quest window is open. If not, it denies with a user-facing reason ("This quest only counts CS2 — you're running Valorant") and publishes a RecordingStartRejectedEvent, exactly like the other gates. Because a quest group is single-game, this is usually equivalent to the existing lobby-game check; the gate's real job is the window check and a clear quest-specific message.

  2. Lobby snapshot carries quest_id. Extend LobbySnapshot (include/service/lobby_state.hpp) and the /api/lobby/set callback (src/service/lobby_http_callbacks.cpp) so the UI passes quest_id when it sets the active lobby. It flows into RecordingStartLobbySnapshot and onto the recording_sessions row at creation, immutably.

  3. Pre-arm RPC. Before arming, the core calls validate_quest_recording (§4.4) instead of validate_lobby_recording when quest_id is present. A non-ok result blocks the start with the returned code.

Config delivery. ProcessMonitor already fetches eligible_games from Supabase every ~5 min and caches it on disk. We piggyback: the same refresh (or a sibling /api/quests/active fetch) pulls the user's joinable/active quests and their eligible-game sets into the same local cache, so the gate is a local lookup with no per-frame network call. Membership comes from the authed session; the quest payload is small.

Layer 2 — server validation (Supabase, the hard boundary)

Even if every client layer is bypassed, the database refuses contaminated data. The s3_uploads BEFORE-INSERT/UPDATE trigger already enforces that a lobby-tagged upload's game_slug matches the immutable recording_sessions row. We extend s3_uploads_enforce_lobby_game_match() (keeping its existing two-tier "session row is ground truth, lobby is fallback" precedence) with a quest clause:

  • If the matching recording_sessions row has quest_id set, assert the row's game_slug is in quest_games for that quest, and that the session's recording_started_at falls within [starts_at, ends_at]. Reject with ERRCODE 23514 and a quest-specific message otherwise.

This means a recording only ever counts toward a quest, and only ever persists, if its game is on the quest's allowlist and it happened in-window — enforced at the storage boundary, not on trust.

Additionally, the uploadability_status / block_reason columns that already exist on recording_sessions give us a softer signal channel: a session that uploads fine but fails a quest requirement (e.g. recorded a permitted game but the party was below min_party) can be marked block_reason = 'quest_unqualified' for CRM visibility without throwing away the data.


6. Client UX (playroll-ui)

The app is an Electron React app: CSS-module styling driven by design tokens in src/renderer/styles/tokens.css (dark canvas #0f1110, lime accent #c8f135, Lato), state-driven routing via LAUNCHER_VIEWS, and a renderer that talks to Supabase only through window.playroll.* IPC — never a direct client. Quests follow all three conventions.

6.1 Navigation

Add QUESTS: "quests" to LAUNCHER_VIEWS, a QuestsView.tsx under features/launcher/views/, and a left-nav entry. Because quests are the headline push, the Home view also surfaces a "Featured quest" hero card (highest-priority active quest the user is eligible for) that deep-links into the Quests view.

6.2 Screens (all mocked — see the HTML deliverable)

  1. Quest list — two segments, Active and Upcoming (derived state), rendered as cards: cover, title, objective, game pills, party-size badge, reward chip, and a countdown ("ends in 2d 4h" / "starts Fri"). Eligible quests get a lime CTA; ineligible ones are dimmed with the reason inline.

  2. Quest detail — full description, the structured requirements rendered as a checklist, the eligible games, rewards, and the group panel: either "Create group" + "Join with code", or, if already in a group for this quest, the live roster.

  3. Group setup — a modal mirroring the existing LobbyModal (Create / Join tabs, 6-char invite codes, expiry countdown, roster popover). For multi-game quests the Create tab adds a game picker constrained to quest_games; the chosen game becomes the lobby's bound game. Join validates the code and that the resulting lobby's quest matches.

  4. In-group recording state — reuse the recording island (RecordingControlBar, data-state variants) but in quest mode it shows the quest title, the locked game, group progress (qualified_seconds toward the requirement), and — critically — when a non-quest game is detected, an explicit "Not counted: this quest only records CS2" banner driven by the QuestGameGate rejection event, so the constraint is legible rather than mysterious.

6.3 IPC additions

window.playroll.getQuests(), createQuestGroup(questId, gameSlug), joinQuestGroup(code), getQuestGroupState(), each a thin main-process RPC over the functions in §4. Dev mode gets fixtures alongside the existing devSupabaseFixtures.generated.ts.


7. CRM authoring app (network-crm)

network-crm is the right home: Vite + React + Express + Tailwind/Radix, with an crm_user_roles-backed RBAC matrix (CASL on the client, authorize() middleware on the server) and a service-role Supabase client. It already has the exact CRUD pattern we need in pages/p2e-admins.tsx / p2e-operators.tsx (PageLayout + Panel + ModalDialog + React Query with cache invalidation).

7.1 What to add

  • RBAC: a quests.authoring resource in the permissions matrix. admin and cm get write; vc / sales read-only. Routes guarded by the existing <RouteGuard>.
  • Page: client/src/pages/p2e-quests.tsx — a list of quests with status chips and window/party columns, a "New quest" modal, and an editor. The editor is a react-hook-form + zod form covering every column in §3: title/subtitle/description/objective, a multi-select of games (populated from eligible_games), the window pickers, party min/max, an audience picker (multi-select from audiences — "All", "Reliable", … — with an ALL / ANY match toggle for how they combine), a reward phase toggle (pre-training keys vs post-training cash), optional difficulty, a rewards repeater (kind incl. game_key / cash / badge / tier_progress, plus optional speed bonus), and the requirements builder (a small structured form that serializes to the requirements JSON — start with "min party size", "min qualified minutes", "must include game X"). A sibling Tiers / Audiences / Badges admin page (same CRUD pattern) lets Marco manage the ladder, the targeting rules, and the badge catalogue.
  • Endpoints (server/routes.ts, mirroring upsertOperator / revokeOperator): GET /api/v1/p2e/quests, GET /api/v1/p2e/quests/:id, PUT /api/v1/p2e/quests (upsert quest + games + rewards in one transaction), POST /api/v1/p2e/quests/:id/transition (draft→scheduled→active→ended), and DELETE (soft → archived). All write via the service role and stamp created_by from the authed email.
  • Publish flow: authoring happens in draft; "Schedule" validates the window and games and moves to scheduled; the client only ever sees scheduled/active/ended rows (filtered by get_quests). This gives a safe staging area before a quest goes live.

A live status page for in-flight quests (groups formed, qualified hours, completions) is a natural follow-on, and a good fit for a Cowork artifact that pulls from Supabase on each open.


8. Lifecycle and state machines

Quest (status): draft → scheduled → active → ended → archived, with paused reachable from active (freezes new groups; existing groups keep their in-flight sessions valid until window close). The CRM owns these transitions; the client display state is derived, so no cron is needed just to flip a quest "live" at starts_atget_quests computes it.

Quest group (quest_group_progress.state): forming (lobby created, below min_party) → in_progress (party met, qualified recording happening) → completed (requirements satisfied) | expired (window closed first) | abandoned (everyone left). Transitions are server-side, driven by lobby_participants changes and qualified recording_sessions.


9. Rewards, tiers, and the progress bar

Rewards are defined in quest_rewards and credited into quest_completions (rewards_state: pending → granted → paid). Delivery reuses existing rails and respects reward_phase: pre-training quests grant game keys (kind='game_key'), post-training quests grant cash (kind='cash', enqueued against the existing payments/review path — quests don't introduce a new money-movement mechanism). XP, badges, and tier_progress write to the player's profile / player_tier_state. Granting is idempotent on (quest_id, user_id, lobby_id).

Tier progression. A worker recomputes player_tier_state from quest_participations: rolling responsiveness (lead time, §3.5b), validated hours, and completion rate, compared against player_tiers.thresholds. Crossing a threshold moves the player's tier_key and can fire a badge or a tier_progress reward — this is the "small things that push people to change tier" Marco described. Promotion widens the audiences the player qualifies for, which is the gamification loop.

Footer progress bar. Today it tracks uploaded hours; this moves to validated hours, sourced from quest_participations.validated_seconds (and solo recordings). Whether post-training sessions still contribute to the footer bar is an open product call (see §12) — the data supports either, since reward_phase and validation state are both on the record.


10. Eligibility / audience model

Eligibility is decided by the quest's audience set (§3.5), evaluated only server-side inside get_quests and re-checked in create_quest_group / join_quest_group. The evaluation is two-level:

  1. For each linked audience, evaluate its rule — predicates min_tier, has_badge, region/allowlist, prior-completion (one-shot quests), requires_games_owned against user_game_library — combined as AND within that audience.
  2. Combine the per-audience verdicts by quests.audience_match: ANY (the player passes if at least one audience matched) or ALL (every audience must match).

So "Reliable or best-driver badge" is two audiences linked with any; "Reliable and EU" is two audiences linked with all. The client renders the verdict + human reason it's handed; it never decides eligibility itself. Marco owns the rule values; the schema gives them a typed home.


11. Rollout plan

  1. Migrations (cpp repo): enums first, then tables, then lobbies / recording_sessions columns, then the extended s3_uploads trigger and the four RPCs. Idempotent (IF NOT EXISTS, ON CONFLICT DO NOTHING).
  2. CRM authoring behind the quests.authoring permission — lets us create real quests before any client work ships.
  3. Client read-only: Quests list + detail, no joining yet — validates the pull/listing path and the featured-quest hero.
  4. Group + enforcement: create/join_quest_group, the QuestGameGate, and the pre-arm validate_quest_recording call.
  5. Progress + rewards: rollup worker, quest_completions, reward crediting.
  6. Live status surface for the team (artifact or CRM page).

Each step is shippable and observable on its own; the database hard boundary (step 1's trigger) lands before any client can write quest-tagged uploads.


12. Open questions

  • Multi-game parties: v1 keeps a party single-game (one slug from the quest's set). Do we ever need a single party to satisfy a quest by recording several of its games? If so, that's a real lobby-model change (drop the one-game binding) and should be its own design.
  • Responsiveness anchor — RESOLVED (confirm with Marco): primary metric is lead time from quest.starts_at → first_validated_at (matches Marco's stated definition and answers the org timeline question); joined_at → first_validated_at is stored alongside as a fairness/diagnostic split (slow-to-notice vs slow-to-deliver). Both live in quest_participations (§3.5b).
  • Post-training hours in the footer bar: do post-training (cash-era) validated hours count toward the footer progress bar, or is the bar reserved for pre-training (key-era) contribution? (Tom's question to Alex.)
  • Initial tiers & thresholds: ship with rookie + reliable only, or also a top tier (pro/elite) from day one? And what concrete thresholds promote rookie → reliable (Marco to define the values).
  • Audiences — RESOLVED: a quest links to many audiences via quest_audiences, combined by quests.audience_match (all=AND, any=OR); each audience is itself an AND-of-predicates. Remaining call: do we ever need nested AND/OR (e.g. "(Reliable OR best_driver) AND EU")? Deferred until a real quest needs it — would require a rule tree, not a flat list.
  • Solo quests: min_party = 1 works, but do solo quests still mint a lobby (for uniform attribution) or attribute directly off recording_sessions? Recommended: still mint a (single-member) lobby so one code path covers both.
  • Requirements DSL: how expressive does requirements need to be in v1? Proposal: a small fixed set (min party, min qualified minutes, required game) with a typed builder in the CRM, expanded only as real quests demand it.
  • Reward disbursement authority: which role signs off cash rewards before granted → paid, and does that reuse the existing payments review exactly?