5 Commits

Author SHA1 Message Date
prosolis
3f4b4ece5c Headless real-character sim + new-feature exercise harness
Run the actual Adventure module against a copy of the prod DB with no
Matrix client, to smoke-test before deploy.

- expedition-sim: -real-user @mxid runs an EXISTING character loaded from
  -data's gogobee.db instead of a synthetic build. SimRunner gains
  PrepareRealCharacter (heals to full + tops up bankroll; keeps real
  race/class/subclass/level/gear/spells).
- plugin.SendReply now honors the MessageSink like SendMessage/SendDM.
  Reply-based handlers (duels, !town, !rivals, !achievements) previously
  bypassed the capture seam and hit a nil client under the sink. Prod
  behavior is unchanged (sink is nil in production).
- exercise_prod_test.go (build tag: prodexercise) drives every N-series
  feature — world boss, duels, Shadow, Renown, achievements, journal,
  town registries, vault, gifting — against a prod DB copy with all
  outbound messages captured. Gated on GOGOBEE_PROD_DB_DIR; never runs in
  normal CI.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:32:52 -07:00
prosolis
a6f1de4e74 N7/E4: Seasonal events — 1-week holiday skins
Four anchor holidays (Hallowtide, Midwinter Feast, Sweethearts' Revel,
First Bloom) each get a 7-day window (anchor ±3 days) that layers three
things on the world, all reusing existing machinery:

  1. A themed Omen that overrides the weekly rotation. Reuses the B3 omen
     effect fields, so the non-combat rule holds; kept behind activeOmen's
     simOmenDisabled guard (and activeSeason honours it too) so no season
     path can reach the balance sim or move the golden.
  2. A curated curio shelf at Luigi's — existing registry items rotated to
     the front of dailyCuriosStock, so no net-new power enters the economy.
     Off-season output is byte-identical to before.
  3. A themed road visitor via the ambient seam — a season_visitor event
     that leaves a sellable keepsake + coin gift. No combat: the ambient
     seam still never opens a fight.

Pure function of the UTC date + anchor calendar — no schema, ticker, or
persistence, same discipline as the Omen and holiday calendar. Season
banner rides the existing morning DM (no net-new scheduled message).

go test ./internal/plugin ./internal/db green; combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:30:15 -07:00
prosolis
1a47a2fdee N7/B4: Renown achievements wing
Three passive achievements (renown_1/5/10) gated on the derived Renown level
via renownAtLeast, reading player_meta.renown_xp through the achievements
plugin's existing Check(d, userID) seam. No event hook — the passive checker
grants them on the next message once the level is reached.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
38a3693832 N7/B2: Renown — prestige past the L20 cap
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.

Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).

The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.

Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:48:49 -07:00
30 changed files with 1621 additions and 17 deletions

View File

@@ -46,6 +46,8 @@ func main() {
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
@@ -90,9 +92,49 @@ func main() {
return
}
if *realUser != "" {
if *dataDir == "" {
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
}
if *party != 1 {
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
}
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
return
}
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
}
// runReal drives an existing character loaded from dataDir's gogobee.db through
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
// migrations against it, and the run mutates HP/coin/inventory. Never point this
// at the live prod file or at ./data.
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
runner, err := plugin.NewSimRunner(dataDir)
if err != nil {
fail("init runner:", err)
}
defer runner.Close()
uid := id.UserID(userTag)
c, err := runner.PrepareRealCharacter(uid, bank)
if err != nil {
fail("prepare real character:", err)
}
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
if res != nil {
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
if !includeLog {
res.Log = nil
}
emitIndented(res)
}
if err != nil {
fail("run:", err)
}
}
// followerClasses expands -party / -party-classes into the class of each
// follower seat. An explicit list must name every seat: a party of 3 whose
// second follower silently defaulted to the leader's class would quietly

View File

@@ -393,6 +393,14 @@ func runMigrations(d *sql.DB) error {
// character save, so a monotonic false→true flag can't be clobbered.
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
// XP that grantDnDXP used to drop instead accumulates here as cumulative
// prestige XP. renown_xp is monotonic and written by an atomic += (the
// journal_pages pattern), never the bulk character save; renown_level is
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
// there is no read-modify-write race and no second field to disagree.
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {

View File

@@ -1206,9 +1206,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false
},
},
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
// against the derived Renown level (N7/B2); no event hook needed.
{
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
},
{
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
},
{
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
Emoji: "✦",
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
},
}
}
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
// reached level, reading player_meta.renown_xp through the passed handle.
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
var xp int
if err := d.QueryRow(
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&xp); err != nil {
return false
}
return renownLevelFor(xp) >= level
}
// ── Expedition achievement helpers ──────────────────────────────────────────
// clearedZoneIDs returns the zones this player has beaten outright. Boss-

View File

@@ -646,6 +646,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
@@ -796,6 +797,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
ForagingSkill: c.ForagingSkill,
FishingSkill: c.FishingSkill,
CurrentStreak: c.CurrentStreak,
Renown: renownLevelForUser(c.UserID),
})
}
text := renderAdvLeaderboard(entries)

View File

@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
}
totalXP := int(float64(run.XPAccumulated) * xpMult)
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
// pot tax, so both the player's cut and the pot's rake grow proportionally.
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
run.Earnings = int64(float64(run.Earnings) * m)
}
// Arena tax: 10% of earnings to community pot.
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
arenaNet := run.Earnings - arenaTax

