Challenges — Domain Spec¶
A challenge is the engagement unit — a rule, a window, an opt-in mode,
and optionally a reward curio. Completing it writes to your
challenge_progress audit and grants the reward (if any) into your
Tomoda Collection.
Mental model¶
Five axes describe any challenge:
| Axis | Values | Meaning |
|---|---|---|
Cadence (duration) |
permanent, day, week, month, special |
Window shape. Permanent = no expiry. Day/week/month = recurring. Special = custom start/end. |
| Tz mode | local, fixed |
Whether the window aligns to the user's local timezone (local) or to UTC (fixed). |
| Scope | user, circle |
What entity is evaluated. Circle scope lands when circles ship. |
| Opt-in mode | true / false |
Always-on (passive) vs opt-in (user must start). |
| Parent | nullable challenge FK | Children inherit the parent's opt-in. Engine only evaluates a child when the parent has a progress row. |
The rule itself is described in Game Rules.
Always-on vs opt-in¶
| Mode | Use for | Where it surfaces |
|---|---|---|
Always-on (optin=false, no parent) |
Permanent slow-burn achievements, global event challenges | Tomoda Collection's "In progress" once the first qualifying action moves it |
Opt-in (optin=true, no parent) |
Day / week / month quests; user-pulled engagement loops | Quests panel — pickable card; once started, moves to "In progress" |
Child (parent_challenge_id IS NOT NULL) |
Composition under a parent (e.g. four weeklies in a monthly arc) | Surfaces under the parent in both quests panel and progress views |
Children's own optin value is ignored — parent controls.
Lifecycle¶
| Phase | Entered when | Exit |
|---|---|---|
draft |
Admin saves | Admin publishes |
published |
Admin publishes (triggers triggers recompute + expiry scheduling) |
Window closes OR admin archives |
archived |
Expiry handler fires for time-bounded; admin archives manually for permanent | (Terminal) |
Time-bounded published challenges get an engine:expire_challenge Asynq
task enqueued at publish time, scheduled at ends_at for tz_mode='fixed'
or ends_at + 26h for tz_mode='local'. The handler hard-deletes
in-progress rows and flips status to archived. Completed rows persist
forever as audit.
Opt-in API¶
| Endpoint | Method | Action |
|---|---|---|
/api/v1/challenges/:id/start |
POST | Create challenge_progress(scope=user, scope_id=$uid, challenge_id, state='in_progress', leaf_state={}). Idempotent. 200 ok or already present; 409 if completed; 404 if unpublished/archived. |
No user-scope leave endpoint. Started rows persist until completion or scheduled expiry. The "I no longer want this" path is "don't engage", and the row gets cleaned up when the window closes. Circle leave + reset lands when circles ship.
Progress¶
challenge_progress (scope_kind, scope_id, challenge_id) PK row, plus
state ∈ {in_progress, completed}, completed_at, leaf_state JSONB.
- Lazy creation: always-on challenges only get a row when something actually moves (or when the user opts in for opt-in challenges).
- Single state transition:
in_progress → completed. No "abandoned" state (cleanup at expiry). leaf_state: per-leaf persistent state — sequence anchors, matched_id lists for stateful leaves. Format mirrors the rule tree.
Retroactive eligibility¶
Admin can trigger a retroactive replay on a published challenge — every existing user's historical travel log is walked through the engine, with grants applied where they qualify. Only a narrow subset of challenges are eligible:
| Challenge shape | Replay-eligible |
|---|---|
Permanent + always-on (optin = false) + top-level (no parent) |
✓ |
Opt-in (optin = true) |
✗ — user must explicitly start |
| Day / week / month / special cadence | ✗ — time-bounded windows don't apply to history |
| Child of any parent | ✗ — inherits parent's opt-in state |
Auto-enqueue on publish or republish is off; admin must click Replay explicitly. Idempotent: grants are PK-guarded so re-running is safe.
Reward grant¶
On completion: if reward_curio_id IS NOT NULL, the engine grants the
curio via user_curios_earned PK + INSERT … ON CONFLICT DO NOTHING.
Audit row always written to curio_earn_events with
source = 'challenge', source_id = challenge_id.
See also¶
- Challenges narrative
- Game Rules — the rule expression language
- Tomoda Collection — where rewards land
- Game Engine service — runtime + scheduling