From adcc62112b46fa99f05e8ab2b6b6981593d655d9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:22:10 -0700 Subject: [PATCH] N2/B1+B4+C4: tempering, the expedition achievement wing, arena seasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — tempering. `!adventure temper` pushes an owned magic item one rung up the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on the registry definition, so an upgraded instance records its progress as a `temper` step count on its own row (adventure_inventory, magic_item_equipped) and effective rarity is derived on read. The stored base rarity is never written back, so an item cannot double-bump across a load/save round-trip. Effects flow through the existing rarity scalars — no new combat math, and the Legendary cap means this accelerates reaching the current ceiling rather than raising it. Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30). Only the Legendary rung consumes a T5 material; gating the lower rungs on one would make tempering unreachable until a player already clears T5. Two plan anchors were wrong: thyraks_core and portal_fragment are not loot-note strings needing promotion — they are UniqueAlways slate entries that already materialize as inventory rows on every T5 clear. B4 — expedition achievement wing: first-clear and all-zones-cleared per tier (10), a quiet-clear for keeping peak threat under 50, and temper_legendary. The quiet-clear requires max_threat_seen to be *present*, not merely low: recordMaxThreat samples only on day rollover and never stores a zero, so an absent key means no threat history, not low threat. The plan's no-death-T4 achievement is not shipped — no per-expedition death counter exists — and pet-saved-my-life already ships as combat_pet_save. C4 — arena seasons. Standings are derived from arena_history.created_at per calendar quarter rather than wiping arena_stats: same visible reset, but lifetime totals survive for `!arena stats` and no destructive job can fire twice. Crowns archive to arena_season_titles rather than player_meta.title, which already carries the Survivalist milestone. Rollover rides the existing midnight ticker and self-dedups on the season key, so a bot that was down on the boundary catches up on its next wake. --- internal/db/db.go | 24 ++ internal/plugin/achievements.go | 152 +++++++ .../plugin/achievements_expedition_test.go | 184 ++++++++ internal/plugin/adventure.go | 7 + internal/plugin/adventure_arena.go | 10 +- internal/plugin/adventure_arena_render.go | 7 +- internal/plugin/adventure_arena_season.go | 225 ++++++++++ .../plugin/adventure_arena_season_test.go | 187 ++++++++ internal/plugin/adventure_arena_test.go | 8 +- internal/plugin/adventure_character.go | 21 +- .../plugin/adventure_flavor_blacksmith.go | 16 + internal/plugin/adventure_scheduler.go | 3 + internal/plugin/adventure_temper.go | 406 ++++++++++++++++++ internal/plugin/adventure_temper_test.go | 355 +++++++++++++++ internal/plugin/dnd_sheet.go | 11 +- internal/plugin/magic_items_gameplay.go | 127 +++++- internal/plugin/magic_items_gameplay_test.go | 6 +- 17 files changed, 1707 insertions(+), 42 deletions(-) create mode 100644 internal/plugin/achievements_expedition_test.go create mode 100644 internal/plugin/adventure_arena_season.go create mode 100644 internal/plugin/adventure_arena_season_test.go create mode 100644 internal/plugin/adventure_temper.go create mode 100644 internal/plugin/adventure_temper_test.go 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) }