From cc165bed1fc6f9576912331f44fd9f878b2af36b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:26:16 -0700 Subject: [PATCH] =?UTF-8?q?N6/D3:=20the=20Shadow=20=E2=80=94=20a=20simulat?= =?UTF-8?q?ed=20rival=20adventurer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/db/db.go | 41 ++ internal/plugin/adventure.go | 3 + internal/plugin/adventure_scheduler.go | 6 + internal/plugin/adventure_shadow.go | 432 ++++++++++++++++++++++ internal/plugin/adventure_shadow_test.go | 360 ++++++++++++++++++ internal/plugin/dnd_expedition_cycle.go | 6 + internal/plugin/dnd_expedition_extract.go | 9 +- 7 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 internal/plugin/adventure_shadow.go create mode 100644 internal/plugin/adventure_shadow_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 31a08f5..f06365e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 9a21ff4..ff1b023 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -535,6 +535,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):])) case lower == "journal": return p.handleJournalCmd(ctx) + case lower == "shadow": + return p.handleShadowCmd(ctx) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -567,6 +569,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus ` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps ` + "`!adventure journal`" + ` — Read the campaign pages you've recovered +` + "`!adventure shadow`" + ` — See how your rival's run compares to yours ` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead) ` + "`!thom`" + ` — Visit Thom Krooke (housing and loans) ` + "`!adventure help`" + ` — This message diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 02bea94..7f753a7 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -420,6 +420,12 @@ func (p *AdventurePlugin) midnightReset() error { dmsSent := 0 for _, char := range chars { + // Advance this player's Shadow (N6/D3) once for the day, before any + // streak/idle branching below — the rival runs whether or not the + // player did, which is the whole point of the race pressure. Own table, + // own idempotency guard; never touches char, never fails the reset. + p.advanceShadow(&char) + // Died inside the window — no shame, no streak change. Covers both // currently-dead players and players revived at midnight (Alive // already flipped to true by the reminder loop before this runs). diff --git a/internal/plugin/adventure_shadow.go b/internal/plugin/adventure_shadow.go new file mode 100644 index 0000000..dc5ca12 --- /dev/null +++ b/internal/plugin/adventure_shadow.go @@ -0,0 +1,432 @@ +package plugin + +import ( + "database/sql" + "fmt" + "hash/fnv" + "log/slog" + "strings" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// The Shadow (N6/D3) — a per-player simulated rival adventurer. It "runs" the +// same zone progression the player does, advanced once per UTC day by the +// midnight ticker at ~1.3x the player's own long-run clear pace, so it stays +// just ahead: a rival you can always see and always nearly catch. +// +// It is pure theatre. There is no Shadow combat, no punishment, no state the +// player can lose. The only mechanics are race pressure (a morning-briefing +// line telling you where it is relative to you) and two payoffs at each zone +// clear: beat the Shadow to a zone and TwinBee crows a little bonus XP; let the +// Shadow clear it first and it leaves a journal page waiting in the boss room +// (the D1 campaign tie-in). All Shadow narration TwinBee speaks is first-person, +// he/him, implicit-subject — the campaign voice rules (feedback_twinbee_voice, +// feedback_twinbee_is_male). The Shadow itself is referred to as "the Shadow" +// (or by its name); "he" in TwinBee's lines is TwinBee, never a third-person +// "TwinBee". + +const ( + // shadowPaceMultiplier keeps the Shadow just ahead: it clears at ~1.3x the + // player's own long-run pace. + shadowPaceMultiplier = 1.3 + // shadowMinDailyStep is the floor on a day's advance, so a brand-new or idle + // player still watches the rival creep forward rather than sit frozen. + shadowMinDailyStep = 0.2 + // shadowMaxDailyStep caps a day's advance at just under one zone, so even a + // prolific player never sees the Shadow clear two zones overnight. + shadowMaxDailyStep = 1.0 + // shadowMaxLead caps how far ahead the Shadow may run — it is a race, not a + // runaway. It will never sit more than ~2.5 zones past the player's own count. + shadowMaxLead = 2.5 + // shadowCrowXPPerTier scales the "you beat me here" bonus by zone tier, so a + // T5 crow is worth more than a T1 one. Small on purpose (lift, not a spike). + shadowCrowXPPerTier = 12 +) + +// shadowState mirrors the adventure_shadow row. +type shadowState struct { + UserID id.UserID + Name string + Progress float64 + ZonesCleared int + PendingMask int64 + CrowedMask int64 + DayCounter int + LastAdvanced string +} + +// The Shadow walks zoneOrder — the design-doc order the zone registry is built +// in — start to finish, tier 1 → 5. Bit i of a Shadow's masks is zoneOrder[i]. + +// shadowZoneIndex returns the progression index of a zone, or -1 if it is not +// on the path (the arena, an unknown id). +func shadowZoneIndex(z ZoneID) int { + for i, id := range zoneOrder { + if id == z { + return i + } + } + return -1 +} + +func shadowSetBit(mask int64, idx int) int64 { + if idx < 0 || idx > 62 { + return mask + } + return mask | (int64(1) << idx) +} + +func shadowClearBit(mask int64, idx int) int64 { + if idx < 0 || idx > 62 { + return mask + } + return mask &^ (int64(1) << idx) +} + +func shadowBitSet(mask int64, idx int) bool { + if idx < 0 || idx > 62 { + return false + } + return mask&(int64(1)< shadowMaxDailyStep { + step = shadowMaxDailyStep + } + + newProg := s.Progress + step + // Keep it a race: never more than shadowMaxLead zones past the player's count. + if leadCap := float64(playerCount) + shadowMaxLead; newProg > leadCap { + newProg = leadCap + } + if maxProg := float64(len(zoneOrder)); newProg > maxProg { + newProg = maxProg + } + if newProg < s.Progress { + newProg = s.Progress // never regress (a lead cap that dropped below current) + } + + // Any progression zone the Shadow newly finishes, and the player hasn't, + // leaves a page waiting. Compare per-zone (not by count): the Shadow walks + // in order while the player may clear out of order, so "did the Shadow clear + // zone i first" is a per-zone question. + newCleared := int(newProg) + if newCleared > len(zoneOrder) { + newCleared = len(zoneOrder) + } + for idx := s.ZonesCleared; idx < newCleared; idx++ { + if !cleared[zoneOrder[idx]] { + s.PendingMask = shadowSetBit(s.PendingMask, idx) + } + } + + s.Progress = newProg + s.ZonesCleared = newCleared + s.DayCounter++ + s.LastAdvanced = today + if err := upsertShadow(s); err != nil { + slog.Warn("shadow: advance upsert", "user", char.UserID, "err", err) + } +} + +// shadowPlayerAgeDays estimates how many days the player has been adventuring, +// clamped to at least 1 so a same-day character never divides by zero. Used as +// the denominator of the player's pace. +func shadowPlayerAgeDays(char *AdventureCharacter) float64 { + if char.CreatedAt.IsZero() { + return 1 + } + days := time.Since(char.CreatedAt).Hours() / 24 + if days < 1 { + return 1 + } + return days +} + +// shadowOnPlayerZoneClear is the payoff, fired when the player clears a zone's +// boss. Three outcomes: the Shadow left a page here (pending bit) → grant the +// page and clear the bit; the player beat the Shadow to this zone → a crow plus +// a little bonus XP; or the Shadow already passed through with nothing owed → +// nothing. Returns the narration line to append to the clear message ("" for +// the silent case, or when the Shadow hasn't been born yet). +func (p *AdventurePlugin) shadowOnPlayerZoneClear(userID id.UserID, zoneID ZoneID) string { + s, err := loadShadow(userID) + if err != nil || s == nil { + return "" + } + idx := shadowZoneIndex(zoneID) + if idx < 0 { + return "" + } + + if shadowBitSet(s.PendingMask, idx) { + // The Shadow got here first and left a page. Grant it BEFORE retiring the + // debt: grantJournalPage returns "" both when the campaign is already + // complete and on a transient DB error, so clear the pending bit only once + // the page has actually landed (or the ledger is genuinely full). A + // transient failure then leaves the bit set and the next clear retries, + // rather than swallowing a page the player earned. + pageLine := p.grantJournalPage(userID, nil) + mask, _ := loadJournalPages(userID) + if pageLine == "" && !journalComplete(mask) { + slog.Warn("shadow: waiting page grant failed; leaving debt for retry", + "user", userID, "zone", zoneID) + return "" + } + s.PendingMask = shadowClearBit(s.PendingMask, idx) + if err := upsertShadow(s); err != nil { + // The page is already granted; a failed bit-clear can at worst re-grant + // a different unfound page on a later re-clear, which is benign (pages + // are collectible and the campaign self-limits). Better than losing it. + slog.Warn("shadow: clear pending bit", "user", userID, "zone", zoneID, "err", err) + } + if pageLine == "" { + // Ledger already complete — nothing to leave, but the Shadow was first. + return shadowBeatYouHereLine(s) + } + return shadowLeftPageLine(s) + "\n" + pageLine + } + + // No page owed. If the Shadow hasn't reached this zone yet, the player beat + // it here — crow and hand over a small bonus, but only once per zone: the + // crowed bit makes re-running a not-yet-reached zone unable to farm the XP. + if int(s.Progress) <= idx && !shadowBitSet(s.CrowedMask, idx) { + xp := shadowCrowXP(zoneID) + if _, err := p.grantDnDXP(userID, xp); err != nil { + // Don't promise XP we didn't grant, and don't burn the crow — leave the + // bit unset so the next clear can crow for real. + slog.Warn("shadow: crow xp", "user", userID, "err", err) + return "" + } + s.CrowedMask = shadowSetBit(s.CrowedMask, idx) + if err := upsertShadow(s); err != nil { + slog.Warn("shadow: set crowed bit", "user", userID, "zone", zoneID, "err", err) + } + return shadowCrowLine(s, xp) + } + return "" +} + +// shadowCrowXP is the bonus for beating the Shadow to a zone, scaled by tier. +func shadowCrowXP(zoneID ZoneID) int { + tier := 1 + if z, ok := getZone(zoneID); ok { + tier = int(z.Tier) + } + if tier < 1 { + tier = 1 + } + return tier * shadowCrowXPPerTier +} + +// shadowBriefingLine is the morning-briefing race-pressure beat: where the +// Shadow stands relative to the player. Deterministic by day so a re-rendered +// briefing reads the same, and silent until the Shadow has been born and has +// something to say. TwinBee's voice. +func (p *AdventurePlugin) shadowBriefingLine(e *Expedition) string { + userID := id.UserID(e.UserID) + s, err := loadShadow(userID) + if err != nil || s == nil { + return "" + } + playerCount := len(clearedZoneIDs(db.Get(), userID)) + lead := s.ZonesCleared - playerCount + return shadowRacePressureLine(s, e.CurrentDay, lead) +} + +// ── Flavour ──────────────────────────────────────────────────────────────── +// +// TwinBee's voice: first-person, implicit subject, he/him, one line. "The +// Shadow"/"{name}" is the rival; "I"/"we" is TwinBee and the player. Never the +// third-person "TwinBee" (guarded by test, same as the journal reactions). + +// shadowAheadLines — TwinBee, the Shadow is ahead of us. Race pressure. +var shadowAheadLines = []string{ + "Passed the Shadow's camp at dawn — cold ashes, a day old. %s is ahead of us again.", + "Found %s's mark cut into a doorframe up ahead. Still warm. We're not gaining.", + "%s came through here already. I can smell the pitch of a torch not long snuffed.", + "The Shadow's been and gone. %s leaves the doors open behind, like he wants us to know.", +} + +// shadowBehindLines — TwinBee, we're ahead of the Shadow. Earned swagger. +var shadowBehindLines = []string{ + "No sign of %s ahead — I think we're out in front for once. Let's keep it that way.", + "We've outpaced the Shadow. %s is somewhere behind us, eating our dust. Good.", + "Quiet up ahead. %s hasn't reached this deep yet. First ones through.", +} + +// shadowNeckLines — TwinBee, we're level with the Shadow. Tension. +var shadowNeckLines = []string{ + "%s is right on our heels — or we're on his. Hard to say who's chasing who now.", + "Neck and neck with the Shadow. I keep catching %s at the edge of the torchlight.", + "%s is close. Same rooms, hours apart. Whoever clears the next zone first wins the day.", +} + +// shadowRacePressureLine picks a race-pressure line by the day and the Shadow's +// lead, deterministically. lead > 0: Shadow ahead; lead < 0: player ahead; 0: +// level. +func shadowRacePressureLine(s *shadowState, day, lead int) string { + var pool []string + switch { + case lead > 0: + pool = shadowAheadLines + case lead < 0: + pool = shadowBehindLines + default: + pool = shadowNeckLines + } + if len(pool) == 0 { + return "" + } + idx := (day + s.DayCounter) % len(pool) + if idx < 0 { + idx = -idx + } + return "👤 " + fmt.Sprintf(pool[idx], s.Name) +} + +// shadowLeftPageLine prefaces the waiting journal page: the Shadow cleared this +// zone first and left a page behind for us. TwinBee's voice. +func shadowLeftPageLine(s *shadowState) string { + return fmt.Sprintf("👤 %s beat us to this one — but left a torn page pinned in the boss's lair, like a note for whoever came next.", s.Name) +} + +// shadowBeatYouHereLine is the page-less acknowledgement (campaign already +// complete): the Shadow was first, but there's nothing left to find. +func shadowBeatYouHereLine(s *shadowState) string { + return fmt.Sprintf("👤 %s was here before us. Boot-prints in the dust, and nothing left to take. We know how this ends now anyway.", s.Name) +} + +// shadowCrowLine is TwinBee crowing that we beat the Shadow to a zone, with the +// bonus XP. +func shadowCrowLine(s *shadowState, xp int) string { + return fmt.Sprintf("👤 First ones through — %s is still somewhere behind us. I'd crow if it wouldn't give us away. (**+%d XP**, for beating the Shadow here.)", s.Name, xp) +} + +// handleShadowCmd renders the player's standing against the Shadow. A quiet +// status view — the feature lives mostly in the briefing and at zone clears. +func (p *AdventurePlugin) handleShadowCmd(ctx MessageContext) error { + s, err := loadShadow(ctx.Sender) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't read the trail just now. Try again in a moment.") + } + if s == nil { + return p.SendDM(ctx.Sender, "👤 No one's shadowing you yet. Clear a zone or two and someone will take an interest.") + } + playerCount := len(clearedZoneIDs(db.Get(), ctx.Sender)) + total := len(zoneOrder) + var b strings.Builder + b.WriteString(fmt.Sprintf("👤 **The Shadow — %s**\n\n", s.Name)) + b.WriteString(fmt.Sprintf("🏴 **%s's trail:** %d / %d zones cleared\n", s.Name, s.ZonesCleared, total)) + b.WriteString(fmt.Sprintf("🚩 **Your trail:** %d / %d zones cleared\n", playerCount, total)) + switch lead := s.ZonesCleared - playerCount; { + case lead > 0: + b.WriteString(fmt.Sprintf("\n_%s is **%d** ahead. Pages wait where he cleared first — take them back by clearing those zones yourself._", s.Name, lead)) + case lead < 0: + b.WriteString(fmt.Sprintf("\n_You're **%d** ahead. Keep it that way._", -lead)) + default: + b.WriteString("\n_Dead level. The next zone decides who's chasing whom._") + } + return p.SendDM(ctx.Sender, b.String()) +} diff --git a/internal/plugin/adventure_shadow_test.go b/internal/plugin/adventure_shadow_test.go new file mode 100644 index 0000000..0c3f669 --- /dev/null +++ b/internal/plugin/adventure_shadow_test.go @@ -0,0 +1,360 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func newShadowTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +// freshChar builds a same-day character so shadowPlayerAgeDays reads 1 day. +func freshChar(user id.UserID) *AdventureCharacter { + return &AdventureCharacter{ + UserID: user, + DisplayName: "Tester", + CreatedAt: time.Now().UTC(), + } +} + +func TestShadowName_DeterministicAndInPool(t *testing.T) { + user := id.UserID("@name:test.invalid") + first := shadowNameFor("Aria", user) + if again := shadowNameFor("Aria", user); again != first { + t.Errorf("name not deterministic: %q vs %q", first, again) + } + inPool := false + for _, n := range shadowNames { + if n == first { + inPool = true + break + } + } + if !inPool { + t.Errorf("generated name %q not in shadowNames pool", first) + } + // Empty display name falls back to the user id and still yields a pool name. + if got := shadowNameFor("", user); got == "" { + t.Error("empty display name produced an empty shadow name") + } +} + +// TestShadowFlavor_NoThirdPersonTwinBee: every Shadow line TwinBee speaks obeys +// the campaign voice rule — he never refers to himself in the third person +// (feedback_twinbee_voice). Same guard the journal reactions carry. +func TestShadowFlavor_NoThirdPersonTwinBee(t *testing.T) { + pools := [][]string{shadowAheadLines, shadowBehindLines, shadowNeckLines} + for _, pool := range pools { + for _, line := range pool { + if strings.Contains(line, "TwinBee") { + t.Errorf("third-person TwinBee in Shadow pool line: %q", line) + } + if !strings.Contains(line, "%s") { + t.Errorf("race-pressure line missing the name slot: %q", line) + } + } + } + s := &shadowState{Name: "Vael"} + rendered := []string{ + shadowLeftPageLine(s), + shadowBeatYouHereLine(s), + shadowCrowLine(s, 24), + shadowRacePressureLine(s, 3, 1), + shadowRacePressureLine(s, 3, -1), + shadowRacePressureLine(s, 3, 0), + } + for _, r := range rendered { + if strings.Contains(r, "TwinBee") { + t.Errorf("third-person TwinBee in rendered Shadow line: %q", r) + } + if !strings.Contains(r, "Vael") { + t.Errorf("rendered line dropped the Shadow's name: %q", r) + } + } +} + +func TestShadowRacePressure_Deterministic(t *testing.T) { + s := &shadowState{Name: "Vael", DayCounter: 2} + first := shadowRacePressureLine(s, 4, 1) + if again := shadowRacePressureLine(s, 4, 1); again != first { + t.Errorf("race pressure not deterministic: %q vs %q", first, again) + } + // Direction picks a different pool: ahead vs behind must differ in wording. + ahead := shadowRacePressureLine(s, 4, 2) + behind := shadowRacePressureLine(s, 4, -2) + if ahead == behind { + t.Errorf("ahead and behind produced the same line: %q", ahead) + } +} + +func TestShadowAdvance_CreepsAndIsIdempotent(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@creep:test.invalid") + char := freshChar(user) + + p.advanceShadow(char) + s, err := loadShadow(user) + if err != nil || s == nil { + t.Fatalf("shadow not born: %v", err) + } + if s.Progress <= 0 { + t.Errorf("a born shadow should have crept forward, got progress %v", s.Progress) + } + if s.Name == "" { + t.Error("born shadow has no name") + } + firstProg := s.Progress + + // Second advance the same UTC day is a no-op. + p.advanceShadow(char) + s2, _ := loadShadow(user) + if s2.Progress != firstProg { + t.Errorf("same-day re-advance moved the shadow: %v -> %v", firstProg, s2.Progress) + } + + // Roll the clock back a day and it advances again. + s2.LastAdvanced = time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + if err := upsertShadow(s2); err != nil { + t.Fatal(err) + } + p.advanceShadow(char) + s3, _ := loadShadow(user) + if s3.Progress <= firstProg { + t.Errorf("a new day should have advanced the shadow: %v -> %v", firstProg, s3.Progress) + } +} + +func TestShadowAdvance_PendingBitForUnclearedZone(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@pending:test.invalid") + char := freshChar(user) + + // Seed the shadow one step short of clearing zone 0, dated yesterday so the + // next advance runs. The player has cleared nothing. + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + seed := newShadow(user, char.DisplayName) + seed.Progress = 0.95 + seed.LastAdvanced = yesterday + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + p.advanceShadow(char) + s, _ := loadShadow(user) + if s.ZonesCleared < 1 { + t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d prog=%v", s.ZonesCleared, s.Progress) + } + if !shadowBitSet(s.PendingMask, 0) { + t.Errorf("shadow cleared zone 0 before the player but left no pending page (mask=%b)", s.PendingMask) + } +} + +func TestShadowAdvance_NoPendingWhenPlayerClearedFirst(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@nopending:test.invalid") + char := freshChar(user) + + // Player already cleared zone 0. + insertClearedExpedition(t, user, zoneOrder[0], ExpeditionStatusComplete, 1, "{}") + + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + seed := newShadow(user, char.DisplayName) + seed.Progress = 0.95 + seed.LastAdvanced = yesterday + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + p.advanceShadow(char) + s, _ := loadShadow(user) + if s.ZonesCleared < 1 { + t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d", s.ZonesCleared) + } + if shadowBitSet(s.PendingMask, 0) { + t.Errorf("player cleared zone 0 first — no page should wait (mask=%b)", s.PendingMask) + } +} + +func TestShadowAdvance_LeadCapKeepsItARace(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@leadcap:test.invalid") + char := freshChar(user) // player has cleared nothing → count 0 + + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + seed := newShadow(user, char.DisplayName) + seed.Progress = shadowMaxLead // already at the cap for a 0-clear player + seed.ZonesCleared = 2 + seed.LastAdvanced = yesterday + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + p.advanceShadow(char) + s, _ := loadShadow(user) + if s.Progress > shadowMaxLead+0.0001 { + t.Errorf("shadow ran past the lead cap: progress %v > %v", s.Progress, shadowMaxLead) + } +} + +func TestShadowZoneClear_PendingGrantsWaitingPage(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@page:test.invalid") + + idx := 2 + seed := newShadow(user, "Tester") + seed.Progress = 5 + seed.ZonesCleared = 5 + seed.PendingMask = shadowSetBit(0, idx) + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]) + if strings.TrimSpace(line) == "" { + t.Fatal("a waiting page should have produced a clear-message line") + } + if !strings.Contains(line, "page") { + t.Errorf("expected a journal-page grant in the line, got: %q", line) + } + // Bit cleared so a re-clear can't re-award. + s, _ := loadShadow(user) + if shadowBitSet(s.PendingMask, idx) { + t.Error("pending bit not cleared after the page was granted") + } + // The page actually landed. + if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 { + t.Errorf("expected exactly one journal page granted, got %d", journalPageCount(mask)) + } + + // A second clear of the same zone grants nothing more. + again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]) + if again != "" { + t.Errorf("re-clearing a paid-out zone should be silent, got: %q", again) + } + if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 { + t.Errorf("re-clear granted a second page (count %d)", journalPageCount(mask)) + } +} + +func TestShadowZoneClear_PlayerFirstCrows(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@crow:test.invalid") + + idx := 3 + seed := newShadow(user, "Tester") + seed.Progress = 0 // shadow hasn't reached this zone + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]) + if !strings.Contains(line, "XP") { + t.Errorf("beating the shadow should crow a bonus, got: %q", line) + } + // No page granted on a crow. + if mask, _ := loadJournalPages(user); journalPageCount(mask) != 0 { + t.Errorf("a crow should not grant a page (count %d)", journalPageCount(mask)) + } + // The crowed bit is set so a re-run of the same not-yet-reached zone can't + // farm the XP again — second clear is silent. + if s, _ := loadShadow(user); !shadowBitSet(s.CrowedMask, idx) { + t.Error("crowed bit not set after the first crow") + } + if again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); again != "" { + t.Errorf("re-clearing an already-crowed zone should be silent, got: %q", again) + } +} + +func TestShadowZoneClear_ShadowPassedNoDebt(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@passed:test.invalid") + + idx := 1 + seed := newShadow(user, "Tester") + seed.Progress = 5 // well past this zone + seed.ZonesCleared = 5 + seed.PendingMask = 0 // nothing owed here + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + + if line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); line != "" { + t.Errorf("a zone the shadow passed with nothing owed should be silent, got: %q", line) + } +} + +func TestShadowZoneClear_SilentBeforeBirth(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@unborn:test.invalid") + if line := p.shadowOnPlayerZoneClear(user, zoneOrder[0]); line != "" { + t.Errorf("no shadow yet should be silent, got: %q", line) + } +} + +func TestShadowBriefingLine_SilentUntilBorn(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + user := id.UserID("@brief:test.invalid") + e := &Expedition{UserID: string(user), ZoneID: zoneOrder[0], CurrentDay: 2} + + if line := p.shadowBriefingLine(e); line != "" { + t.Errorf("no shadow should mean no briefing line, got: %q", line) + } + + seed := newShadow(user, "Tester") + seed.ZonesCleared = 2 + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + if line := p.shadowBriefingLine(e); strings.TrimSpace(line) == "" { + t.Error("a born shadow should produce a briefing line") + } +} + +func TestHandleShadowCmd_DMsStatus(t *testing.T) { + newShadowTestDB(t) + p := &AdventurePlugin{} + sink := installSink(p) + user := id.UserID("@cmd:test.invalid") + + // Before birth: a gentle "no one's shadowing you yet". + if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil { + t.Fatal(err) + } + if dms := sink.dmsTo(user); len(dms) != 1 || !strings.Contains(dms[0], "shadowing you yet") { + t.Errorf("expected an unborn-shadow DM, got: %v", sink.dmsTo(user)) + } + + seed := newShadow(user, "Tester") + seed.ZonesCleared = 4 + if err := upsertShadow(seed); err != nil { + t.Fatal(err) + } + if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil { + t.Fatal(err) + } + dms := sink.dmsTo(user) + if len(dms) != 2 || !strings.Contains(dms[1], seed.Name) { + t.Errorf("expected a status DM naming the shadow, got: %v", dms) + } +} diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 48c2b03..4431e17 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -400,6 +400,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) + if sl := p.shadowBriefingLine(e); sl != "" { + body += "\n" + sl + "\n" + } if restSummary != "" { body += "\n💤 _" + restSummary + "_\n" } @@ -513,6 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) + if sl := p.shadowBriefingLine(e); sl != "" { + body += "\n" + sl + "\n" + } if forced { body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n" } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 8202609..f37f319 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -170,7 +170,14 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID exp.Status = ExpeditionStatusComplete _ = retireAllRegionRuns(exp) p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear) - return p.AwardCompletionMilestones(exp, false) + lines := p.AwardCompletionMilestones(exp, false) + // N6/D3: the Shadow's payoff for this zone — a crow (player was first) or a + // waiting journal page (the Shadow cleared it first). Appended after the + // milestone lines so the campaign beat reads last. + if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" { + lines = append(lines, sl) + } + return lines } // midZoneRegionClear reports whether the just-completed run (runID) is the