View File

@@ -118,6 +118,18 @@ type AdventureCharacter struct {
// N5/D1c the finale reward-once flag. True after the first finale clear;
// overlay-read, written by the atomic markEpilogueClearedDB.
EpilogueCleared bool
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
// bulk character save. RenownLevel() derives the prestige level from it.
RenownXP int
}
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
func (c *AdventureCharacter) RenownLevel() int {
if c == nil {
return 0
}
return renownLevelFor(c.RenownXP)
}
type AdvEquipment struct {

View File

@@ -0,0 +1,101 @@
package plugin
import (
"time"
)
// N7/B3 — the Omen: one rotating world modifier per ISO week
// (gogobee_engagement_plan.md §B3).
//
// The active omen is a pure function of the ISO (year, week): omenTable indexed
// by (year*53 + week) % len, so it advances by exactly one entry each week and
// needs no schema, no ticker state, and no persistence. Every seam reads
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
// field means "this omen doesn't touch that seam."
//
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
// on the real clock, so a combat-affecting omen would make the combat golden
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
// deliberately omitted for exactly that reason.
type omen struct {
Key string // stable id (tests, logs)
Name string // player-facing name, e.g. "Bountiful Harvest"
TwinBee string // first-person announce blurb (TwinBee voice)
// Effect fields — zero == no effect at that seam.
HarvestYieldBonus int // + units per harvest grant
SupplyFreebiePacks int // + complimentary standard packs at outfitting
StartMoodBonus int // + starting DM mood on a new expedition
ArenaPayoutMult float64 // >1 scales arena net earnings
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
}
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
// sweep's results never depend on which wall-clock week it was run in. Set true
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
// which returns the no-effect omen while this is set.
var simOmenDisabled bool
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
// order is the rotation order. Adding an entry reshuffles the schedule but never
// breaks determinism (still a pure function of the week).
var omenTable = []omen{
{
Key: "bountiful_harvest", Name: "Bountiful Harvest",
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
HarvestYieldBonus: 1,
},
{
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
SupplyFreebiePacks: 1,
StartMoodBonus: 5,
},
{
Key: "golden_purse", Name: "Golden Purse",
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
ArenaPayoutMult: 1.20,
},
{
Key: "overflowing_satchels", Name: "Overflowing Satchels",
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
ConsumableChanceMult: 2.0,
},
{
Key: "still_waters", Name: "Still Waters",
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
ThreatDriftReduce: 1,
},
}
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
func omenForWeek(year, week int) omen {
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
return omenTable[idx%len(omenTable)]
}
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
// the balance sim has disabled it.
func activeOmen() omen {
if simOmenDisabled {
return omen{Key: "none", Name: "None"}
}
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
// behind the simOmenDisabled guard above so the balance sim never sees it.
if s, ok := activeSeason(); ok {
return s.Omen
}
y, w := time.Now().UTC().ISOWeek()
return omenForWeek(y, w)
}
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
// announce seam). Empty is never returned — an omen is always active — but the
// caller may still choose when to show it.
func omenMorningLine(o omen) string {
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
}

View File

@@ -0,0 +1,86 @@
package plugin
import "testing"
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
func TestOmenForWeek_Deterministic(t *testing.T) {
a := omenForWeek(2026, 28)
b := omenForWeek(2026, 28)
if a.Key != b.Key {
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
}
}
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
// entry, so the schedule rotates rather than sticking or skipping.
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
n := len(omenTable)
for w := 1; w <= n; w++ {
cur := omenForWeek(2026, w)
next := omenForWeek(2026, w+1)
wantNext := omenTable[((2026*53)+w+1)%n]
if next.Key != wantNext.Key {
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
}
if cur.Key == next.Key {
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
}
}
}
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
// including ISO week 53.
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
for y := 2020; y <= 2030; y++ {
for w := 1; w <= 53; w++ {
o := omenForWeek(y, w)
if o.Key == "" {
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
}
}
}
}
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
// struct has no combat lever, so the launch set can never move the golden or the
// balance corpus. §B3.
func TestOmenTable_NonCombatOnly(t *testing.T) {
for _, o := range omenTable {
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
if !hasEffect {
t.Errorf("omen %q has no active effect", o.Key)
}
if o.Name == "" || o.TwinBee == "" {
t.Errorf("omen %q missing player-facing copy", o.Key)
}
}
}
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
// results are wall-clock-independent. §B3.
func TestActiveOmen_SimDisabled(t *testing.T) {
defer func() { simOmenDisabled = false }()
simOmenDisabled = true
o := activeOmen()
if o.Key != "none" {
t.Errorf("sim-disabled omen = %q, want none", o.Key)
}
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
}
}
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
// the same omen two of every len(omenTable) weeks).
func TestOmenKeysUnique(t *testing.T) {
seen := map[string]bool{}
for _, o := range omenTable {
if seen[o.Key] {
t.Errorf("duplicate omen key %q", o.Key)
}
seen[o.Key] = true
}
}

View File

