Proposal: Quests — Technical Design
Owner: Tom · 2026-06-18. This is the entities / enforcement half of the quests thread. It sits alongside:
- Quests as a Data Product — which quests are worth building (buyer value).
- Player-Tier KPIs — the tier values/thresholds audiences key off (Marco).
- The shared data-model diagram: Quests — Data Model · client mockups: Quests — Visual Mockups.
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_stateshipped aspending | paid | failed(nograntedstep — §9'spending → granted → paidis the aspirational flow). - CRM authoring (§7) — live at network-crm
/p2e/questsbehind the newquests.authoringRBAC resource (admin/cm read+write, vc/sales read). Writes go through the transactional service-role RPCcrm_upsert_quest(); lifecycle transitions follow §8's state machine. Seeplayroll-cpp/supabase/migrations/20260703160000_quests_authoring_rbac.sql. - Not shipped: client RPCs (§4), enforcement gates (§5), playroll-ui surfaces (§6), progress/rewards workers (§9).
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
lobbiestable. "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 toeligible_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_gameslists 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'sgame_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. linkreliable+top_driverswithany→ "Reliable players or anyone with the best-driver badge."all(AND) — must match every linked audience. E.g. linkreliable+eu_regionwithall→ "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 forauthenticated; 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 areSECURITY DEFINERRPC / service-role only (tier recompute, badge award).quest_group_progress,quest_completions: a participant may read rows for lobbies they belong to (mirror the existinglobby_participantsmembership predicate); writes areSECURITY DEFINERRPC / 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:
-
QuestGameGateinserted right afterGameDetectedGate. When the active lobby snapshot carries aquest_id, the gate confirms the detectedgame_slugis 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 aRecordingStartRejectedEvent, 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. -
Lobby snapshot carries
quest_id. ExtendLobbySnapshot(include/service/lobby_state.hpp) and the/api/lobby/setcallback (src/service/lobby_http_callbacks.cpp) so the UI passesquest_idwhen it sets the active lobby. It flows intoRecordingStartLobbySnapshotand onto therecording_sessionsrow at creation, immutably. -
Pre-arm RPC. Before arming, the core calls
validate_quest_recording(§4.4) instead ofvalidate_lobby_recordingwhenquest_idis present. A non-okresult 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_sessionsrow hasquest_idset, assert the row'sgame_slugis inquest_gamesfor that quest, and that the session'srecording_started_atfalls within[starts_at, ends_at]. Reject withERRCODE 23514and 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)
-
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.
-
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.
-
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 toquest_games; the chosen game becomes the lobby's bound game. Join validates the code and that the resulting lobby's quest matches. -
In-group recording state — reuse the recording island (
RecordingControlBar,data-statevariants) but in quest mode it shows the quest title, the locked game, group progress (qualified_secondstoward the requirement), and — critically — when a non-quest game is detected, an explicit "Not counted: this quest only records CS2" banner driven by theQuestGameGaterejection 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.authoringresource in the permissions matrix.adminandcmget write;vc/salesread-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 areact-hook-form+zodform covering every column in §3: title/subtitle/description/objective, a multi-select of games (populated fromeligible_games), the window pickers, party min/max, an audience picker (multi-select fromaudiences— "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 therequirementsJSON — 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, mirroringupsertOperator/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), andDELETE(soft →archived). All write via the service role and stampcreated_byfrom the authed email. - Publish flow: authoring happens in
draft; "Schedule" validates the window and games and moves toscheduled; the client only ever seesscheduled/active/endedrows (filtered byget_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_at — get_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:
- For each linked audience, evaluate its
rule— predicatesmin_tier,has_badge,region/allowlist, prior-completion (one-shot quests),requires_games_ownedagainstuser_game_library— combined as AND within that audience. - 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
- Migrations (cpp repo): enums first, then tables, then
lobbies/recording_sessionscolumns, then the extendeds3_uploadstrigger and the four RPCs. Idempotent (IF NOT EXISTS,ON CONFLICT DO NOTHING). - CRM authoring behind the
quests.authoringpermission — lets us create real quests before any client work ships. - Client read-only: Quests list + detail, no joining yet — validates the pull/listing path and the featured-quest hero.
- Group + enforcement:
create/join_quest_group, theQuestGameGate, and the pre-armvalidate_quest_recordingcall. - Progress + rewards: rollup worker,
quest_completions, reward crediting. - 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_atis stored alongside as a fairness/diagnostic split (slow-to-notice vs slow-to-deliver). Both live inquest_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+reliableonly, or also a top tier (pro/elite) from day one? And what concrete thresholds promoterookie → reliable(Marco to define the values). - Audiences — RESOLVED: a quest links to many audiences via
quest_audiences, combined byquests.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 = 1works, but do solo quests still mint a lobby (for uniform attribution) or attribute directly offrecording_sessions? Recommended: still mint a (single-member) lobby so one code path covers both. - Requirements DSL: how expressive does
requirementsneed 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?