N6/D3: the Shadow — a simulated rival adventurer

A per-player NPC rival who "runs" the same zone progression on a midnight
ticker at ~1.3x the player's own clear pace, staying just ahead so it's a
race you can always see and nearly catch. Pure theatre: no combat, no
punishment, only race pressure and two payoffs at each zone clear.

- New adventure_shadow table, deliberately OUTSIDE the player_meta save
  fan-out so a character save can never clobber the ticker's advance (the
  isolation journal_pages earns by being grant-only, made structural). No
  bootstrap: absent row == no Shadow, minted lazily on first advance.
- midnightReset advances every player's Shadow once per UTC day (own
  idempotency guard); lead-capped so it never runs >2.5 zones ahead. When it
  clears a zone the player hasn't, it leaves a journal page waiting (D1 tie-in).
- Morning-briefing race-pressure one-liners (TwinBee voice, deterministic).
- Zone-clear payoff in finalizeExpeditionOnZoneClear: a bonus-XP crow when the
  player got there first, or the Shadow's waiting page when it did.
- !adventure shadow status view.

Review fixes (3 finders + verify) folded in before commit:
- Crow XP is now set-once per zone (crowed_mask), so re-running a zone the
  Shadow hasn't reached can't farm it.
- The waiting page is granted BEFORE the pending bit is retired, so a transient
  grant failure leaves the debt for the next clear instead of swallowing a page.
- The crow line no longer claims "+XP" when the grant errored.

Combat golden byte-identical (Shadow never touches SimulateCombat); go
build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 17:26:16 -07:00
parent 81b2359109
commit cc165bed1f
7 changed files with 856 additions and 1 deletions

View File

@@ -2215,6 +2215,47 @@ CREATE TABLE IF NOT EXISTS expedition_invite (
);
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
ON expedition_invite(user_id);
-- ── N6/D3 — the Shadow: a simulated rival adventurer ──────────────────────
-- One row per player, born lazily the first midnight after their character
-- exists. The Shadow "runs" the same zone progression on a simulated schedule
-- (~1.3x the player's own clear pace), advanced once per UTC day by the
-- midnight ticker. Pure theatre: no combat, no punishment — only race
-- pressure surfaced in the morning briefing and a payoff at each zone clear.
--
-- Absent == the player has no Shadow yet, which is true of every row that
-- existed before N6, so there is nothing to backfill. The ticker mints the
-- row on first advance. This table is deliberately NOT part of the
-- player_meta save fan-out: the ticker owns it, so a character save can never
-- clobber the Shadow's advance (the same isolation journal_pages earns by
-- being grant-only, made structural here).
--
-- name: the rival's proper name, seeded deterministically from the
-- player's display name at birth.
-- progress: cumulative zone-units the Shadow has run (fractional, so a
-- slow player still sees it creep between clears).
-- zones_cleared: floor(progress) at the last advance — the committed count,
-- stored so the next advance can tell which zones were newly
-- finished.
-- pending_mask: zones (by progression index bit) the Shadow cleared before
-- the player did — a journal page waits in each until the
-- player clears that zone's boss.
-- crowed_mask: zones the player beat the Shadow to and has already been
-- crowed a bonus for — set-once, so re-running a zone the
-- Shadow hasn't reached can't farm the crow XP.
-- day_counter: the Shadow's own day count, for flavour variety.
-- last_advanced: UTC date of the last advance; the per-day idempotency guard.
CREATE TABLE IF NOT EXISTS adventure_shadow (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
progress REAL NOT NULL DEFAULT 0,
zones_cleared INTEGER NOT NULL DEFAULT 0,
pending_mask INTEGER NOT NULL DEFAULT 0,
crowed_mask INTEGER NOT NULL DEFAULT 0,
day_counter INTEGER NOT NULL DEFAULT 0,
last_advanced TEXT NOT NULL DEFAULT '',
born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.