@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
}
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
// existing morning DM (no net-new scheduled message); persistent one-liner so
// a mid-week arrival still learns the active omen.
sb.WriteString(omenMorningLine(activeOmen()))
sb.WriteString("\n\n")
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
// line (its themed omen already rode the line above). Same morning DM, no
// net-new scheduled message.
if s, ok := activeSeason(); ok {
sb.WriteString(seasonBannerLine(s))
sb.WriteString("\n\n")
}
// Pick a morning greeting
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
displayName, _ := loadDisplayName(userID)
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
ForagingSkill int
FishingSkill int
CurrentStreak int
Renown int // N7/B2 — prestige level, cosmetic marker only
}
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
Score int
Levels string
Streak int
Renown int
}
var entries []entry
for _, c := range chars {
// Renown adds to the ranking score so a capped, prestigious player still
// climbs — it's the only progression they have left past L20.
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
score += c.Renown * 10
name, _ := loadDisplayName(c.UserID)
entries = append(entries, entry{
Name: name,
Score: score,
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
Streak: c.CurrentStreak,
Renown: c.Renown,
})
}
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
if e.Streak >= 7 {
streak = fmt.Sprintf(" 🔥%d", e.Streak)
}
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
renownBadge := ""
if m := renownMarker(e.Renown); m != "" {
renownBadge = " " + m
}
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
}
return sb.String()

View File

