diff --git a/internal/db/db.go b/internal/db/db.go index c7d96a8..6749d0c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -336,6 +336,15 @@ func runMigrations(d *sql.DB) error { // URL link previews now post the page's og:image/twitter:image // thumbnail; cache it alongside the title/description. `ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`, + // Tempering (gogobee_engagement_plan.md B1). A magic item's rarity + // lives on its registry definition, so an upgraded instance needs + // somewhere of its own to record how far it has been pushed. temper + // counts rarity steps above the definition's base; effective rarity + // is derived at the effect/render/sell boundary, never written back. + // DEFAULT 0 is the correct value for every pre-existing row, so this + // needs no bootstrap backfill. + `ALTER TABLE adventure_inventory ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE magic_item_equipped ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -1500,6 +1509,21 @@ CREATE TABLE IF NOT EXISTS arena_stats ( updated_at INTEGER NOT NULL ); +-- Arena seasons (gogobee_engagement_plan.md C4). Season standings are DERIVED +-- from arena_history.created_at, so arena_stats stays lifetime and no +-- quarterly wipe ever runs. Only the awarded titles are archived here, one row +-- per (season, kind) — the champion for that quarter, frozen. +CREATE TABLE IF NOT EXISTS arena_season_titles ( + season TEXT NOT NULL, + kind TEXT NOT NULL, -- 'earnings' | 'streak' + user_id TEXT NOT NULL, + value INTEGER NOT NULL, + awarded_at INTEGER NOT NULL, + PRIMARY KEY (season, kind) +); +CREATE INDEX IF NOT EXISTS idx_arena_season_titles_user ON arena_season_titles(user_id); +CREATE INDEX IF NOT EXISTS idx_arena_history_created ON arena_history(created_at); + -- Rival System CREATE TABLE IF NOT EXISTS adventure_rival_records ( user_id TEXT NOT NULL, diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index f8434f1..99a298f 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -2,6 +2,7 @@ package plugin import ( "database/sql" + "encoding/json" "fmt" "log/slog" "strings" @@ -1137,9 +1138,160 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { return false }, }, + + // ── Expeditions ── + // All passive: they read the expedition rows the extract path already + // writes. Tier comes from the zone registry, not a column, so the + // checks map zone_id → Tier in Go. + { + ID: "expedition_clear_t1", Name: "Off the Porch", Description: "Cleared your first Tier 1 zone. It only gets worse from here.", + Emoji: "🗺️", + Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 1) }, + }, + { + ID: "expedition_clear_t2", Name: "Reasonably Lost", Description: "Cleared a Tier 2 zone. The map stops being a suggestion.", + Emoji: "🗺️", + Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 2) }, + }, + { + ID: "expedition_clear_t3", Name: "Deep Enough", Description: "Cleared a Tier 3 zone. Someone should have stopped you.", + Emoji: "🗺️", + Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 3) }, + }, + { + ID: "expedition_clear_t4", Name: "Past the Warnings", Description: "Cleared a Tier 4 zone. The signs were quite clear.", + Emoji: "🗺️", + Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 4) }, + }, + { + ID: "expedition_clear_t5", Name: "Where the Map Ends", Description: "Cleared a Tier 5 zone. There was nothing left to be brave about.", + Emoji: "🗺️", + Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) }, + }, + { + ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.", + Emoji: "🧭", + Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 1) }, + }, + { + ID: "expedition_master_t2", Name: "No Stone Unturned", Description: "Cleared every Tier 2 zone.", + Emoji: "🧭", + Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 2) }, + }, + { + ID: "expedition_master_t3", Name: "Completionist's Limp", Description: "Cleared every Tier 3 zone. Walk it off.", + Emoji: "🧭", + Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 3) }, + }, + { + ID: "expedition_master_t4", Name: "Nowhere Left to Go Wrong", Description: "Cleared every Tier 4 zone.", + Emoji: "🧭", + Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 4) }, + }, + { + ID: "expedition_master_t5", Name: "The Long Way Down", Description: "Cleared every Tier 5 zone. Both of them. All the way.", + Emoji: "🧭", + Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) }, + }, + { + ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.", + Emoji: "🌑", + Check: clearedUnderThreat50, + }, + { + ID: "temper_legendary", Name: "Hot Enough", Description: "Tempered an item all the way to Legendary. The blacksmith needed a moment.", + Emoji: "🔨", + Check: func(d *sql.DB, u id.UserID) bool { + // Granted by the temper path on reaching Legendary + return false + }, + }, } } +// ── Expedition achievement helpers ────────────────────────────────────────── + +// clearedZoneIDs returns the zones this player has beaten outright. Boss- +// defeated gates out extractions that merely ended in 'complete'. +func clearedZoneIDs(d *sql.DB, userID id.UserID) map[ZoneID]bool { + out := map[ZoneID]bool{} + rows, err := d.Query( + `SELECT DISTINCT zone_id FROM dnd_expedition + WHERE user_id = ? AND status = ? AND boss_defeated = 1`, + string(userID), ExpeditionStatusComplete) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var z string + if err := rows.Scan(&z); err != nil { + return out + } + out[ZoneID(z)] = true + } + return out +} + +func clearedAnyZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool { + cleared := clearedZoneIDs(d, userID) + for zid := range cleared { + if z, ok := getZone(zid); ok && z.Tier == tier { + return true + } + } + return false +} + +func clearedEveryZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool { + cleared := clearedZoneIDs(d, userID) + seen := 0 + for _, z := range allZones() { + if z.Tier != tier { + continue + } + if !cleared[z.ID] { + return false + } + seen++ + } + return seen > 0 +} + +// clearedUnderThreat50 looks for a cleared expedition whose peak threat never +// crossed 50 — the same max_threat_seen sample the Patient Zero milestone +// reads, so the two reward the same play. +// +// The key's *presence* is required, not just a low value. recordMaxThreat only +// samples on day rollover and never stores a zero, so an absent key means the +// expedition has no threat history at all (a same-day clear) — which is not +// evidence that threat stayed low. +func clearedUnderThreat50(d *sql.DB, userID id.UserID) bool { + rows, err := d.Query( + `SELECT region_state FROM dnd_expedition + WHERE user_id = ? AND status = ? AND boss_defeated = 1`, + string(userID), ExpeditionStatusComplete) + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return false + } + var state map[string]any + if json.Unmarshal([]byte(raw), &state) != nil { + continue + } + peak, ok := state[regionStateMaxThreatKey].(float64) + if ok && int(peak) < 50 { + return true + } + } + return false +} + // statGTE checks if a user_stats column is >= threshold. var allowedStatColumns = map[string]bool{ "total_messages": true, diff --git a/internal/plugin/achievements_expedition_test.go b/internal/plugin/achievements_expedition_test.go new file mode 100644 index 0000000..aafd685 --- /dev/null +++ b/internal/plugin/achievements_expedition_test.go @@ -0,0 +1,184 @@ +package plugin + +import ( + "fmt" + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// expeditionRowSeq gives each synthetic row a unique primary key; two rows can +// otherwise differ only in boss_defeated. +var expeditionRowSeq int + +// insertClearedExpedition writes an expedition row directly. status and +// boss_defeated are the two gates the achievement checks read. +func insertClearedExpedition(t *testing.T, user id.UserID, zoneID ZoneID, status string, bossDefeated int, regionState string) { + t.Helper() + expeditionRowSeq++ + _, err := db.Get().Exec( + `INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated, region_state) + VALUES (?, ?, ?, ?, ?, ?)`, + fmt.Sprintf("exp-%d", expeditionRowSeq), string(user), string(zoneID), status, bossDefeated, regionState) + if err != nil { + t.Fatal(err) + } +} + +// zonesOfTier lists the zone ids at a tier, straight from the registry, so the +// test tracks content edits instead of hardcoding a zone roster. +func zonesOfTier(tier ZoneTier) []ZoneID { + var out []ZoneID + for _, z := range allZones() { + if z.Tier == tier { + out = append(out, z.ID) + } + } + return out +} + +func newAchievementTestDB(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 TestExpeditionClearAchievements(t *testing.T) { + newAchievementTestDB(t) + d := db.Get() + user := id.UserID("@clears:test.invalid") + + t1 := zonesOfTier(1) + if len(t1) < 2 { + t.Skipf("tier 1 has %d zones; test wants at least 2", len(t1)) + } + + if clearedAnyZoneOfTier(d, user, 1) { + t.Error("clean slate reported a tier-1 clear") + } + + // A boss-defeated complete run is the only thing that counts. + insertClearedExpedition(t, user, t1[0], ExpeditionStatusComplete, 1, "{}") + if !clearedAnyZoneOfTier(d, user, 1) { + t.Error("cleared tier-1 zone not detected") + } + if clearedEveryZoneOfTier(d, user, 1) { + t.Error("one of several tier-1 zones counted as all of them") + } + if clearedAnyZoneOfTier(d, user, 2) { + t.Error("a tier-1 clear leaked into tier 2") + } + + for _, z := range t1[1:] { + insertClearedExpedition(t, user, z, ExpeditionStatusComplete, 1, "{}") + } + if !clearedEveryZoneOfTier(d, user, 1) { + t.Error("clearing every tier-1 zone did not satisfy the master check") + } +} + +// TestExpeditionClearIgnoresUnfinishedRuns: an abandoned run, or one where the +// player walked out without killing the boss, must not count as a clear. +func TestExpeditionClearIgnoresUnfinishedRuns(t *testing.T) { + newAchievementTestDB(t) + d := db.Get() + user := id.UserID("@partial:test.invalid") + + t2 := zonesOfTier(2) + if len(t2) == 0 { + t.Skip("no tier-2 zones") + } + + insertClearedExpedition(t, user, t2[0], ExpeditionStatusAbandoned, 1, "{}") + if clearedAnyZoneOfTier(d, user, 2) { + t.Error("an abandoned expedition counted as a clear") + } + + insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 0, "{}") + if clearedAnyZoneOfTier(d, user, 2) { + t.Error("a complete run with the boss alive counted as a clear") + } + + insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 1, "{}") + if !clearedAnyZoneOfTier(d, user, 2) { + t.Error("a real clear was not detected") + } +} + +// TestClearedUnderThreat50 pins the presence-vs-zero distinction. recordMaxThreat +// samples only on day rollover and never writes a zero, so an empty region_state +// means "no threat history", not "threat stayed at zero". +func TestClearedUnderThreat50(t *testing.T) { + newAchievementTestDB(t) + d := db.Get() + + zones := zonesOfTier(1) + if len(zones) == 0 { + t.Skip("no tier-1 zones") + } + z := zones[0] + + quiet := id.UserID("@quiet:test.invalid") + loud := id.UserID("@loud:test.invalid") + sameDay := id.UserID("@sameday:test.invalid") + + insertClearedExpedition(t, quiet, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":30}`) + if !clearedUnderThreat50(d, quiet) { + t.Error("peak threat 30 did not satisfy the under-50 check") + } + + insertClearedExpedition(t, loud, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":60}`) + if clearedUnderThreat50(d, loud) { + t.Error("peak threat 60 satisfied the under-50 check") + } + + insertClearedExpedition(t, sameDay, z, ExpeditionStatusComplete, 1, "{}") + if clearedUnderThreat50(d, sameDay) { + t.Error("an expedition with no threat samples was awarded the quiet clear") + } + + // Exactly 50 is not under 50. + edge := id.UserID("@edge:test.invalid") + insertClearedExpedition(t, edge, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":50}`) + if clearedUnderThreat50(d, edge) { + t.Error("peak threat of exactly 50 counted as under 50") + } +} + +// TestExpeditionAchievementIDsRegistered guards the wiring: every expedition +// achievement referenced by the tier helpers must exist in the registry, and a +// tier with no zones must not ship a master achievement nobody can earn. +func TestExpeditionAchievementIDsRegistered(t *testing.T) { + p := &AchievementsPlugin{} + defs := p.buildAchievements() + ids := map[string]bool{} + for _, a := range defs { + if ids[a.ID] { + t.Errorf("duplicate achievement id %q", a.ID) + } + ids[a.ID] = true + } + + for tier := ZoneTier(1); tier <= 5; tier++ { + if len(zonesOfTier(tier)) == 0 { + continue + } + for _, prefix := range []string{"expedition_clear_t", "expedition_master_t"} { + id := prefix + string(rune('0'+tier)) + if !ids[id] { + t.Errorf("tier %d has zones but %q is not registered", tier, id) + } + } + } + for _, id := range []string{"expedition_quiet_clear", "temper_legendary"} { + if !ids[id] { + t.Errorf("%q is not registered", id) + } + } +} diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index ef39778..a149b50 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -484,6 +484,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleRepairAllCmd(ctx) case strings.HasPrefix(lower, "repair "): return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:])) + case lower == "temper": + return p.handleTemperCmd(ctx) case lower == "boost": return p.handleBoostCmd(ctx) case lower == "recipes": @@ -518,6 +520,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs) ` + "`!adventure repair all`" + ` — Repair all damaged equipment ` + "`!adventure repair `" + ` — Repair a specific slot +` + "`!adventure temper`" + ` — Push a magic item one rarity step higher (blacksmith) ` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level ` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus ` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps @@ -853,6 +856,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.resolveBlacksmithSlotChoice(ctx, interaction) case "blacksmith_confirm": return p.resolveBlacksmithConfirm(ctx, interaction) + case "temper_pick": + return p.resolveTemperPick(ctx, interaction) + case "temper_confirm": + return p.resolveTemperConfirm(ctx, interaction) case "shop_category": return p.resolveShopCategoryChoice(ctx, interaction) case "shop_item": diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index f84a056..c1c5740 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -304,13 +304,17 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error { return p.SendDM(ctx.Sender, renderArenaPersonalStats(displayName, wins, losses, stats)) } +// handleArenaLeaderboard shows the current season's standings (C4). Lifetime +// totals stay reachable via `!arena stats`. func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error { - entries, err := loadArenaLeaderboard() + now := time.Now().UTC() + start, end := arenaSeasonBounds(now) + entries, err := loadArenaSeasonLeaderboard(start, end) if err != nil { - slog.Error("arena: failed to load leaderboard", "err", err) + slog.Error("arena: failed to load season leaderboard", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.") } - return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries)) + return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(arenaSeasonKey(now), entries)) } // ── Combat Resolution ─────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_arena_render.go b/internal/plugin/adventure_arena_render.go index a8858a5..08099a6 100644 --- a/internal/plugin/adventure_arena_render.go +++ b/internal/plugin/adventure_arena_render.go @@ -134,13 +134,13 @@ type ArenaLeaderboardEntry struct { TotalDeaths int } -func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string { +func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string { if len(entries) == 0 { - return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first." + return fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\nNobody has entered the arena this season. Be the first.", season) } var b strings.Builder - b.WriteString("⚔️ **Arena Leaderboard**\n\n") + b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season)) medals := []string{"🥇", "🥈", "🥉"} for i, e := range entries { @@ -157,6 +157,7 @@ func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string { b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n", prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths)) } + b.WriteString("\n_Season standings. `!arena stats` for your lifetime record._") return b.String() } diff --git a/internal/plugin/adventure_arena_season.go b/internal/plugin/adventure_arena_season.go new file mode 100644 index 0000000..339e56d --- /dev/null +++ b/internal/plugin/adventure_arena_season.go @@ -0,0 +1,225 @@ +package plugin + +import ( + "database/sql" + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// ── Arena seasons ─────────────────────────────────────────────────────────── +// +// Quarterly standings (gogobee_engagement_plan.md C4). The plan called for a +// quarterly *reset* of arena_stats; this derives season standings from +// arena_history.created_at instead. Same player-visible effect — the board +// clears every quarter — but lifetime totals survive for `!arena stats`, and +// there is no destructive job that can fire twice or half-way. +// +// Season titles are archived to their own table rather than player_meta.title: +// that column already carries the Survivalist milestone, and a season champion +// overwriting someone's expedition title would silently destroy it. + +// arenaSeasonTitleKinds are the two crowns awarded per season. +const ( + arenaTitleEarnings = "earnings" + arenaTitleStreak = "streak" +) + +// arenaSeasonKey names the quarter containing t, e.g. "2026-Q3". +func arenaSeasonKey(t time.Time) string { + t = t.UTC() + return fmt.Sprintf("%d-Q%d", t.Year(), (int(t.Month())-1)/3+1) +} + +// arenaSeasonStart is the first instant of the quarter containing t. +func arenaSeasonStart(t time.Time) time.Time { + t = t.UTC() + firstMonth := time.Month(((int(t.Month())-1)/3)*3 + 1) + return time.Date(t.Year(), firstMonth, 1, 0, 0, 0, 0, time.UTC) +} + +// arenaSeasonBounds returns [start, end) for the quarter containing t. +func arenaSeasonBounds(t time.Time) (time.Time, time.Time) { + start := arenaSeasonStart(t) + return start, start.AddDate(0, 3, 0) +} + +// previousArenaSeason returns the key and bounds of the quarter before t's. +func previousArenaSeason(t time.Time) (string, time.Time, time.Time) { + prev := arenaSeasonStart(t).AddDate(0, -1, 0) // any instant inside the prior quarter + start, end := arenaSeasonBounds(prev) + return arenaSeasonKey(prev), start, end +} + +// loadArenaSeasonLeaderboard aggregates arena_history over [start, end) into +// the same shape the lifetime board renders. +func loadArenaSeasonLeaderboard(start, end time.Time) ([]ArenaLeaderboardEntry, error) { + rows, err := db.Get().Query(` + SELECT h.user_id, COALESCE(c.display_name, h.user_id), + SUM(h.earnings), + MAX(h.tier), + SUM(CASE WHEN h.tier = 5 AND h.outcome = 'completed' THEN 1 ELSE 0 END), + COUNT(*), + SUM(CASE WHEN h.outcome = 'dead' THEN 1 ELSE 0 END) + FROM arena_history h + LEFT JOIN player_meta c ON c.user_id = h.user_id + WHERE h.created_at >= ? AND h.created_at < ? + GROUP BY h.user_id + ORDER BY SUM(h.earnings) DESC + LIMIT 10`, start.Unix(), end.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ArenaLeaderboardEntry + for rows.Next() { + var e ArenaLeaderboardEntry + var uid string + if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier, + &e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// arenaSeasonChampion finds the single top row for a season by the given +// metric. Returns ok=false when nobody entered the arena that quarter. +func arenaSeasonChampion(kind string, start, end time.Time) (id.UserID, int64, bool) { + var metric string + switch kind { + case arenaTitleEarnings: + metric = "SUM(earnings)" + case arenaTitleStreak: + metric = "MAX(rounds_survived)" + default: + return "", 0, false + } + + // Only runs that earned or survived something can crown anyone: a season of + // nothing but deaths at round zero should award no streak title. + q := fmt.Sprintf(` + SELECT user_id, %s AS metric + FROM arena_history + WHERE created_at >= ? AND created_at < ? + GROUP BY user_id + HAVING metric > 0 + ORDER BY metric DESC, user_id ASC + LIMIT 1`, metric) + + var uid string + var value int64 + err := db.Get().QueryRow(q, start.Unix(), end.Unix()).Scan(&uid, &value) + if err == sql.ErrNoRows { + return "", 0, false + } + if err != nil { + slog.Error("arena season: champion query", "kind", kind, "err", err) + return "", 0, false + } + return id.UserID(uid), value, true +} + +// recordArenaSeasonTitle archives a crown. Idempotent on (season, kind). +func recordArenaSeasonTitle(season, kind string, userID id.UserID, value int64, at time.Time) error { + _, err := db.Get().Exec(` + INSERT INTO arena_season_titles (season, kind, user_id, value, awarded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(season, kind) DO NOTHING`, + season, kind, string(userID), value, at.Unix()) + return err +} + +// loadArenaSeasonTitles returns every crown a player has ever taken. +func loadArenaSeasonTitles(userID id.UserID) ([]string, error) { + rows, err := db.Get().Query(` + SELECT season, kind FROM arena_season_titles + WHERE user_id = ? ORDER BY season DESC`, string(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var season, kind string + if err := rows.Scan(&season, &kind); err != nil { + return nil, err + } + out = append(out, fmt.Sprintf("%s %s", season, arenaTitleName(kind))) + } + return out, rows.Err() +} + +func arenaTitleName(kind string) string { + switch kind { + case arenaTitleEarnings: + return "Coinlord of the Arena" + case arenaTitleStreak: + return "Longest Walk" + } + return kind +} + +// ── Rollover ──────────────────────────────────────────────────────────────── + +// arenaSeasonRollover awards the previous season's crowns exactly once, and +// announces them. Safe to call every midnight: JobCompleted keyed on the season +// makes it a no-op for the rest of the quarter, and a bot that was down on the +// rollover day still catches up the next time it wakes. +func (p *AdventurePlugin) arenaSeasonRollover(now time.Time) { + season, start, end := previousArenaSeason(now) + jobName := "arena_season_rollover" + if db.JobCompleted(jobName, season) { + return + } + // Guard against awarding a season that hasn't finished yet — only possible + // if a caller passes a doctored clock. + if !now.UTC().After(end) { + return + } + + var lines []string + for _, kind := range []string{arenaTitleEarnings, arenaTitleStreak} { + uid, value, ok := arenaSeasonChampion(kind, start, end) + if !ok { + continue + } + if err := recordArenaSeasonTitle(season, kind, uid, value, now); err != nil { + slog.Error("arena season: record title failed", "season", season, "kind", kind, "err", err) + continue // don't announce a crown we failed to persist + } + name, _ := loadDisplayName(uid) + switch kind { + case arenaTitleEarnings: + lines = append(lines, fmt.Sprintf("🥇 **%s** — _%s_ (€%d earned)", + name, arenaTitleName(kind), value)) + case arenaTitleStreak: + lines = append(lines, fmt.Sprintf("🏃 **%s** — _%s_ (%d rounds in one run)", + name, arenaTitleName(kind), value)) + } + } + + db.MarkJobCompleted(jobName, season) + if len(lines) == 0 { + slog.Info("arena season: closed with no entrants", "season", season) + return + } + slog.Info("arena season: crowned", "season", season, "titles", len(lines)) + + gr := gamesRoom() + if gr == "" { + return + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("⚔️ **Arena Season %s has ended.**\n\n", season)) + sb.WriteString(strings.Join(lines, "\n")) + sb.WriteString("\n\nThe board is clear. Season " + arenaSeasonKey(now) + " starts now.") + p.SendMessage(id.RoomID(gr), sb.String()) +} diff --git a/internal/plugin/adventure_arena_season_test.go b/internal/plugin/adventure_arena_season_test.go new file mode 100644 index 0000000..911d833 --- /dev/null +++ b/internal/plugin/adventure_arena_season_test.go @@ -0,0 +1,187 @@ +package plugin + +import ( + "testing" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func TestArenaSeasonKeyAndBounds(t *testing.T) { + tests := []struct { + when time.Time + key string + wantStart time.Time + }{ + {time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + {time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + {time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), "2026-Q2", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)}, + {time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC), "2026-Q3", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}, + {time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), "2026-Q4", time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}, + } + for _, tc := range tests { + if got := arenaSeasonKey(tc.when); got != tc.key { + t.Errorf("arenaSeasonKey(%s) = %s, want %s", tc.when, got, tc.key) + } + start, end := arenaSeasonBounds(tc.when) + if !start.Equal(tc.wantStart) { + t.Errorf("season start for %s = %s, want %s", tc.when, start, tc.wantStart) + } + if !end.Equal(start.AddDate(0, 3, 0)) { + t.Errorf("season end for %s is not start+3mo", tc.when) + } + if !tc.when.Before(end) || tc.when.Before(start) { + t.Errorf("%s does not fall inside its own season bounds", tc.when) + } + } +} + +// TestPreviousArenaSeasonWrapsYear pins the Q1 → prior-year-Q4 edge. +func TestPreviousArenaSeasonWrapsYear(t *testing.T) { + key, start, end := previousArenaSeason(time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC)) + if key != "2025-Q4" { + t.Errorf("previous season = %s, want 2025-Q4", key) + } + if !start.Equal(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("start = %s, want 2025-10-01", start) + } + if !end.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("end = %s, want 2026-01-01", end) + } +} + +func insertArenaRun(t *testing.T, user id.UserID, tier, rounds int, earnings int64, outcome string, at time.Time) { + t.Helper() + _, err := db.Get().Exec( + `INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at) + VALUES (?, 1, ?, ?, ?, ?, 'Test Monster', ?)`, + string(user), tier, rounds, earnings, outcome, at.Unix()) + if err != nil { + t.Fatal(err) + } +} + +// TestArenaSeasonLeaderboardWindowing: only runs inside the season window count. +func TestArenaSeasonLeaderboardWindowing(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + + inSeason := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + start, end := arenaSeasonBounds(inSeason) + + alice := id.UserID("@alice:test.invalid") + bob := id.UserID("@bob:test.invalid") + + insertArenaRun(t, alice, 3, 4, 5000, "completed", inSeason) + insertArenaRun(t, alice, 5, 2, 1000, "dead", inSeason.AddDate(0, 0, 1)) + insertArenaRun(t, bob, 2, 3, 9000, "cashed_out", inSeason) + // Last season — must not appear. + insertArenaRun(t, alice, 5, 4, 999999, "completed", start.AddDate(0, 0, -1)) + + entries, err := loadArenaSeasonLeaderboard(start, end) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + // Bob out-earned Alice this season (9000 vs 6000). + if entries[0].DisplayName != string(bob) { + t.Errorf("top entry = %s, want bob", entries[0].DisplayName) + } + if entries[0].TotalEarnings != 9000 { + t.Errorf("bob earnings = %d, want 9000", entries[0].TotalEarnings) + } + if entries[1].TotalEarnings != 6000 { + t.Errorf("alice earnings = %d, want 6000 (last season's 999999 must not count)", entries[1].TotalEarnings) + } + if entries[1].TotalDeaths != 1 { + t.Errorf("alice deaths = %d, want 1", entries[1].TotalDeaths) + } + if entries[1].HighestTier != 5 { + t.Errorf("alice highest tier = %d, want 5", entries[1].HighestTier) + } +} + +// TestArenaSeasonChampions: earnings crown goes by total, streak crown by the +// single longest run — they can be different players. +func TestArenaSeasonChampions(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + + when := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + start, end := arenaSeasonBounds(when) + + rich := id.UserID("@rich:test.invalid") + tough := id.UserID("@tough:test.invalid") + + insertArenaRun(t, rich, 5, 2, 50000, "cashed_out", when) + insertArenaRun(t, tough, 1, 12, 500, "completed", when) + + uid, val, ok := arenaSeasonChampion(arenaTitleEarnings, start, end) + if !ok || uid != rich || val != 50000 { + t.Errorf("earnings champion = (%s, %d, %v), want rich/50000", uid, val, ok) + } + uid, val, ok = arenaSeasonChampion(arenaTitleStreak, start, end) + if !ok || uid != tough || val != 12 { + t.Errorf("streak champion = (%s, %d, %v), want tough/12", uid, val, ok) + } + + // An empty season crowns nobody. + emptyStart, emptyEnd := arenaSeasonBounds(when.AddDate(1, 0, 0)) + if _, _, ok := arenaSeasonChampion(arenaTitleEarnings, emptyStart, emptyEnd); ok { + t.Error("an empty season produced an earnings champion") + } + + // A season of nothing but round-zero deaths crowns no streak. + deathsOnly := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC) + dStart, dEnd := arenaSeasonBounds(deathsOnly) + insertArenaRun(t, tough, 1, 0, 0, "dead", deathsOnly) + if _, _, ok := arenaSeasonChampion(arenaTitleStreak, dStart, dEnd); ok { + t.Error("a season of round-zero deaths produced a streak champion") + } +} + +// TestRecordArenaSeasonTitleIdempotent: the rollover must be safe to re-run. +func TestRecordArenaSeasonTitleIdempotent(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + + now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + winner := id.UserID("@winner:test.invalid") + usurper := id.UserID("@usurper:test.invalid") + + if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, winner, 100, now); err != nil { + t.Fatal(err) + } + // A second write for the same (season, kind) must not overwrite the crown. + if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, usurper, 999, now); err != nil { + t.Fatal(err) + } + + titles, err := loadArenaSeasonTitles(winner) + if err != nil { + t.Fatal(err) + } + if len(titles) != 1 { + t.Fatalf("winner has %d titles, want 1", len(titles)) + } + stolen, _ := loadArenaSeasonTitles(usurper) + if len(stolen) != 0 { + t.Errorf("usurper took the crown: %v", stolen) + } +} diff --git a/internal/plugin/adventure_arena_test.go b/internal/plugin/adventure_arena_test.go index 8266c6b..874599f 100644 --- a/internal/plugin/adventure_arena_test.go +++ b/internal/plugin/adventure_arena_test.go @@ -637,9 +637,9 @@ func TestRenderArenaPersonalStats_WithStats(t *testing.T) { } func TestRenderArenaLeaderboard_Empty(t *testing.T) { - text := renderArenaLeaderboard(nil) - if !strings.Contains(text, "No arena runs") { - t.Error("empty leaderboard should say no runs") + text := renderArenaLeaderboard("2026-Q3", nil) + if !strings.Contains(text, "Nobody has entered") { + t.Error("empty leaderboard should say nobody entered") } } @@ -648,7 +648,7 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) { {DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2}, {DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7}, } - text := renderArenaLeaderboard(entries) + text := renderArenaLeaderboard("2026-Q3", entries) if !strings.Contains(text, "Alice") { t.Error("leaderboard should contain Alice") } diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 3838d67..dbf6a22 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -121,6 +121,7 @@ type AdvItem struct { Value int64 Slot EquipmentSlot // non-empty for MasterworkGear SkillSource string // non-empty for MasterworkGear + Temper int // rarity steps above base; magic_item rows only } type AdvBuff struct { @@ -560,7 +561,7 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error { func loadAdvInventory(userID id.UserID) ([]AdvItem, error) { d := db.Get() rows, err := d.Query(` - SELECT id, name, item_type, tier, value, slot, skill_source + SELECT id, name, item_type, tier, value, slot, skill_source, temper FROM adventure_inventory WHERE user_id = ? ORDER BY tier DESC, value DESC`, string(userID)) if err != nil { @@ -572,7 +573,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) { for rows.Next() { var it AdvItem var slot string - if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil { + if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil { return nil, err } it.Slot = EquipmentSlot(slot) @@ -584,9 +585,19 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) { func addAdvInventoryItem(userID id.UserID, item AdvItem) error { d := db.Get() _, err := d.Exec(` - INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource) + INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source, temper) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource, item.Temper) + return err +} + +// temperInventoryItem records a tempering step on an un-equipped magic item. +// Tier and value move with the item's new effective rarity so it keeps sorting +// and selling correctly. +func temperInventoryItem(itemID int64, temper, tier int, value int64) error { + _, err := db.Get().Exec( + `UPDATE adventure_inventory SET temper = ?, tier = ?, value = ? WHERE id = ?`, + temper, tier, value, itemID) return err } diff --git a/internal/plugin/adventure_flavor_blacksmith.go b/internal/plugin/adventure_flavor_blacksmith.go index 85685af..911ae82 100644 --- a/internal/plugin/adventure_flavor_blacksmith.go +++ b/internal/plugin/adventure_flavor_blacksmith.go @@ -49,6 +49,22 @@ var blacksmithArena = []string{ "The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.", } +var blacksmithTemperGreetings = []string{ + "_sets aside the repair work_ You want me to make something _more_ than it is. That's different. That's the good work.", + "Tempering. _rolls his shoulders_ I don't repair it. I take it apart and I convince it to come back stronger. It doesn't always want to.", + "_wipes the counter clean with one sweep_ Put it down. Right there. Let me see what it could be instead of what it is.", + "Anything can be pushed further. Anything. It's just a question of what you're willing to spend and how much heat it can take before it gives in.", + "_eyes light up_ Oh, you've come for the real thing. Not a patch. A _change_. Show me. Show me everything you've got.", +} + +var blacksmithTemperCompletion = []string{ + "_lifts it from the quench, steam everywhere_ Feel the weight of it now. It's not the same thing you handed me. It never will be again.", + "_breathing hard_ It fought me. Right to the end it fought me. And now look at it. Look what it became.", + "There. _sets it down with enormous care_ I pushed it further than it wanted to go, and it thanked me for it. They always do.", + "_holds it up, turning it slowly_ I've been doing this a long time and this one still surprised me. Take it before I change my mind about giving it back.", + "It's still hot. It'll be hot for a while. That's what happens when you ask something to become more than it was.", +} + var blacksmithFullCondition = []string{ "_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.", "Full condition. You've been taking care of it. Good. I appreciate that in a person.", diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 06acabb..02bea94 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -389,6 +389,9 @@ func (p *AdventurePlugin) midnightTicker() { slog.Error("adventure: midnight reset failed, will retry next tick", "err", err) continue } + // Close out the arena season if one just ended. Self-dedups on the + // season key, so this is a cheap no-op on every other night. + p.arenaSeasonRollover(time.Now().UTC()) db.MarkJobCompleted(jobName, dateKey) lastRanDate = dateKey } diff --git a/internal/plugin/adventure_temper.go b/internal/plugin/adventure_temper.go new file mode 100644 index 0000000..be6a446 --- /dev/null +++ b/internal/plugin/adventure_temper.go @@ -0,0 +1,406 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "maunium.net/go/mautrix/id" +) + +// ── Tempering ─────────────────────────────────────────────────────────────── +// +// The blacksmith's endgame role: push an owned magic item one rung up the +// rarity ladder (gogobee_engagement_plan.md B1). Rarity flows through the +// existing scalar formulas in magic_items_gameplay.go, so tempering adds no +// new combat math — and because it caps at Legendary it accelerates reaching +// the existing best-in-slot ceiling rather than raising it. Every duplicate +// drop becomes a candidate instead of vendor trash. +// +// Rarity itself lives on the registry definition, so a tempered instance +// records its progress as a `temper` step count on its own row; effective +// rarity is derived on read (see temperedItem). + +// temperMaterialNames are the legendary crafting materials the final rung +// consumes. Both drop as UniqueAlways entries on their T5 zone slate, so one +// clear of the Underforge or the Abyss Portal yields exactly one. +var temperMaterialNames = []string{"Thyraks Core", "Portal Fragment"} + +// temperStepCost is what one rung costs, keyed by the *target* rarity's tier. +// The euro costs and foraging gates mirror the crafting recipe tiers in +// adventure_consumables.go, so the blacksmith reads as an extension of the +// crafting ladder rather than a parallel economy. +type temperStepCost struct { + Euros int + MinForaging int + NeedsMaterial bool +} + +// Only the final rung demands a T5 material. Gating the lower rungs on one too +// would make tempering unreachable until a player already clears T5 content — +// which is exactly when they stop needing it. +var temperCosts = map[int]temperStepCost{ + 2: {Euros: 10_000, MinForaging: 15}, + 3: {Euros: 25_000, MinForaging: 20}, + 4: {Euros: 60_000, MinForaging: 25}, + 5: {Euros: 150_000, MinForaging: 30, NeedsMaterial: true}, +} + +// temperTarget is one temperable item, from either side of the equip line. +type temperTarget struct { + Equipped bool + Slot DnDSlot // set when Equipped + InvID int64 // set when not Equipped + Base MagicItem + Temper int +} + +// Effective is the item as it stands today, before this temper. +func (t temperTarget) Effective() MagicItem { return temperedItem(t.Base, t.Temper) } + +// NextRarity is the rung this temper would buy. +func (t temperTarget) NextRarity() DnDRarity { return temperedRarity(t.Base.Rarity, t.Temper+1) } + +// cost is the price of this target's next rung. +func (t temperTarget) cost() temperStepCost { return temperCosts[rarityLootTierNum(t.NextRarity())] } + +// sameItemAs reports whether other is the same physical instance, used to +// re-find a target after a fresh reload so a concurrent equip/sell can't let a +// stale pending confirm temper the wrong thing. +func (t temperTarget) sameItemAs(other temperTarget) bool { + if t.Equipped != other.Equipped { + return false + } + if t.Equipped { + return t.Slot == other.Slot && t.Base.ID == other.Base.ID + } + return t.InvID == other.InvID +} + +type advPendingTemperPick struct { + Targets []temperTarget +} + +type advPendingTemperConfirm struct { + Target temperTarget + Material *AdvItem // consumed on confirm; nil when the rung needs none +} + +// collectTemperTargets gathers every magic item the player owns that still has +// a rung ahead of it — worn or stowed. Potions and scrolls are excluded: they +// carry a zero combat effect, so a rarity bump would buy nothing. +func collectTemperTargets(userID id.UserID) ([]temperTarget, error) { + var out []temperTarget + + equipped, err := loadEquippedMagicItems(userID) + if err != nil { + return nil, err + } + for _, ds := range dndSlotOrder { + e, ok := equipped[ds] + if !ok || e.Item.ID == "" { + continue + } + if temperStepsToLegendary(e.Item.Rarity, e.Temper) == 0 { + continue + } + out = append(out, temperTarget{Equipped: true, Slot: ds, Base: e.Item, Temper: e.Temper}) + } + + items, err := loadAdvInventory(userID) + if err != nil { + return nil, err + } + for _, it := range items { + if it.Type != "magic_item" { + continue + } + mi, ok := magicItemFromAdvItem(it) + if !ok || mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { + continue + } + if temperStepsToLegendary(mi.Rarity, it.Temper) == 0 { + continue + } + out = append(out, temperTarget{InvID: it.ID, Base: mi, Temper: it.Temper}) + } + return out, nil +} + +// findTemperMaterial returns the first legendary material row in inventory. +func findTemperMaterial(items []AdvItem) (AdvItem, bool) { + for _, it := range items { + for _, name := range temperMaterialNames { + if it.Name == name { + return it, true + } + } + } + return AdvItem{}, false +} + +// parseTemperIndex reads a leading 1-indexed number and bounds-checks it. +func parseTemperIndex(reply string, n int) (int, bool) { + idx, parsed := 0, false + for _, c := range strings.TrimSpace(reply) { + if c < '0' || c > '9' { + break + } + idx = idx*10 + int(c-'0') + parsed = true + } + idx-- + if !parsed || idx < 0 || idx >= n { + return 0, false + } + return idx, true +} + +// ── Command ───────────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) handleTemperCmd(ctx MessageContext) error { + char, _, err := p.ensureCharacter(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to load your character.") + } + + targets, err := collectTemperTargets(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to read your curios.") + } + if len(targets) == 0 { + return p.SendDM(ctx.Sender, + "_He looks over your kit and shrugs._ Nothing here I can push any further. "+ + "Bring me something that isn't already Legendary.") + } + + greeting, _ := advPickFlavor(blacksmithTemperGreetings, ctx.Sender, "temper_greet") + + var sb strings.Builder + sb.WriteString("🔨 **Tempering**\n\n") + sb.WriteString(greeting) + sb.WriteString("\n\n") + for i, t := range targets { + cur := t.Effective() + cost := t.cost() + where := "stowed" + if t.Equipped { + where = "worn — " + string(t.Slot) + } + gate := "" + switch { + case char.ForagingSkill < cost.MinForaging: + gate = fmt.Sprintf(" — 🔒 needs Foraging %d", cost.MinForaging) + case cost.NeedsMaterial: + gate = " — needs a legendary material" + } + sb.WriteString(fmt.Sprintf("%d. **%s** _(%s → %s)_ — €%d _(%s)_%s\n", + i+1, cur.Name, cur.Rarity, t.NextRarity(), cost.Euros, where, gate)) + } + sb.WriteString("\nReply with a number to temper it, or \"cancel\".") + + p.pending.Store(string(ctx.Sender), &advPendingInteraction{ + Type: "temper_pick", + Data: &advPendingTemperPick{Targets: targets}, + ExpiresAt: time.Now().Add(advDMResponseWindow), + }) + return p.SendDM(ctx.Sender, sb.String()) +} + +func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *advPendingInteraction) error { + data := interaction.Data.(*advPendingTemperPick) + reply := strings.TrimSpace(ctx.Body) + if strings.EqualFold(reply, "cancel") { + return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.") + } + idx, ok := parseTemperIndex(reply, len(data.Targets)) + if !ok { + p.pending.Store(string(ctx.Sender), interaction) + return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".") + } + return p.buildTemperConfirm(ctx.Sender, data.Targets[idx]) +} + +// buildTemperConfirm re-checks every gate against fresh state before quoting a +// price, then parks a confirm. executeTemper re-checks them all again — this +// pass exists to give the player a real error message, not to be trusted. +func (p *AdventurePlugin) buildTemperConfirm(userID id.UserID, t temperTarget) error { + char, _, err := p.ensureCharacter(userID) + if err != nil { + return p.SendDM(userID, "Failed to load your character.") + } + cost := t.cost() + if char.ForagingSkill < cost.MinForaging { + return p.SendDM(userID, fmt.Sprintf( + "_He turns the piece over, then sets it down._ I could ruin this. Come back when you know your "+ + "materials better — **Foraging %d**. You're at %d.", cost.MinForaging, char.ForagingSkill)) + } + + items, err := loadAdvInventory(userID) + if err != nil { + return p.SendDM(userID, "Failed to read your inventory.") + } + var material *AdvItem + if cost.NeedsMaterial { + mat, ok := findTemperMaterial(items) + if !ok { + return p.SendDM(userID, fmt.Sprintf( + "_He exhales through his teeth._ Legendary is a different conversation. I need a **%s** on this "+ + "table before I touch it. They come out of the deepest places — the Underforge, the Portal.", + strings.Join(temperMaterialNames, "** or a **"))) + } + material = &mat + } + + if bal := p.euro.GetBalance(userID); bal < float64(cost.Euros) { + return p.SendDM(userID, fmt.Sprintf( + "_He names the price without apology._ €%d. You have €%.0f. The forge doesn't run on good intentions.", + cost.Euros, bal)) + } + + cur := t.Effective() + next := temperedItem(t.Base, t.Temper+1) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🔨 **Temper %s?**\n\n", cur.Name)) + sb.WriteString(fmt.Sprintf(" %s _(%s)_ → %s _(%s)_\n", cur.Name, cur.Rarity, next.Name, next.Rarity)) + sb.WriteString(fmt.Sprintf(" %s\n ↳ %s\n\n", magicItemEffectSummary(cur), magicItemEffectSummary(next))) + sb.WriteString(fmt.Sprintf(" Cost: **€%d**", cost.Euros)) + if material != nil { + sb.WriteString(fmt.Sprintf(" + one **%s**", material.Name)) + } + sb.WriteString("\n\nReply \"yes\" to hand it over, or anything else to keep it as it is.") + + p.pending.Store(string(userID), &advPendingInteraction{ + Type: "temper_confirm", + Data: &advPendingTemperConfirm{Target: t, Material: material}, + ExpiresAt: time.Now().Add(advDMResponseWindow), + }) + return p.SendDM(userID, sb.String()) +} + +func (p *AdventurePlugin) resolveTemperConfirm(ctx MessageContext, interaction *advPendingInteraction) error { + userMu := p.advUserLock(ctx.Sender) + userMu.Lock() + defer userMu.Unlock() + + data := interaction.Data.(*advPendingTemperConfirm) + reply := strings.ToLower(strings.TrimSpace(ctx.Body)) + if reply != "yes" && reply != "y" && reply != "confirm" { + return p.SendDM(ctx.Sender, "_He nods once and turns back to the fire._ It's a good piece as it is.") + } + return p.executeTemper(ctx.Sender, data) +} + +// executeTemper re-validates against fresh state, then charges and applies. +// Order matters: the euro debit is the only atomic guard we have, so it goes +// first; the material and the temper write both roll back onto it. +func (p *AdventurePlugin) executeTemper(userID id.UserID, data *advPendingTemperConfirm) error { + char, _, err := p.ensureCharacter(userID) + if err != nil { + return p.SendDM(userID, "Failed to load your character.") + } + + // Re-find the target. Between the confirm prompt and this reply the + // player may have equipped, sold, or already tempered it. + fresh, err := collectTemperTargets(userID) + if err != nil { + return p.SendDM(userID, "Failed to read your curios.") + } + var target temperTarget + found := false + for _, t := range fresh { + if t.sameItemAs(data.Target) { + target, found = t, true + break + } + } + if !found { + return p.SendDM(userID, "_He looks at the empty table._ That piece isn't here any more.") + } + if target.Temper != data.Target.Temper { + return p.SendDM(userID, "_He raises an eyebrow._ Someone's already been at this one. Ask me again.") + } + + cost := target.cost() + if char.ForagingSkill < cost.MinForaging { + return p.SendDM(userID, fmt.Sprintf("You need **Foraging %d** for that.", cost.MinForaging)) + } + + // Re-locate the material by row id — a concurrent craft may have eaten it. + var material *AdvItem + if cost.NeedsMaterial { + items, err := loadAdvInventory(userID) + if err != nil { + return p.SendDM(userID, "Failed to read your inventory.") + } + mat, ok := findTemperMaterial(items) + if !ok { + return p.SendDM(userID, "_He taps the empty spot on the table._ The material's gone. Find another.") + } + material = &mat + } + + if !p.euro.Debit(userID, float64(cost.Euros), "temper") { + return p.SendDM(userID, fmt.Sprintf("Payment failed — tempering costs €%d.", cost.Euros)) + } + + refund := func(reason string) { + p.euro.Credit(userID, float64(cost.Euros), "temper_refund") + slog.Error("temper: rolled back", "user", userID, "item", target.Base.ID, "reason", reason) + } + + if material != nil { + if err := removeAdvInventoryItem(material.ID); err != nil { + refund("material consume failed") + return p.SendDM(userID, "Something went wrong at the forge. Your money is back.") + } + } + + newTemper := target.Temper + 1 + if target.Equipped { + err = temperEquippedItem(userID, target.Slot, newTemper) + } else { + bumped := temperedItem(target.Base, newTemper) + err = temperInventoryItem(target.InvID, newTemper, rarityLootTierNum(bumped.Rarity), int64(bumped.Value)) + } + if err != nil { + refund("temper write failed") + if material != nil { + if rbErr := addAdvInventoryItem(userID, *material); rbErr != nil { + slog.Error("temper: material rollback failed", + "user", userID, "material", material.Name, "err", rbErr) + } + } + return p.SendDM(userID, "Something went wrong at the forge. Your money is back.") + } + + result := temperedItem(target.Base, newTemper) + line, _ := advPickFlavor(blacksmithTemperCompletion, userID, "temper_done") + + var sb strings.Builder + sb.WriteString(line) + sb.WriteString(fmt.Sprintf("\n\n🔨 **%s** is now **%s**.\n%s\n", + result.Name, result.Rarity, magicItemEffectSummary(result))) + if remaining := temperStepsToLegendary(target.Base.Rarity, newTemper); remaining > 0 { + sb.WriteString(fmt.Sprintf("\n_%d more temper%s would make it Legendary._", + remaining, pluralS(remaining))) + } + if err := p.SendDM(userID, sb.String()); err != nil { + return err + } + + if result.Rarity == RarityLegendary { + if p.achievements != nil { + p.achievements.GrantAchievement(userID, "temper_legendary") + } + if gr := gamesRoom(); gr != "" { + displayName, _ := loadDisplayName(userID) + p.SendMessage(id.RoomID(gr), fmt.Sprintf( + "🔨 The forge in town ran hot all night. **%s** walked out with a **Legendary %s**.", + displayName, result.Name)) + } + } + return nil +} diff --git a/internal/plugin/adventure_temper_test.go b/internal/plugin/adventure_temper_test.go new file mode 100644 index 0000000..2c5862d --- /dev/null +++ b/internal/plugin/adventure_temper_test.go @@ -0,0 +1,355 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// TestTemperLadderWalk pins the rung order and the Legendary clamp. VeryRare +// must fold onto Epic — it shares Epic's tier and power scalar, so treating it +// as its own rung would sell a step that changes no number. +func TestTemperLadderWalk(t *testing.T) { + tests := []struct { + base DnDRarity + steps int + want DnDRarity + }{ + {RarityCommon, 0, RarityCommon}, + {RarityCommon, 1, RarityUncommon}, + {RarityCommon, 4, RarityLegendary}, + {RarityCommon, 99, RarityLegendary}, // clamp, never past the ceiling + {RarityRare, 1, RarityEpic}, + {RarityEpic, 1, RarityLegendary}, + {RarityLegendary, 1, RarityLegendary}, + {RarityVeryRare, 0, RarityVeryRare}, // untempered items keep their label + {RarityVeryRare, 1, RarityLegendary}, + {RarityUncommon, -1, RarityUncommon}, // negative steps are inert + } + for _, tc := range tests { + if got := temperedRarity(tc.base, tc.steps); got != tc.want { + t.Errorf("temperedRarity(%s, %d) = %s, want %s", tc.base, tc.steps, got, tc.want) + } + } +} + +func TestTemperStepsToLegendary(t *testing.T) { + tests := []struct { + base DnDRarity + steps int + want int + }{ + {RarityCommon, 0, 4}, + {RarityCommon, 2, 2}, + {RarityCommon, 4, 0}, + {RarityCommon, 10, 0}, + {RarityVeryRare, 0, 1}, // folds onto Epic, one rung short + {RarityLegendary, 0, 0}, + } + for _, tc := range tests { + if got := temperStepsToLegendary(tc.base, tc.steps); got != tc.want { + t.Errorf("temperStepsToLegendary(%s, %d) = %d, want %d", tc.base, tc.steps, got, tc.want) + } + } +} + +// TestTemperCostsCoverEveryRung guards against a ladder edit that leaves a rung +// priced at the zero value — a free temper. +func TestTemperCostsCoverEveryRung(t *testing.T) { + for rung := 1; rung < len(temperLadder); rung++ { + target := temperLadder[rung] + cost, ok := temperCosts[rarityLootTierNum(target)] + if !ok { + t.Fatalf("no temper cost for target rarity %s", target) + } + if cost.Euros <= 0 || cost.MinForaging <= 0 { + t.Errorf("temper to %s is free: %+v", target, cost) + } + } + // Only the Legendary rung consumes a material. + for tier, cost := range temperCosts { + if want := tier == 5; cost.NeedsMaterial != want { + t.Errorf("tier %d NeedsMaterial = %v, want %v", tier, cost.NeedsMaterial, want) + } + } +} + +// TestTemperedItemScalesPowerAndValue asserts a tempered item is exactly an +// item of its effective rarity — the whole point of B1 is that rarity flows +// through the existing scalar formulas with no new combat math. +func TestTemperedItemScalesPowerAndValue(t *testing.T) { + base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityRare, Value: 300} + + if got := temperedItem(base, 0); got != base { + t.Errorf("temper 0 mutated the item: %+v", got) + } + + up := temperedItem(base, 1) + if up.Rarity != RarityEpic { + t.Fatalf("rarity = %s, want Epic", up.Rarity) + } + // Value tracks the power axis: Rare=3 → Epic=4. + if wantVal := int(300 * (rarityPowerScalar(RarityEpic) / rarityPowerScalar(RarityRare))); up.Value != wantVal { + t.Errorf("value = %d, want %d", up.Value, wantVal) + } + // A tempered Epic must fight exactly like a native Epic. (Its Value is + // deliberately higher than a native Epic definition's — value tracks the + // power axis, and the player paid to move it there.) + native := base + native.Rarity = RarityEpic + if magicItemEffectFor(up) != magicItemEffectFor(native) { + t.Errorf("tempered effect diverges from native Epic effect") + } + // Base must be untouched — temperedItem takes a copy. + if base.Rarity != RarityRare || base.Value != 300 { + t.Errorf("temperedItem mutated its argument: %+v", base) + } +} + +// TestTemperNeverDoubleBumps is the invariant the whole design hangs on: the +// stored row keeps BASE rarity plus a step count, so loading and re-saving an +// item any number of times must not compound the upgrade. +func TestTemperNeverDoubleBumps(t *testing.T) { + base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100} + e := EquippedMagicItem{Item: base, Temper: 2} + + first := e.Effective() + // Simulate a load→save→load cycle: Item stays base, Temper stays put. + again := EquippedMagicItem{Item: e.Item, Temper: e.Temper}.Effective() + if first != again { + t.Errorf("round-trip changed the item: %+v vs %+v", first, again) + } + if e.Item.Rarity != RarityCommon { + t.Errorf("Effective() mutated the stored base rarity to %s", e.Item.Rarity) + } + if first.Rarity != RarityRare { + t.Errorf("Common + 2 tempers = %s, want Rare", first.Rarity) + } +} + +// TestTemperSurvivesEquipRoundTrip: a tempered item that gets swapped back to +// inventory must carry its temper with it, priced at its effective rarity. +// This is the path resolveMagicEquipReply takes on a slot swap. +func TestTemperSurvivesEquipRoundTrip(t *testing.T) { + base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100} + + back := magicItemSellAt(base, 3) + if back.Temper != 3 { + t.Errorf("swap-back lost temper: %d", back.Temper) + } + if back.Tier != rarityLootTierNum(RarityEpic) { + t.Errorf("swap-back tier = %d, want Epic tier", back.Tier) + } + if back.Value <= int64(base.Value) { + t.Errorf("swap-back value %d not scaled up from %d", back.Value, base.Value) + } + // An untempered sell must be byte-identical to the old behaviour. + if magicItemSellAt(base, 0) != magicItemSell(base) { + t.Errorf("magicItemSellAt(_, 0) diverges from magicItemSell") + } +} + +// TestTemperPersistence exercises the two DB writes: an equipped item's temper +// and a stowed item's temper both survive a reload, and loadEquippedMagicItems +// hands back a base-rarity Item with the step count beside it. +func TestTemperPersistence(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + + user := id.UserID("@temper:test.invalid") + + var item MagicItem + for _, mi := range magicItemRegistry { + if mi.Slot != "" && mi.Rarity == RarityCommon { + item = mi + break + } + } + if item.ID == "" { + for _, mi := range magicItemRegistry { + if mi.Slot != "" && temperStepsToLegendary(mi.Rarity, 0) > 0 { + item = mi + break + } + } + } + if item.ID == "" { + t.Skip("no slotted sub-Legendary item in registry") + } + + if err := equipMagicItem(user, item.Slot, item.ID, false, 0); err != nil { + t.Fatal(err) + } + if err := temperEquippedItem(user, item.Slot, 1); err != nil { + t.Fatal(err) + } + equipped, err := loadEquippedMagicItems(user) + if err != nil { + t.Fatal(err) + } + e := equipped[item.Slot] + if e.Temper != 1 { + t.Fatalf("equipped temper = %d, want 1", e.Temper) + } + if e.Item.Rarity != item.Rarity { + t.Errorf("stored Item.Rarity = %s, want the untouched base %s", e.Item.Rarity, item.Rarity) + } + if want := temperedRarity(item.Rarity, 1); e.Effective().Rarity != want { + t.Errorf("Effective().Rarity = %s, want %s", e.Effective().Rarity, want) + } + + // Stowed side: a fresh inventory row defaults to temper 0, then bumps. + inv := AdvItem{Name: item.Name, Type: "magic_item", Tier: 1, Value: 100, + SkillSource: "magic_item:" + item.ID} + if err := addAdvInventoryItem(user, inv); err != nil { + t.Fatal(err) + } + items, err := loadAdvInventory(user) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Temper != 0 { + t.Fatalf("fresh inventory row should default to temper 0, got %+v", items) + } + bumped := temperedItem(item, 2) + if err := temperInventoryItem(items[0].ID, 2, rarityLootTierNum(bumped.Rarity), int64(bumped.Value)); err != nil { + t.Fatal(err) + } + items, _ = loadAdvInventory(user) + if items[0].Temper != 2 { + t.Errorf("inventory temper = %d, want 2", items[0].Temper) + } + if items[0].Tier != rarityLootTierNum(bumped.Rarity) { + t.Errorf("inventory tier = %d, want %d", items[0].Tier, rarityLootTierNum(bumped.Rarity)) + } +} + +// TestCollectTemperTargetsFiltering: Legendary items and consumable-kind magic +// items (potions/scrolls, whose combat effect is zero) are not offerable. +func TestCollectTemperTargetsFiltering(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + + user := id.UserID("@filter:test.invalid") + + var legendary, potion, upgradable MagicItem + for _, mi := range magicItemRegistry { + if legendary.ID == "" && mi.Rarity == RarityLegendary && mi.Slot != "" { + legendary = mi + } + if potion.ID == "" && (mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll) { + potion = mi + } + if upgradable.ID == "" && mi.Slot != "" && mi.Kind == MagicItemWeapon && + temperStepsToLegendary(mi.Rarity, 0) > 0 { + upgradable = mi + } + } + + add := func(mi MagicItem) { + if mi.ID == "" { + return + } + if err := addAdvInventoryItem(user, AdvItem{ + Name: mi.Name, Type: "magic_item", Tier: rarityLootTierNum(mi.Rarity), + Value: int64(mi.Value), SkillSource: "magic_item:" + mi.ID, + }); err != nil { + t.Fatal(err) + } + } + add(legendary) + add(potion) + add(upgradable) + + targets, err := collectTemperTargets(user) + if err != nil { + t.Fatal(err) + } + for _, tg := range targets { + if legendary.ID != "" && tg.Base.ID == legendary.ID { + t.Errorf("Legendary %s offered for tempering", legendary.Name) + } + if potion.ID != "" && tg.Base.ID == potion.ID { + t.Errorf("consumable %s offered for tempering", potion.Name) + } + } + if upgradable.ID != "" { + found := false + for _, tg := range targets { + if tg.Base.ID == upgradable.ID { + found = true + } + } + if !found { + t.Errorf("upgradable %s (%s) was not offered", upgradable.Name, upgradable.Rarity) + } + } +} + +// TestTemperMaterialsAreObtainable guards the economy: every material name the +// final rung demands must actually drop from some zone's loot slate. If a slate +// is renamed, tempering to Legendary silently becomes impossible. +func TestTemperMaterialsAreObtainable(t *testing.T) { + live := map[string]bool{} + for _, zone := range allZones() { + for _, entry := range zone.Loot { + live[titleCaseUnderscored(entry.ItemID)] = true + } + } + for _, name := range temperMaterialNames { + if !live[name] { + t.Errorf("temper material %q drops from no zone loot slate", name) + } + } +} + +// TestFindTemperMaterial picks the first material regardless of which one. +func TestFindTemperMaterial(t *testing.T) { + if _, ok := findTemperMaterial(nil); ok { + t.Error("found a material in an empty inventory") + } + if _, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}}); ok { + t.Error("Iron Ore accepted as a legendary material") + } + for _, name := range temperMaterialNames { + got, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}, {ID: 7, Name: name}}) + if !ok || got.ID != 7 { + t.Errorf("did not find material %q", name) + } + } +} + +func TestParseTemperIndex(t *testing.T) { + tests := []struct { + in string + n int + want int + wantOK bool + }{ + {"1", 3, 0, true}, + {"3", 3, 2, true}, + {" 2 ", 3, 1, true}, + {"2. Thing", 3, 1, true}, + {"0", 3, 0, false}, + {"4", 3, 0, false}, + {"", 3, 0, false}, + {"cancel", 3, 0, false}, + } + for _, tc := range tests { + got, ok := parseTemperIndex(tc.in, tc.n) + if ok != tc.wantOK || (ok && got != tc.want) { + t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)", + tc.in, tc.n, got, ok, tc.want, tc.wantOK) + } + } +} diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index cc5eea0..032e28c 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -161,9 +161,14 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, status = " — **(inactive)** _unbonded_" } } - b.WriteString(fmt.Sprintf(" %s %-9s %s _(%s)_%s\n %s\n", - rarityIcon(e.Item.Rarity), string(ds), e.Item.Name, e.Item.Rarity, - status, magicItemEffectSummary(e.Item))) + mi := e.Effective() + temperMark := "" + if e.Temper > 0 { + temperMark = " 🔨" + } + b.WriteString(fmt.Sprintf(" %s %-9s %s%s _(%s)_%s\n %s\n", + rarityIcon(mi.Rarity), string(ds), mi.Name, temperMark, mi.Rarity, + status, magicItemEffectSummary(mi))) } } diff --git a/internal/plugin/magic_items_gameplay.go b/internal/plugin/magic_items_gameplay.go index f1989ed..21db843 100644 --- a/internal/plugin/magic_items_gameplay.go +++ b/internal/plugin/magic_items_gameplay.go @@ -72,16 +72,23 @@ func pickMagicItemForRarity(r DnDRarity, rng *rand.Rand) (MagicItem, bool) { // magicItemSell builds the AdvItem an inventory deposit uses for a magic item. // Potions and scrolls land as "consumable" so the combat pipeline picks them // up (see magicItemConsumableDefByName); everything else is a "magic_item". -func magicItemSell(mi MagicItem) AdvItem { +func magicItemSell(mi MagicItem) AdvItem { return magicItemSellAt(mi, 0) } + +// magicItemSellAt is magicItemSell for an instance carrying temper steps: the +// row it produces is priced and tiered at the item's effective rarity, and +// remembers the temper so equipping it again restores the upgrade. +func magicItemSellAt(mi MagicItem, temper int) AdvItem { typ := "magic_item" if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { typ = "consumable" } + eff := temperedItem(mi, temper) return AdvItem{ - Name: mi.Name, - Type: typ, - Tier: rarityLootTierNum(mi.Rarity), - Value: int64(mi.Value), + Name: eff.Name, + Type: typ, + Tier: rarityLootTierNum(eff.Rarity), + Value: int64(eff.Value), + Temper: temper, } } @@ -103,6 +110,66 @@ func rarityLootTierNum(r DnDRarity) int { return 1 } +// ── Tempering: per-instance rarity above the definition's base ────────────── + +// temperLadder is the rarity ladder tempering walks. VeryRare is absent by +// design: it collapses onto Epic in both rarityLootTierNum and +// rarityPowerScalar, so treating it as a distinct rung would sell the player +// a step that changes no number. +var temperLadder = []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityEpic, RarityLegendary} + +// temperRung locates a rarity on temperLadder, folding VeryRare onto Epic. +func temperRung(r DnDRarity) int { + if r == RarityVeryRare { + r = RarityEpic + } + for i, lr := range temperLadder { + if lr == r { + return i + } + } + return 0 +} + +// temperedRarity applies steps rungs of tempering to a base rarity, clamped at +// Legendary. Callers derive this on read — the base rarity on the registry +// definition stays authoritative, so an item can never double-bump across a +// load/save round-trip. +func temperedRarity(base DnDRarity, steps int) DnDRarity { + if steps <= 0 { + return base + } + rung := temperRung(base) + steps + if rung >= len(temperLadder) { + rung = len(temperLadder) - 1 + } + return temperLadder[rung] +} + +// temperStepsToLegendary reports how many tempers an item at this base rarity +// and temper count still has ahead of it. +func temperStepsToLegendary(base DnDRarity, steps int) int { + return (len(temperLadder) - 1) - temperRung(temperedRarity(base, steps)) +} + +// temperedItem returns a copy of mi at its effective rarity. Everything that +// reads rarity for gameplay — effects, sell value, display — goes through here. +func temperedItem(mi MagicItem, steps int) MagicItem { + if steps <= 0 { + return mi + } + eff := temperedRarity(mi.Rarity, steps) + if eff == mi.Rarity { + return mi + } + // Value tracks the power axis, so a tempered item is worth what an item + // of its effective rarity is worth. + scaled := float64(mi.Value) * (rarityPowerScalar(eff) / rarityPowerScalar(mi.Rarity)) + mi.Value = int(scaled) + mi.Rarity = eff + return mi +} + // ── Codified combat-effect formula ────────────────────────────────────────── // magicItemEffect is the combat delta a single equipped magic item grants. @@ -172,10 +239,16 @@ func magicItemEffectFor(mi MagicItem) magicItemEffect { // registry. Item is the registry entry; Attuned mirrors the DB column. type EquippedMagicItem struct { Slot DnDSlot - Item MagicItem + Item MagicItem // registry definition, at its BASE rarity Attuned bool + Temper int } +// Effective is the item as the game should see it: base definition plus any +// tempering the player has paid for. Item stays at base rarity so re-saving an +// equipped row can't compound the bump. +func (e EquippedMagicItem) Effective() MagicItem { return temperedItem(e.Item, e.Temper) } + // dndMagicItemAttuneLimit — 5e's three-attunement cap. Items that require // attunement only grant their effect while attuned, and a player may hold at // most this many attunements at once. @@ -183,7 +256,7 @@ const dndMagicItemAttuneLimit = 3 func loadEquippedMagicItems(userID id.UserID) (map[DnDSlot]EquippedMagicItem, error) { d := db.Get() - rows, err := d.Query(`SELECT slot, item_id, attuned FROM magic_item_equipped WHERE user_id = ?`, + rows, err := d.Query(`SELECT slot, item_id, attuned, temper FROM magic_item_equipped WHERE user_id = ?`, string(userID)) if err != nil { return nil, err @@ -193,8 +266,8 @@ func loadEquippedMagicItems(userID id.UserID) (map[DnDSlot]EquippedMagicItem, er out := make(map[DnDSlot]EquippedMagicItem) for rows.Next() { var slot, itemID string - var attuned int - if err := rows.Scan(&slot, &itemID, &attuned); err != nil { + var attuned, temper int + if err := rows.Scan(&slot, &itemID, &attuned, &temper); err != nil { return nil, err } mi, ok := magicItemRegistry[itemID] @@ -205,24 +278,34 @@ func loadEquippedMagicItems(userID id.UserID) (map[DnDSlot]EquippedMagicItem, er Slot: DnDSlot(slot), Item: mi, Attuned: attuned == 1, + Temper: temper, } } return out, rows.Err() } // equipMagicItem writes (or replaces) the item in its slot. attuned is only -// honoured when the item requires attunement. -func equipMagicItem(userID id.UserID, slot DnDSlot, itemID string, attuned bool) error { +// honoured when the item requires attunement. temper rides along from the +// inventory row so tempering survives being worn. +func equipMagicItem(userID id.UserID, slot DnDSlot, itemID string, attuned bool, temper int) error { d := db.Get() a := 0 if attuned { a = 1 } _, err := d.Exec(` - INSERT INTO magic_item_equipped (user_id, slot, item_id, attuned) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id, slot) DO UPDATE SET item_id = excluded.item_id, attuned = excluded.attuned`, - string(userID), string(slot), itemID, a) + INSERT INTO magic_item_equipped (user_id, slot, item_id, attuned, temper) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, slot) DO UPDATE SET + item_id = excluded.item_id, attuned = excluded.attuned, temper = excluded.temper`, + string(userID), string(slot), itemID, a, temper) + return err +} + +// temperEquippedItem bumps the temper on a worn item in place. +func temperEquippedItem(userID id.UserID, slot DnDSlot, temper int) error { + _, err := db.Get().Exec(`UPDATE magic_item_equipped SET temper = ? WHERE user_id = ? AND slot = ?`, + temper, string(userID), string(slot)) return err } @@ -262,7 +345,7 @@ func applyMagicItemEffects(stats *CombatStats, mods *CombatModifiers, userID id. if e.Item.Attunement && !e.Attuned { continue // unattuned attunement item is inert } - eff := magicItemEffectFor(e.Item) + eff := magicItemEffectFor(e.Effective()) mods.DamageBonus += eff.DamageBonus if eff.DamageReductMult != 0 && eff.DamageReductMult != 1.0 { mods.DamageReduct *= eff.DamageReductMult @@ -464,7 +547,8 @@ func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { var sb strings.Builder sb.WriteString("🔮 **Equippable magic items:**\n\n") for i, it := range magic { - mi, _ := magicItemFromAdvItem(it) + base, _ := magicItemFromAdvItem(it) + mi := temperedItem(base, it.Temper) curDesc := "empty" if cur, ok := equipped[mi.Slot]; ok && cur.Item.ID != "" { curDesc = cur.Item.Name @@ -528,7 +612,7 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction // can re-open a slot the prior occupant was holding. var swappedBackName string if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" { - back := magicItemSell(prev.Item) + back := magicItemSellAt(prev.Item, prev.Temper) back.SkillSource = "magic_item:" + prev.Item.ID if err := addAdvInventoryItem(ctx.Sender, back); err != nil { return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.") @@ -557,10 +641,10 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction "user", ctx.Sender, "item", mi.ID, "err", err) return p.SendDM(ctx.Sender, "Failed to equip that item.") } - if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune); err != nil { + if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune, it.Temper); err != nil { // Roll back: try to put the item back in inventory so the player // doesn't lose it. Best-effort; log if the rollback also fails. - restored := magicItemSell(mi) + restored := magicItemSellAt(mi, it.Temper) restored.Value = it.Value restored.SkillSource = "magic_item:" + mi.ID if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil { @@ -570,9 +654,10 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction return p.SendDM(ctx.Sender, "Failed to equip that item.") } + eqMI := temperedItem(mi, it.Temper) var sb strings.Builder sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.", - mi.Name, mi.Slot, magicItemEffectSummary(mi))) + eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI))) if swappedBackName != "" { sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName)) } diff --git a/internal/plugin/magic_items_gameplay_test.go b/internal/plugin/magic_items_gameplay_test.go index d141a7e..329be55 100644 --- a/internal/plugin/magic_items_gameplay_test.go +++ b/internal/plugin/magic_items_gameplay_test.go @@ -229,7 +229,7 @@ func TestSwapBackReturnsFullValue(t *testing.T) { } // Pre-occupy the slot with `a`, then drop `b` into inventory and equip it. - if err := equipMagicItem(user, a.Slot, a.ID, false); err != nil { + if err := equipMagicItem(user, a.Slot, a.ID, false, 0); err != nil { t.Fatalf("seed equip: %v", err) } bInv := magicItemSell(b) @@ -281,7 +281,7 @@ func TestEquippedMagicItemRoundTrip(t *testing.T) { t.Skip("registry lacks both an attunement and a non-attunement slotted item") } - if err := equipMagicItem(user, attItem.Slot, attItem.ID, true); err != nil { + if err := equipMagicItem(user, attItem.Slot, attItem.ID, true, 0); err != nil { t.Fatalf("equip attunement item: %v", err) } // Equip the plain item into a distinct slot so it doesn't overwrite the @@ -290,7 +290,7 @@ func TestEquippedMagicItemRoundTrip(t *testing.T) { if plainSlot == attItem.Slot { plainSlot = DnDSlotRing2 } - if err := equipMagicItem(user, plainSlot, plainItem.ID, false); err != nil { + if err := equipMagicItem(user, plainSlot, plainItem.ID, false, 0); err != nil { t.Fatalf("equip plain item: %v", err) }