mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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:
@@ -2215,6 +2215,47 @@ CREATE TABLE IF NOT EXISTS expedition_invite (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
|
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
|
||||||
ON expedition_invite(user_id);
|
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.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
||||||
case lower == "journal":
|
case lower == "journal":
|
||||||
return p.handleJournalCmd(ctx)
|
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.")
|
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 mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||||
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
|
` + "`!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)
|
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||||
` + "`!adventure help`" + ` — This message
|
` + "`!adventure help`" + ` — This message
|
||||||
|
|||||||
@@ -420,6 +420,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
|
|
||||||
dmsSent := 0
|
dmsSent := 0
|
||||||
for _, char := range chars {
|
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
|
// Died inside the window — no shame, no streak change. Covers both
|
||||||
// currently-dead players and players revived at midnight (Alive
|
// currently-dead players and players revived at midnight (Alive
|
||||||
// already flipped to true by the reminder loop before this runs).
|
// already flipped to true by the reminder loop before this runs).
|
||||||
|
|||||||
432
internal/plugin/adventure_shadow.go
Normal file
432
internal/plugin/adventure_shadow.go
Normal file
@@ -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)<<idx) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadShadow reads a player's Shadow, or (nil, nil) if it hasn't been born yet.
|
||||||
|
func loadShadow(userID id.UserID) (*shadowState, error) {
|
||||||
|
s := &shadowState{UserID: userID}
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced
|
||||||
|
FROM adventure_shadow WHERE user_id = ?`,
|
||||||
|
string(userID),
|
||||||
|
).Scan(&s.Name, &s.Progress, &s.ZonesCleared, &s.PendingMask, &s.CrowedMask, &s.DayCounter, &s.LastAdvanced)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertShadow persists the whole Shadow row. The ticker is the only writer, so
|
||||||
|
// a plain last-write-wins upsert is safe — there is no concurrent mutator to
|
||||||
|
// race (unlike the player_meta columns a character save also touches).
|
||||||
|
func upsertShadow(s *shadowState) error {
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO adventure_shadow
|
||||||
|
(user_id, name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
progress = excluded.progress,
|
||||||
|
zones_cleared = excluded.zones_cleared,
|
||||||
|
pending_mask = excluded.pending_mask,
|
||||||
|
crowed_mask = excluded.crowed_mask,
|
||||||
|
day_counter = excluded.day_counter,
|
||||||
|
last_advanced = excluded.last_advanced`,
|
||||||
|
string(s.UserID), s.Name, s.Progress, s.ZonesCleared, s.PendingMask,
|
||||||
|
s.CrowedMask, s.DayCounter, s.LastAdvanced,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// shadowNames is the pool the rival's proper name is drawn from — cold,
|
||||||
|
// half-familiar names for a thing that shadows you. Picked deterministically
|
||||||
|
// from the player's own name so a given player always faces the same Shadow.
|
||||||
|
var shadowNames = []string{
|
||||||
|
"Vael", "Corriss", "Mordane", "Ashen", "Locke", "Grimma", "Sable",
|
||||||
|
"Thane", "Wren", "Vesper", "Cael", "Orrin", "Dain", "Wraithe",
|
||||||
|
"Nyx", "Solenne",
|
||||||
|
}
|
||||||
|
|
||||||
|
// shadowNameFor seeds the Shadow's name from the player's display name so it is
|
||||||
|
// stable per player and "seeded from the player's" (D3 spec). Falls back to the
|
||||||
|
// user id when the display name is empty.
|
||||||
|
func shadowNameFor(displayName string, userID id.UserID) string {
|
||||||
|
seed := strings.ToLower(strings.TrimSpace(displayName))
|
||||||
|
if seed == "" {
|
||||||
|
seed = string(userID)
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(seed))
|
||||||
|
return shadowNames[int(h.Sum32())%len(shadowNames)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// newShadow mints a fresh Shadow at the start line.
|
||||||
|
func newShadow(userID id.UserID, displayName string) *shadowState {
|
||||||
|
return &shadowState{
|
||||||
|
UserID: userID,
|
||||||
|
Name: shadowNameFor(displayName, userID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceShadow moves a player's Shadow forward one UTC day. Idempotent per day
|
||||||
|
// via last_advanced, so the once-a-day midnight guard plus this makes a double
|
||||||
|
// tick a no-op. Mints the Shadow on first call. Errors are logged and swallowed
|
||||||
|
// — a stalled Shadow is cosmetic, and must never break the midnight reset loop.
|
||||||
|
func (p *AdventurePlugin) advanceShadow(char *AdventureCharacter) {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
s, err := loadShadow(char.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("shadow: load", "user", char.UserID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
s = newShadow(char.UserID, char.DisplayName)
|
||||||
|
}
|
||||||
|
if s.LastAdvanced == today {
|
||||||
|
return // already advanced this UTC day
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared := clearedZoneIDs(db.Get(), char.UserID)
|
||||||
|
playerCount := len(cleared)
|
||||||
|
|
||||||
|
// The player's long-run pace, in zones per day, drives the Shadow's speed.
|
||||||
|
// Floor it so a new/idle player still gets a creeping rival; the 1.3x keeps
|
||||||
|
// the Shadow just ahead; cap the daily step so it never leaps two zones.
|
||||||
|
step := (float64(playerCount) / float64(shadowPlayerAgeDays(char))) * shadowPaceMultiplier
|
||||||
|
if step < shadowMinDailyStep {
|
||||||
|
step = shadowMinDailyStep
|
||||||
|
}
|
||||||
|
if step > 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())
|
||||||
|
}
|
||||||
360
internal/plugin/adventure_shadow_test.go
Normal file
360
internal/plugin/adventure_shadow_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,6 +400,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
|
body += "\n" + sl + "\n"
|
||||||
|
}
|
||||||
if restSummary != "" {
|
if restSummary != "" {
|
||||||
body += "\n💤 _" + restSummary + "_\n"
|
body += "\n💤 _" + restSummary + "_\n"
|
||||||
}
|
}
|
||||||
@@ -513,6 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
|
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||||
|
body += "\n" + sl + "\n"
|
||||||
|
}
|
||||||
if forced {
|
if forced {
|
||||||
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,14 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
|||||||
exp.Status = ExpeditionStatusComplete
|
exp.Status = ExpeditionStatusComplete
|
||||||
_ = retireAllRegionRuns(exp)
|
_ = retireAllRegionRuns(exp)
|
||||||
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
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
|
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
||||||
|
|||||||
Reference in New Issue
Block a user