@@ -0,0 +1,249 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"maunium.net/go/mautrix/id"
"gogobee/internal/db"
)
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
//
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
//
// Storage is a single cumulative column, player_meta.renown_xp, written by an
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
// stored, so nothing can disagree with the XP total.
//
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
// touches only the AdvBonusSummary activity levers and only at the activity
// call sites, never loadCombatBonuses.
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
// Renown level is dozens of endgame clears.
const renownXPPerLevel = 25000
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
// power.
//
// The perks are deliberately ONLY the two combat-neutral levers of the
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
// stats and are off-limits (§B2: never combat-stat inflation, the balance
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
// loot/XP economy, never by combat stat derivation — so renown can pay them out
// even through loadCombatBonuses without moving the golden. That is why §B2's
// suggested "death penalty" perk is intentionally NOT granted: it would inflate
// Defense.
const (
renownPerkStepLevels = 3
renownPerkMaxSteps = 10
)
// renownLevelFor derives the Renown level from cumulative overflow XP.
func renownLevelFor(renownXP int) int {
if renownXP <= 0 {
return 0
}
return renownXP / renownXPPerLevel
}
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
// display. cost is always renownXPPerLevel.
func renownXPIntoLevel(renownXP int) (int, int) {
if renownXP < 0 {
renownXP = 0
}
return renownXP % renownXPPerLevel, renownXPPerLevel
}
// renownRank is one rung of the derived title ladder: the first Renown level at
// which the rank applies, and its name. A rank promotion (crossing into a new
// rung) is what gets announced in the games room; plain level-ups only DM.
type renownRank struct {
MinLevel int
Name string
}
var renownRanks = []renownRank{
{1, "Renowned"},
{3, "Storied"},
{5, "Illustrious"},
{10, "Fabled"},
{15, "Mythic"},
{20, "Ascendant"},
{30, "Eternal"},
}
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
func renownRankFor(level int) string {
name := ""
for _, r := range renownRanks {
if level >= r.MinLevel {
name = r.Name
}
}
return name
}
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
// and leaderboard. Empty below Renown 1.
func renownMarker(level int) string {
if level < 1 {
return ""
}
return fmt.Sprintf("✦%d", level)
}
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
// — so it is safe to call anywhere the summary is built, including
// loadCombatBonuses, without changing any combat stat (see the const block).
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
if b == nil || renownLevel < renownPerkStepLevels {
return
}
steps := renownLevel / renownPerkStepLevels
if steps > renownPerkMaxSteps {
steps = renownPerkMaxSteps
}
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
}
// ── Persistence ──────────────────────────────────────────────────────────────
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
// journal_pages pattern), returning the post-update total. Shared by the
// standalone and transactional accrual paths so the SQL lives in one place.
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
RETURNING renown_xp`
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
// the accrual can run standalone or inside grantDnDXP's save transaction.
type sqlQueryer interface {
QueryRow(query string, args ...any) *sql.Row
}
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
// the cumulative totals before and after. A single statement, safe against
// concurrent grants — no lost update.
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
}
return after - delta, after, nil
}
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
// instead, so this is the API for callers not already saving a character.
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
if delta <= 0 {
cur, e := loadRenownXP(userID)
return cur, cur, e
}
return accrueRenownXP(db.Get(), userID, delta)
}
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
// nor double-credit it. Returns the cumulative renown totals before/after.
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
tx, err := db.Get().Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
if err = saveDnDCharacterExec(tx, c); err != nil {
return 0, 0, err
}
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
return 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, err
}
return before, after, nil
}
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
func loadRenownXP(userID id.UserID) (int, error) {
var xp int
err := db.Get().QueryRow(
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&xp)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return xp, err
}
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
// that don't already hold the overlay. Errors resolve to 0.
func renownLevelForUser(userID id.UserID) int {
xp, err := loadRenownXP(userID)
if err != nil {
return 0
}
return renownLevelFor(xp)
}
// ── Announce ─────────────────────────────────────────────────────────────────
// announceRenown notifies the player of Renown level-ups (DM) and, when the
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
// not a scheduled recap. from/to are Renown levels before/after the grant.
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
if p == nil || to <= from {
return
}
gained := to - from
rank := renownRankFor(to)
if p.Client != nil {
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
to, pluralLevels(gained))
if rank != "" {
msg += fmt.Sprintf("\nRank: **%s**.", rank)
}
if err := p.SendDM(userID, msg); err != nil {
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
}
}
// Games-room shout only on a rank promotion, to keep the room quiet.
if renownRankFor(from) == rank {
return
}
gr := gamesRoom()
if gr == "" {
return
}
name, _ := loadDisplayName(userID)
if name == "" {
name = string(userID)
}
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
}
func pluralLevels(n int) string {
if n == 1 {
return "a Renown level"
}
return fmt.Sprintf("%d Renown levels", n)
}

View File

@@ -0,0 +1,238 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newRenownTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func TestRenownLevelFor(t *testing.T) {
cases := []struct{ xp, want int }{
{0, 0},
{-100, 0},
{renownXPPerLevel - 1, 0},
{renownXPPerLevel, 1},
{renownXPPerLevel*3 + 12, 3},
{renownXPPerLevel * 30, 30},
}
for _, c := range cases {
if got := renownLevelFor(c.xp); got != c.want {
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
}
}
}
func TestRenownXPIntoLevel(t *testing.T) {
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
if cost != renownXPPerLevel {
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
}
if into != 500 {
t.Errorf("into = %d, want 500", into)
}
}
func TestRenownRankLadderMonotonic(t *testing.T) {
// Rank thresholds must strictly increase so renownRankFor is well-defined.
for i := 1; i < len(renownRanks); i++ {
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
}
}
if renownRankFor(0) != "" {
t.Errorf("rank at 0 should be empty")
}
if renownRankFor(1) != "Renowned" {
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
}
if renownRankFor(4) != "Storied" {
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
}
if renownRankFor(1000) != "Eternal" {
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
}
}
func TestRenownMarker(t *testing.T) {
if renownMarker(0) != "" {
t.Errorf("marker at 0 should be empty")
}
if got := renownMarker(7); got != "✦7" {
t.Errorf("marker(7) = %q, want ✦7", got)
}
}
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
// touch a lever that combat_stats.go maps to a combat stat. §B2.
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
// Below the first step: no effect.
b := &AdvBonusSummary{}
applyRenownBonuses(b, 2)
if b.XPMultiplier != 0 || b.LootQuality != 0 {
t.Errorf("renown < step should be inert, got %+v", b)
}
// One step at renown 3.
b = &AdvBonusSummary{}
applyRenownBonuses(b, 3)
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
t.Errorf("one step wrong: %+v", b)
}
// At renown 30 and beyond, capped at +20% XP / +15% loot.
for _, lvl := range []int{30, 45, 300} {
b = &AdvBonusSummary{}
applyRenownBonuses(b, lvl)
if b.XPMultiplier != 20 || b.LootQuality != 15 {
t.Errorf("renown %d not capped: %+v", lvl, b)
}
}
// Never combat-stat inflation: the levers combat_stats.go reads
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
// and the skill/combat levers must all stay at zero at any renown level.
b = &AdvBonusSummary{}
applyRenownBonuses(b, 300)
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
}
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
t.Errorf("renown leaked into combat/skill levers: %+v", b)
}
}
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
func TestAddRenownXP_Accumulates(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_add:example")
if err := createAdvCharacter(uid, "Renowner"); err != nil {
t.Fatal(err)
}
before, after, err := addRenownXP(uid, 10000)
if err != nil {
t.Fatal(err)
}
if before != 0 || after != 10000 {
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
}
before, after, err = addRenownXP(uid, 20000)
if err != nil {
t.Fatal(err)
}
if before != 10000 || after != 30000 {
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
}
got, err := loadRenownXP(uid)
if err != nil || got != 30000 {
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
}
if renownLevelForUser(uid) != 1 {
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
}
}
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
func TestRenownAtLeast(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_ach:example")
if err := createAdvCharacter(uid, "Achiever"); err != nil {
t.Fatal(err)
}
d := db.Get()
if renownAtLeast(d, uid, 1) {
t.Errorf("no renown yet, should not meet level 1")
}
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
t.Fatal(err)
}
if !renownAtLeast(d, uid, 5) {
t.Errorf("renown 5 should meet level 5")
}
if renownAtLeast(d, uid, 6) {
t.Errorf("renown 5 should not meet level 6")
}
// Absent player → false, not a crash.
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
t.Errorf("absent player should not meet any renown level")
}
}
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
func TestRenownOverlay(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_overlay:example")
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
t.Fatal(err)
}
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
t.Fatal(err)
}
c, err := loadAdvCharacter(uid)
if err != nil || c == nil {
t.Fatalf("load: %v", err)
}
if c.RenownXP != renownXPPerLevel*5+7 {
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
}
if c.RenownLevel() != 5 {
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
}
}
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
newRenownTestDB(t)
uid := id.UserID("@renown_grant:example")
if err := createAdvCharacter(uid, "Capped"); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
}
c.HPMax = 100
c.HPCurrent = 100
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Errorf("got %d level-up events at cap, want 0", len(events))
}
got, _ := LoadDnDCharacter(uid)
if got.Level != dndMaxLevel {
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
}
if got.XP != 0 {
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
}
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
}
if renownLevelForUser(uid) != 1 {
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
}
}

View File

@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
balance := p.euro.GetBalance(char.UserID)
holidayLabel := ""

View File

@@ -0,0 +1,199 @@
package plugin
import (
"time"
)
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
//
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
// rotation), a season spans a window around its anchor date and layers three
// things on top of the world for that week:
//
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
// rotation (see activeOmen). It reuses the omen effect fields, so the same
// non-combat rule holds: a season never touches SimulateCombat or the turn
// engine. That is enforced structurally — activeSeason() honours
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
// sees a season through any path, and the season Omen only reaches the sim's
// omen seams behind activeOmen's own simOmenDisabled guard.
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
// registry items (dailyCuriosStock). No new power enters the game; the shelf
// just changes which items rotate in for the week.
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
// ambient seam has never opened a fight and this does not change that.
//
// A season is a pure function of the UTC date and the anchor calendar, so it
// needs no schema, no ticker state, and no persistence — the same discipline as
// the Omen and the holiday calendar.
// seasonHalfWidth is the number of days on each side of the anchor date that the
// season is live. 3 → a 7-day window (anchor ± 3).
const seasonHalfWidth = 3
// seasonVisitor is the themed ambient event a season adds to the road. It never
// resolves combat — it leaves a sellable keepsake and a small coin gift.
type seasonVisitor struct {
Weight int // relative weight in the ambient pick
FlavorPool []string // scene narration lines for the visit
Keepsake string // sellable trophy left behind
KeepsakeValue int64 // coin baseline of the keepsake
Coins int // flat coin gift
}
// season is one holiday skin.
type season struct {
Key string // stable id (tests, logs)
Name string // player-facing, e.g. "Hallowtide"
Emoji string // banner emoji
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
Blurb string // one-line "what's live this week" banner copy
Omen omen // themed override omen (non-combat fields only)
CurioIDs []string // curated registry IDs for Luigi's shelf
Visitor seasonVisitor // the road visitor
}
// fixedAnchor builds an Anchor for a fixed month/day holiday.
func fixedAnchor(month time.Month, day int) func(int) time.Time {
return func(year int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
}
// seasonTable is the set of anchor holidays that get a skin. Order is match
// order; the windows are disjoint so it never matters, but keep them disjoint.
var seasonTable = []season{
{
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
Anchor: fixedAnchor(time.October, 31),
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
Omen: omen{
Key: "hallowtide", Name: "Hallowtide",
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
ConsumableChanceMult: 2.0,
},
CurioIDs: []string{
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
"slippers_of_spider_climbing", "staff_of_swarming_insects",
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
},
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
},
},
{
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
Anchor: fixedAnchor(time.December, 25),
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
Omen: omen{
Key: "midwinter", Name: "Midwinter Feast",
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
SupplyFreebiePacks: 1,
StartMoodBonus: 5,
},
CurioIDs: []string{
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
"staff_of_frost", "potion_of_healing", "staff_of_healing",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
},
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
},
},
{
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
Anchor: fixedAnchor(time.February, 14),
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
Omen: omen{
Key: "sweethearts", Name: "Sweethearts' Revel",
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
ArenaPayoutMult: 1.20,
},
CurioIDs: []string{
"eyes_of_charming", "philter_of_love", "staff_of_charming",
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
"glamoured_studded_leather",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
},
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
},
},
{
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
Anchor: func(year int) time.Time { return easterDate(year) },
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
Omen: omen{
Key: "first_bloom", Name: "First Bloom",
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
HarvestYieldBonus: 1,
ThreatDriftReduce: 1,
},
CurioIDs: []string{
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
},
Visitor: seasonVisitor{
Weight: 12,
FlavorPool: []string{
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
},
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
},
},
}
// activeSeason returns the season whose window contains today (UTC), or false.
// Honours simOmenDisabled so the balance sim never observes a season through any
// path — the same neutralisation activeOmen applies to the weekly rotation.
func activeSeason() (season, bool) {
if simOmenDisabled {
return season{}, false
}
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return seasonForDate(today)
}
// seasonForDate is the pure core of activeSeason, split out for tests. The
// anchor is checked against the target year and its neighbours so a window that
// straddles a year boundary still resolves.
func seasonForDate(today time.Time) (season, bool) {
for _, s := range seasonTable {
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
a := s.Anchor(yr)
lo := a.AddDate(0, 0, -seasonHalfWidth)
hi := a.AddDate(0, 0, seasonHalfWidth)
if !today.Before(lo) && !today.After(hi) {
return s, true
}
}
}
return season{}, false
}
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
// Omen line in the morning DM. Empty when no season is active.
func seasonBannerLine(s season) string {
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
}

View File

@@ -0,0 +1,120 @@
package plugin
import (
"testing"
"time"
)
func date(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
for _, s := range seasonTable {
a := s.Anchor(2026)
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
got, ok := seasonForDate(a.AddDate(0, 0, off))
if !ok || got.Key != s.Key {
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
s.Key, off, ok, got.Key, s.Key)
}
}
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
}
}
}
}
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
// the first-match order in seasonForDate never hides overlapping content.
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
for y := 2024; y <= 2030; y++ {
day := date(y, time.January, 1)
end := date(y+1, time.January, 1)
for day.Before(end) {
matches := 0
for _, s := range seasonTable {
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
a := s.Anchor(yr)
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
matches++
break
}
}
}
if matches > 1 {
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
day.Format("2006-01-02"), matches)
}
day = day.AddDate(0, 0, 1)
}
}
}
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
for _, s := range seasonTable {
if len(s.CurioIDs) == 0 {
t.Errorf("%s has no curated curios", s.Key)
}
for _, id := range s.CurioIDs {
if _, ok := magicItemRegistry[id]; !ok {
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
}
}
}
}
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
// only touches the non-combat omen levers, so overriding the weekly rotation with
// it can never move the combat golden or the balance corpus. §E4/§B3.
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
for _, s := range seasonTable {
o := s.Omen
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
if !hasEffect {
t.Errorf("%s omen has no active effect", s.Key)
}
if o.Name == "" || o.TwinBee == "" {
t.Errorf("%s omen missing player-facing copy", s.Key)
}
}
}
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
// has a real reward to grant.
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
for _, s := range seasonTable {
v := s.Visitor
if len(v.FlavorPool) == 0 {
t.Errorf("%s visitor has no flavor", s.Key)
}
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
}
if v.Weight <= 0 {
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
}
}
}
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
prev := simOmenDisabled
simOmenDisabled = true
defer func() { simOmenDisabled = prev }()
if s, ok := activeSeason(); ok {
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
}
}

View File

@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
n := curiosStockSize
if n > len(ids) {
n = len(ids)
target := curiosStockSize
if target > len(ids) {
target = len(ids)
}
stock := make([]MagicItem, 0, n)
for _, id := range ids[:n] {
stock := make([]MagicItem, 0, target)
seen := make(map[string]bool)
// N7/E4 — a live season features its curated items at the front of the shelf,
// displacing the day's rotation. They are existing registry items, so no
// net-new power enters the economy for the week; only which items rotate in.
if s, ok := activeSeason(); ok {
for _, id := range s.CurioIDs {
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
stock = append(stock, mi)
seen[id] = true
}
}
if len(stock) > target {
target = len(stock)
}
}
// Fill the remaining slots from the day's deterministic rotation.
for _, id := range ids {
if len(stock) >= target {
break
}
if seen[id] {
continue
}
stock = append(stock, magicItemRegistry[id])
seen[id] = true
}
// Stable display order: rarity ascending, then name.
sort.Slice(stock, func(i, j int) bool {
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
var sb strings.Builder
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
if s, ok := activeSeason(); ok {
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
} else {
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
}
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
factor := p.shopSessionPriceFactor(userID)

View File

@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
treasures, _ := loadAdvTreasureBonuses(userID)
buffs, _ := loadAdvActiveBuffs(userID)
hasGrudge := char.GrudgeLocation != ""
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
// which combat stat derivation never reads (see adventure_renown.go).
applyRenownBonuses(b, char.RenownLevel())
return b
}
// loadConsumableInventory scans inventory for items matching consumable definitions.

View File

@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
return c, nil
}
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
// so the upsert can run either standalone or inside a caller's transaction.
type sqlExecer interface {
Exec(query string, args ...any) (sql.Result, error)
}
// SaveDnDCharacter upserts the row.
func SaveDnDCharacter(c *DnDCharacter) error {
return saveDnDCharacterExec(db.Get(), c)
}
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
// transaction (adventure_renown.go), so the character save and the renown
// credit commit atomically.
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
pending := 0
if c.PendingSetup {
pending = 1
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
if c.OnboardingSent {
onboard = 1
}
_, err := db.Get().Exec(`
_, err := ex.Exec(`
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
str_score, dex_score, con_score, int_score, wis_score, cha_score,
hp_current, hp_max, temp_hp, armor_class,

View File

@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
if isHol, _ := isHolidayToday(); isHol {
startMood = 55
}
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
startMood += b
}
exp := &Expedition{
ID: newExpeditionID(),
UserID: string(userID),

View File

@@ -384,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
suppliesPurchase.StandardPacks += pk
}
supplies := makeSupplies(zone.Tier, suppliesPurchase)
// Debit coins; bail on debit failure (race / cap).

View File

@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
if isHol, _ := isHolidayToday(); isHol {
qty++
}
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
qty += b
}
tier := zoneTierFromID(res.ZoneID)
for i := 0; i < qty; i++ {
item := AdvItem{

View File

@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
return 0, "", nil
}
delta, reason := dailyThreatDrift(e.DMMood)
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
// *rise* only (guarded on delta > 0), floored so the worst case is threat
// holding steady for the day; it never turns a rise into active decay.
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
if delta -= r; delta < 0 {
delta = 0
}
}
if delta == 0 {
return 0, reason, nil
}

View File

@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
if c.Level >= dndMaxLevel {
b.WriteString("XP: capped at L20.")
if rl := advChar.RenownLevel(); rl > 0 {
into, cost := renownXPIntoLevel(advChar.RenownXP)
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
if rank := renownRankFor(rl); rank != "" {
b.WriteString(fmt.Sprintf(" — _%s_", rank))
}
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
} else {
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
}
} else {
next := dndXPToNextLevel(c.Level)
pct := int(100.0 * float64(c.XP) / float64(next))

View File

@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
if adv != nil && adv.DisplayName != "" {
name = adv.DisplayName
}
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
renownLevel := 0
if adv != nil {
renownLevel = adv.RenownLevel()
}
nameLine := name
if m := renownMarker(renownLevel); m != "" {
nameLine = fmt.Sprintf("%s %s", name, m)
}
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
if renownLevel > 0 {
if rank := renownRankFor(renownLevel); rank != "" {
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
}
}
if c.Subclass != "" {
if si, ok := subclassInfo(c.Subclass); ok {
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))

View File

@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
}
// Cap at L20 — overflow XP is silently dropped.
if c.Level >= dndMaxLevel {
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
// be dropped converts to Renown in the SAME transaction as the save, so a
// crash can neither lose the overflow nor double-credit it on the next grant.
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
// the balance corpus stays valid.
var renownFrom, renownTo int
if c.Level >= dndMaxLevel && c.XP > 0 {
overflow := c.XP
c.XP = 0
}
if err := SaveDnDCharacter(c); err != nil {
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
if err != nil {
return events, err
}
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
} else if err := SaveDnDCharacter(c); err != nil {
return events, err
}
p.announceRenown(userID, renownFrom, renownTo)
if len(events) > 0 {
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
// superiority cap from 4 → 5; reconcile any level-gated subclass pool

View File

@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
// rollZoneIngredient draws one crafting ingredient from a random legacy
// gathering table at the zone's tier. Returns nil on the common no-drop path.
func rollZoneIngredient(zoneTier int) *AdvItem {
if rand.Float64() >= advIngredientDropChance {
chance := advIngredientDropChance
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
chance *= m
}
if rand.Float64() >= chance {
return nil
}
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]

View File

@@ -0,0 +1,290 @@
//go:build prodexercise
package plugin
// Headless smoke-exercise of the N-series features (parties aside — those have
// their own sim path) against a COPY of the prod DB. Nothing here touches the
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
// before opening it, so even the copy is left pristine.
//
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
//
// Every outbound DM / room message a handler would have sent to Matrix is
// captured via the existing MessageSink seam and dumped with t.Log, so the run
// shows exactly what a player would see. Each feature is wrapped so a panic or
// error in one is reported and the battery continues.
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestExerciseNewFeaturesProd(t *testing.T) {
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
if srcDir == "" {
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
}
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
t.Skipf("no gogobee.db in %s", srcDir)
}
tmp := t.TempDir()
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
}
db.Close()
if err := db.Init(tmp); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
// so the same DB copy exercises identically regardless of which week we run.
simOmenDisabled = true
simAutoArmEnabled = true
euro := NewEuroPlugin(nil)
xp := NewXPPlugin(nil)
p := NewAdventurePlugin(nil, euro, xp)
sink := &captureSink{}
p.Sink = sink
mark := 0
drain := func() {
for _, m := range sink.msgs[mark:] {
who := string(m.ToUser)
if who == "" {
who = "room:" + string(m.ToRoom)
}
t.Logf(" → [%s]\n%s", who, indent(m.Text))
}
mark = len(sink.msgs)
}
exercise := func(name string, fn func()) {
defer func() {
if r := recover(); r != nil {
t.Logf(" !! %s PANICKED: %v", name, r)
mark = len(sink.msgs)
}
}()
t.Logf("──────── %s ────────", name)
fn()
drain()
}
chars := runnableChars(t)
if len(chars) == 0 {
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
}
t.Logf("runnable characters: %d", len(chars))
for _, c := range chars {
healToFull(t, c.uid)
euro.Credit(c.uid, 5000, "exercise bankroll")
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
}
ctxFor := func(uid id.UserID, body string) MessageContext {
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
}
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
exercise("N7/B2 Renown — standing + prestige announce", func() {
for _, c := range chars {
xp, _ := loadRenownXP(c.uid)
lvl := renownLevelForUser(c.uid)
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
}
// Push the first character up a renown level to fire the announce path.
c0 := chars[0]
before, after, err := addRenownXP(c0.uid, 500)
if err != nil {
t.Logf(" addRenownXP: %v", err)
return
}
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
})
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
boss, err := p.spawnWorldBoss("exercise-siege")
if err != nil {
t.Logf(" spawnWorldBoss: %v", err)
}
if boss != nil {
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
}
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
for _, c := range chars {
t.Logf(" -- %s takes a bout --", c.uid)
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
}
t.Logf(" -- pool after the raid --")
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
})
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
if len(chars) < 2 {
t.Log(" need 2 characters for a duel; skipping")
return
}
a, b := chars[0], chars[1]
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
})
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
exercise("N6/D3 Shadow — standing vs the rival", func() {
for _, c := range chars {
p.handleShadowCmd(ctxFor(c.uid, ""))
}
})
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
exercise("N5/D1a Journal — collected campaign pages", func() {
for _, c := range chars {
p.handleJournalCmd(ctxFor(c.uid, ""))
}
})
// ── N4/E3 Town registries ───────────────────────────────────────────────
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
p.handleTownCmd(ctxFor(chars[0].uid, ""))
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
})
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
for _, c := range chars {
p.handleVaultCmd(ctxFor(c.uid, ""), "")
}
})
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
exercise("N4/E2 Gifting — !give <item> @user", func() {
if len(chars) < 2 {
t.Log(" need 2 characters to gift; skipping")
return
}
giver, receiver := chars[0], chars[1]
item := firstInventoryItem(giver.uid)
if item == "" {
t.Logf(" %s has no inventory items to gift", giver.uid)
return
}
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
p.handleGiveCmd(ctxFor(giver.uid, body))
})
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
exercise("N7/B4 Achievements — !achievements", func() {
ach := NewAchievementsPlugin(nil, nil)
ach.Sink = sink
p.SetAchievements(ach)
for _, c := range chars {
ach.handleAchievements(ctxFor(c.uid, ""))
}
})
}
// ---- helpers ---------------------------------------------------------------
type exChar struct {
uid id.UserID
class string
level int
}
// runnableChars lists characters that have both a completed dnd_character (class
// set) and the legacy adventure_characters row the expedition/feature paths load.
func runnableChars(t *testing.T) []exChar {
t.Helper()
d := db.Get()
rows, err := d.Query(`
SELECT d.user_id, d.class, d.dnd_level
FROM dnd_character d
JOIN adventure_characters a ON a.user_id = d.user_id
WHERE d.class != ''`)
if err != nil {
t.Fatalf("query chars: %v", err)
}
defer rows.Close()
var out []exChar
for rows.Next() {
var c exChar
var uid string
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
t.Fatal(err)
}
c.uid = id.UserID(uid)
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
return out
}
func healToFull(t *testing.T, uid id.UserID) {
t.Helper()
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
t.Logf(" healToFull: load %s: %v", uid, err)
return
}
c.HPCurrent = c.HPMax
c.TempHP = 0
c.Exhaustion = 0
c.ShortRestCharges = c.Level
if err := SaveDnDCharacter(c); err != nil {
t.Logf(" healToFull: save %s: %v", uid, err)
}
}
func firstInventoryItem(uid id.UserID) string {
d := db.Get()
var name string
// adventure_inventory holds the player's consumables/misc, one row per item.
_ = d.QueryRow(
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
string(uid),
).Scan(&name)
return name
}
func copyIfPresent(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
return // wal/shm may be absent — fine
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatal(err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatal(err)
}
}
func indent(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, l := range lines {
lines[i] = " " + l
}
return strings.Join(lines, "\n")
}

View File

@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
// it's cheap to test in isolation and free of package-level state.
func ambientEvents() []ambientEvent {
always := func(*Expedition) bool { return true }
return []ambientEvent{
events := []ambientEvent{
ambientEventMonologue(),
{
Kind: "small_find",
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
},
},
}
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
// reward come from activeSeason(); the ambient seam is production-only, so
// this never reaches the balance sim.
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
events = append(events, ambientEvent{
Kind: "season_visitor",
Pool: s.Visitor.FlavorPool,
Weight: s.Visitor.Weight,
Eligible: func(*Expedition) bool { return true },
})
}
return events
}
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
return ""
}
return "+1 HP"
case "season_visitor":
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
// the season so the reward matches the current window even if it turned
// over between pick and apply; bail quietly if it just ended.
s, ok := activeSeason()
if !ok {
return ""
}
v := s.Visitor
uid := id.UserID(e.UserID)
if err := addAdvInventoryItem(uid, AdvItem{
Name: v.Keepsake,
Type: "trophy",
Tier: 1,
Value: v.KeepsakeValue,
}); err != nil {
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
return ""
}
if v.Coins > 0 {
if p.euro != nil {
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
}
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET coins_earned = coins_earned + ?,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
}
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
}
return fmt.Sprintf("You keep the %s.", v.Keepsake)
}
return ""
}

View File

@@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
// etc. fire the way a competent prod player would set them up.
// Without this the sim under-counts class survival.
simAutoArmEnabled = true
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
// activeOmen's time.Now()).
simOmenDisabled = true
euro := &EuroPlugin{}
p := &AdventurePlugin{euro: euro}
return &SimRunner{P: p, Euro: euro}, nil
@@ -127,6 +131,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
return c, nil
}
// PrepareRealCharacter readies an already-persisted character (loaded from a
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
// nothing: the character keeps its real race/class/subclass/level, ability
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
// player rests before heading out, so we don't handicap a wounded prod snapshot
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
// affords itself regardless of the player's live coin balance. Returns the
// loaded character. The DB copy is disposable; the live prod file is untouched.
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
c, err := LoadDnDCharacter(uid)
if err != nil {
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
}
if c == nil {
return nil, fmt.Errorf("no character for %s in db copy", uid)
}
if c.Class == "" {
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
}
c.HPCurrent = c.HPMax
c.TempHP = 0
c.Exhaustion = 0
c.ShortRestCharges = c.Level
if err := SaveDnDCharacter(c); err != nil {
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
}
if bal := s.Euro.GetBalance(uid); bal < bank {
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
}
return c, nil
}
// stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire

View File

@@ -1349,6 +1349,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
if cleared, err := loadEpilogueCleared(uid); err == nil {
c.EpilogueCleared = cleared
}
if xp, err := loadRenownXP(uid); err == nil {
c.RenownXP = xp
}
if s, err := loadHouseState(uid); err == nil {
c.HouseTier = s.Tier
c.HouseLoanBalance = s.LoanBalance

View File

@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
// SendReply sends a reply to a specific event.
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
// The sink is the headless capture seam: a reply is an outbound room
// message, so — like SendMessage/SendDM — it diverts here when installed and
// the live client is never touched. Without this branch, reply-only handlers
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
return err
}
content := textContent(text)
if eventID != "" {
content.RelatesTo = &event.RelatesTo{