diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index a164f44..c76cd8c 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -1126,7 +1126,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha return } - if count < advMaxTreasures { + if count < maxTreasuresForTier(char.HouseTier) { // Directly save if err := advSaveTreasure(userID, drop.Def); err != nil { slog.Error("adventure: failed to save treasure", "user", userID, "err", err) @@ -1511,7 +1511,7 @@ func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error { if err != nil { return p.SendDM(ctx.Sender, "Failed to load your character.") } - return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill)) + return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))) } func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error { diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 955e371..ab2e0a0 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -346,15 +346,34 @@ var craftingRecipes = []CraftingRecipe{ {Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5}, } -// craftingSuccessRate returns the success chance for a recipe given foraging level. -// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%. -func craftingSuccessRate(foragingLevel, minForaging int) float64 { +// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop β€” +// a flat craft-success bonus while crafting at home. +const houseTierWorkshop = 3 + +// homeWorkshopCraftBonus returns the additive craft-success bonus from a T3+ +// home workshop. Tier 1/2 and no house grant none. +func homeWorkshopCraftBonus(houseTier int) float64 { + if houseTier >= houseTierWorkshop { + return 0.05 + } + return 0 +} + +// craftingSuccessRate returns the success chance for a recipe given foraging +// level. Base rate is 50% at minimum level, +3% per 5 levels above minimum, +// capped at 95%. A T3+ home workshop adds workshopBonus on top and lifts the +// cap to 98%, so a maxed forager still gains a little from the bench. +func craftingSuccessRate(foragingLevel, minForaging int, workshopBonus float64) float64 { base := 0.50 levelsAbove := foragingLevel - minForaging bonus := float64(levelsAbove) / 5.0 * 0.03 - rate := base + bonus - if rate > 0.95 { - return 0.95 + rate := base + bonus + workshopBonus + cap := 0.95 + if workshopBonus > 0 { + cap = 0.98 + } + if rate > cap { + return cap } return rate } @@ -369,7 +388,7 @@ type CraftResult struct { // their current foraging level, plus a teaser line for the next unlock // threshold. Hides exact ingredient lists for recipes the player hasn't // unlocked β€” only the count of locked recipes is shown. -func renderRecipesKnown(foragingLevel int) string { +func renderRecipesKnown(foragingLevel int, workshopBonus float64) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("πŸ§ͺ **Crafting Recipes** β€” Foraging Lv.%d\n\n", foragingLevel)) @@ -377,6 +396,9 @@ func renderRecipesKnown(foragingLevel int) string { sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._") return sb.String() } + if workshopBonus > 0 { + sb.WriteString(fmt.Sprintf("_πŸ”¨ Home workshop: +%.0f%% craft success._\n\n", workshopBonus*100)) + } // Group recipes by tier, list those known. known := map[int][]CraftingRecipe{} @@ -402,7 +424,7 @@ func renderRecipesKnown(foragingLevel int) string { } sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging)) for _, r := range recipes { - rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100 + rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100 sb.WriteString(fmt.Sprintf(" β€’ %s β€” %s (%.0f%% success)\n", r.Result, strings.Join(r.Ingredients, " + "), rate)) } @@ -433,7 +455,7 @@ var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18} // // Side effects: grants foraging XP per attempt (success > failure) and bumps // the player's CraftsSucceeded counter on each success. -func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) { +func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) { if foragingLevel < 10 { return nil, items } @@ -452,7 +474,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) break } - rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging) + rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus) success := rand.Float64() < rate if success { diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index c265044..669e4a5 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -1037,8 +1037,17 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string { // ── Treasure Discard Prompt ────────────────────────────────────────────────── func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string { - if len(TreasureInventoryCap) == 0 { - return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`." + // The flavor templates below are written for the base 3-slot set. With a + // T3 trophy room the set can hold 4, so fall back to a plain dynamic list + // whenever it isn't exactly 3 β€” every slot has to be visible to choose it. + if len(TreasureInventoryCap) == 0 || len(existing) != 3 { + var b strings.Builder + fmt.Fprintf(&b, "You found **%s** but your treasure slots are full. Discard one to make room:\n", newTreasure.Name) + for i, ex := range existing { + fmt.Fprintf(&b, "%d. %s β€” %s\n", i+1, ex.Name, ex.InventoryDesc) + } + fmt.Fprintf(&b, "\nReply with the number to discard, or `keep` to leave the new one behind.") + return b.String() } // Build substitution map with existing treasure info diff --git a/internal/plugin/adventure_treasure.go b/internal/plugin/adventure_treasure.go index cfdeccc..0c4d0b6 100644 --- a/internal/plugin/adventure_treasure.go +++ b/internal/plugin/adventure_treasure.go @@ -50,6 +50,26 @@ var advTreasureDropRates = map[int]float64{ const advMaxTreasures = 3 +// houseTierTrophyRoom is the T3 "Comfortable" house that unlocks the trophy +// room β€” a fourth treasure slot. +const houseTierTrophyRoom = 3 + +// maxTreasuresForTier returns how many treasures a player may hold. The base +// cap is 3; a T3+ house adds the trophy-room slot for a 4th. +// +// The extra slot is a housing perk, but HouseTier is only ever written upward +// (purchase paths; there is no foreclosure/downgrade that writes it back down), +// so a player can only ever hold a 4th treasure while tier>=3. There is +// therefore no reachable state where a held treasure must be revoked at +// bonus-computation time, and the cap is enforced only at the save gate. +func maxTreasuresForTier(houseTier int) int { + n := advMaxTreasures + if houseTier >= houseTierTrophyRoom { + n++ + } + return n +} + // ── Treasure Definitions ───────────────────────────────────────────────────── var advAllTreasures = map[int][]AdvTreasureDef{ diff --git a/internal/plugin/adventure_treasure_test.go b/internal/plugin/adventure_treasure_test.go index bfd1c35..0922d7f 100644 --- a/internal/plugin/adventure_treasure_test.go +++ b/internal/plugin/adventure_treasure_test.go @@ -79,3 +79,13 @@ func TestAdvLocForZone(t *testing.T) { t.Errorf("name = %q, want the zone's display name", loc.Name) } } + +// TestMaxTreasuresForTier pins the T3 "trophy room" 4th slot: the base cap is +// 3, and only a tier-3+ home lifts it to 4. +func TestMaxTreasuresForTier(t *testing.T) { + for tier, want := range map[int]int{0: 3, 1: 3, 2: 3, 3: 4, 4: 4} { + if got := maxTreasuresForTier(tier); got != want { + t.Errorf("maxTreasuresForTier(%d) = %d, want %d", tier, got, want) + } + } +} diff --git a/internal/plugin/adventure_well_rested.go b/internal/plugin/adventure_well_rested.go new file mode 100644 index 0000000..300293f --- /dev/null +++ b/internal/plugin/adventure_well_rested.go @@ -0,0 +1,110 @@ +package plugin + +import ( + "fmt" + "strings" +) + +// Well-rested β€” a long rest taken at a home of tier 2+ grants a temporary buff +// that lasts until the next long rest. Renting a room at Thom Krooke's inn does +// not: the perk exists to give owning (and upgrading) a house a payoff the inn +// can't match, and it turns the dead T3/T4 prestige tiers into something worth +// buying. +// +// Two halves, both scaling with house tier (2 < 3 < 4): +// - wellRestedTempHP: a temporary hit-point cushion, layered above MaxHP for +// the day's fights via applyDnDHPScaling; and +// - wellRestedSlotBonus: bonus spell slots folded into the caster's pool at +// their highest available slot level. +// +// The tuning below is deliberately generous toward casters, who trail on +// spell-pool richness β€” the bonus slots are the substantive reward and the +// temp HP is a lighter feel-good cushion. All values are tunable. +// +// Nothing here is a secret discovery mechanic (cf. Misty/Arina), so the rest +// message surfaces exactly what was granted. + +// homeRestTempHPPct maps house tier β†’ temp-HP fraction of MaxHP. +func homeRestTempHPPct(houseTier int) float64 { + switch { + case houseTier >= 4: + return 0.16 + case houseTier >= 3: + return 0.12 + case houseTier >= 2: + return 0.08 + default: + return 0 + } +} + +// homeRestBonusSlots maps house tier β†’ number of bonus spell slots. +func homeRestBonusSlots(houseTier int) int { + switch { + case houseTier >= 4: + return 3 + case houseTier >= 3: + return 2 + case houseTier >= 2: + return 1 + default: + return 0 + } +} + +// wellRestedTempHP returns the temporary HP granted by a home long rest at the +// given house tier. Tier 1 and the inn grant none. Always at least 1 when the +// tier qualifies, so a low-HP character still sees the buff. +func wellRestedTempHP(hpMax, houseTier int) int { + pct := homeRestTempHPPct(houseTier) + if pct <= 0 { + return 0 + } + hp := int(float64(hpMax) * pct) + if hp < 1 { + hp = 1 + } + return hp +} + +// wellRestedSlotBonus returns the temporary bonus spell slots granted by a home +// long rest, placed entirely at the caster's highest available slot level (the +// most valuable, and one a caster is guaranteed to have). Tier 1, the inn, and +// non-casters (empty pool) grant none. +func wellRestedSlotBonus(houseTier int, pool map[int]int) map[int]int { + n := homeRestBonusSlots(houseTier) + if n == 0 || len(pool) == 0 { + return nil + } + highest := 0 + for lvl := range pool { + if lvl > highest { + highest = lvl + } + } + if highest == 0 { + return nil + } + return map[int]int{highest: n} +} + +// wellRestedSummary renders the player-facing description of a granted buff, +// e.g. "+18 temp HP, +2 level-5 spell slots". Returns "" when nothing was +// granted (tier 1, the inn, or a non-caster with no temp HP). +func wellRestedSummary(tempHP int, slotBonus map[int]int) string { + var bits []string + if tempHP > 0 { + bits = append(bits, fmt.Sprintf("+%d temp HP", tempHP)) + } + for lvl, n := range slotBonus { + if n <= 0 { + continue + } + plural := "" + if n != 1 { + plural = "s" + } + bits = append(bits, fmt.Sprintf("+%d level-%d spell slot%s", n, lvl, plural)) + } + return strings.Join(bits, ", ") +} diff --git a/internal/plugin/adventure_well_rested_test.go b/internal/plugin/adventure_well_rested_test.go new file mode 100644 index 0000000..6d3789d --- /dev/null +++ b/internal/plugin/adventure_well_rested_test.go @@ -0,0 +1,192 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func TestWellRestedTempHP_ByTier(t *testing.T) { + cases := []struct { + tier, hpMax, want int + }{ + {0, 100, 0}, // no house + {1, 100, 0}, // tier-1 shack: nothing + {2, 100, 8}, // 8% + {3, 100, 12}, // 12% + {4, 100, 16}, // 16% + {2, 3, 1}, // rounds to <1 β†’ floored to 1 + } + for _, c := range cases { + if got := wellRestedTempHP(c.hpMax, c.tier); got != c.want { + t.Errorf("wellRestedTempHP(hpMax=%d, tier=%d) = %d, want %d", c.hpMax, c.tier, got, c.want) + } + } +} + +func TestWellRestedSlotBonus_PlacesAtHighestLevel(t *testing.T) { + pool := map[int]int{1: 4, 2: 3, 3: 2} // highest available = 3 + + if got := wellRestedSlotBonus(1, pool); got != nil { + t.Errorf("tier 1 β†’ %v, want nil", got) + } + if got := wellRestedSlotBonus(0, pool); got != nil { + t.Errorf("tier 0 β†’ %v, want nil", got) + } + if got := wellRestedSlotBonus(3, nil); got != nil { + t.Errorf("non-caster (nil pool) β†’ %v, want nil", got) + } + + for tier, wantN := range map[int]int{2: 1, 3: 2, 4: 3} { + got := wellRestedSlotBonus(tier, pool) + if len(got) != 1 || got[3] != wantN { + t.Errorf("tier %d β†’ %v, want {3: %d}", tier, got, wantN) + } + } +} + +func TestApplyDnDHPScaling_TempHPCushion(t *testing.T) { + // Golden-safety: TempHP==0 must leave a full-HP character untouched + // (StartHP stays 0 = "enter at MaxHP", MaxHP unchanged). + stats := CombatStats{MaxHP: 100, HPBonus: 10} + full := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 0} + applyDnDHPScaling(&stats, full) + if stats.MaxHP != 100 || stats.StartHP != 0 { + t.Fatalf("TempHP=0 full HP: MaxHP=%d StartHP=%d, want 100/0", stats.MaxHP, stats.StartHP) + } + + // Full HP + cushion: MaxHP grows by TempHP, StartHP stays the sentinel so + // combat enters at the bumped max (full + cushion). + stats = CombatStats{MaxHP: 100, HPBonus: 10} + rested := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 15} + applyDnDHPScaling(&stats, rested) + if stats.MaxHP != 115 || stats.StartHP != 0 { + t.Fatalf("full HP + cushion: MaxHP=%d StartHP=%d, want 115/0", stats.MaxHP, stats.StartHP) + } + + // Wounded + cushion: entry HP is current + gear bonus + cushion, MaxHP grows. + stats = CombatStats{MaxHP: 100, HPBonus: 10} + wounded := &DnDCharacter{HPMax: 100, HPCurrent: 40, TempHP: 15} + applyDnDHPScaling(&stats, wounded) + if stats.MaxHP != 115 || stats.StartHP != 65 { // 40 + 10 + 15 + t.Fatalf("wounded + cushion: MaxHP=%d StartHP=%d, want 115/65", stats.MaxHP, stats.StartHP) + } +} + +func TestApplyLongRestSpellSlots_GrantAndExpiry(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + uid := id.UserID("@rested_caster:example") + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: 5, + STR: 10, DEX: 12, CON: 14, INT: 10, WIS: 16, CHA: 10, + } + c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level) + c.HPCurrent = c.HPMax + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + if err := setSpellSlotsForCharacter(c); err != nil { + t.Fatalf("setSpellSlotsForCharacter: %v", err) + } + + base, _ := getSpellSlots(uid) + if len(base) == 0 { + t.Fatal("cleric L5 should have a base slot pool") + } + highest := 0 + for lvl := range base { + if lvl > highest { + highest = lvl + } + } + + // Home rest at tier 4 β†’ +3 at highest level, used reset to 0. + bonus, err := applyLongRestSpellSlots(c, 4) + if err != nil { + t.Fatalf("applyLongRestSpellSlots(tier4): %v", err) + } + if bonus[highest] != 3 { + t.Fatalf("tier-4 bonus = %v, want {%d: 3}", bonus, highest) + } + after, _ := getSpellSlots(uid) + if after[highest][0] != base[highest][0]+3 { + t.Errorf("highest total = %d, want base+3 = %d", after[highest][0], base[highest][0]+3) + } + for lvl, pair := range after { + if pair[1] != 0 { + t.Errorf("slot L%d used = %d after rest, want 0", lvl, pair[1]) + } + } + + // Next rest at the inn (tier 0) β†’ bonus expires, totals back to base. + if _, err := applyLongRestSpellSlots(c, 0); err != nil { + t.Fatalf("applyLongRestSpellSlots(tier0): %v", err) + } + reset, _ := getSpellSlots(uid) + for lvl, pair := range reset { + if pair[0] != base[lvl][0] { + t.Errorf("L%d total = %d after expiry, want base %d", lvl, pair[0], base[lvl][0]) + } + } + + // Tier-1 house grants nothing. + if bonus, _ := applyLongRestSpellSlots(c, 1); bonus != nil { + t.Errorf("tier-1 bonus = %v, want nil", bonus) + } +} + +func TestApplyLongRestSpellSlots_NonCaster(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + uid := id.UserID("@rested_fighter:example") + c := &DnDCharacter{UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, CON: 14} + c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level) + c.HPCurrent = c.HPMax + if err := SaveDnDCharacter(c); err != nil { + t.Fatalf("SaveDnDCharacter: %v", err) + } + + bonus, err := applyLongRestSpellSlots(c, 4) + if err != nil { + t.Fatalf("applyLongRestSpellSlots: %v", err) + } + if bonus != nil { + t.Errorf("non-caster bonus = %v, want nil", bonus) + } + if slots, _ := getSpellSlots(uid); len(slots) != 0 { + t.Errorf("non-caster has %d slot rows, want 0", len(slots)) + } +} + +// TestHomeWorkshop pins the T3 workshop craft bonus and its effect on the +// success-rate cap. +func TestHomeWorkshop(t *testing.T) { + for tier, want := range map[int]float64{0: 0, 1: 0, 2: 0, 3: 0.05, 4: 0.05} { + if got := homeWorkshopCraftBonus(tier); got != want { + t.Errorf("homeWorkshopCraftBonus(%d) = %.2f, want %.2f", tier, got, want) + } + } + // No workshop: base cap holds at 0.95 for a maxed forager. + if got := craftingSuccessRate(100, 1, 0); got != 0.95 { + t.Errorf("maxed forager, no workshop = %.3f, want 0.95", got) + } + // Workshop lifts the cap to 0.98. + if got := craftingSuccessRate(100, 1, 0.05); got != 0.98 { + t.Errorf("maxed forager, workshop = %.3f, want 0.98", got) + } + // Mid-level forager gets the flat +5% added below the cap. + base := craftingSuccessRate(15, 10, 0) + withShop := craftingSuccessRate(15, 10, 0.05) + if diff := withShop - base; diff < 0.049 || diff > 0.051 { + t.Errorf("workshop delta = %.3f, want ~0.05", diff) + } +} diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index a1422bc..cd6b37b 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -185,7 +185,7 @@ func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []Consumable // Auto-craft if player has foraging level 10+ char, err := loadAdvCharacter(userID) if err == nil && char.ForagingSkill >= 10 { - craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill) + craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)) if len(craftResults) > 0 { items = updatedItems for _, cr := range craftResults { diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 04471d1..4c92336 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -481,10 +481,23 @@ func formatN(n int, word string) string { // wound layered with the fresh equipment cushion. There is no scale // conversion; persistDnDHPAfterCombat is the inverse direct copy. func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) { - if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax { + if c == nil || c.HPMax <= 0 { return } - startHP := c.HPCurrent + stats.HPBonus + // Well-rested temporary HP (from a long rest at a T2+ home) is a cushion + // layered above the fight's MaxHP. It is dormant (0) for anyone not + // currently rested β€” including every scenario in the balance corpus and + // the class-balance harness, which never sets it β€” so the full-HP fast + // path below stays byte-identical for them and the golden does not move. + if c.TempHP > 0 { + stats.MaxHP += c.TempHP + } + if c.HPCurrent >= c.HPMax { + // Full HP: StartHP=0 sentinel already means "enter at MaxHP", which + // now includes the +TempHP cushion. + return + } + startHP := c.HPCurrent + stats.HPBonus + c.TempHP if startHP < 1 { startHP = 1 } diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index cd42694..3163cf6 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -222,8 +222,15 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { innPaid = true } + // Well-rested buff: only when resting at your own home (not the inn), and + // only from tier 2 up β€” a tier-1 shack and a rented inn room grant nothing. + restTier := 0 + if hasHousing { + restTier = house.Tier + } + c.HPCurrent = c.HPMax - c.TempHP = 0 + c.TempHP = wellRestedTempHP(c.HPMax, restTier) c.ShortRestCharges = c.Level // Phase 10 SUB2a β€” long rest clears one level of exhaustion (5e: a // long rest clears one). For Berserker who racks up exhaustion via @@ -241,8 +248,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { } markActedToday(ctx.Sender) _ = refreshAllResources(ctx.Sender) - // Phase 9: spell slots refresh on long rest. - _ = refreshSpellSlots(ctx.Sender) + // Phase 9: spell slots refresh on long rest. A home rest also folds in the + // well-rested bonus slots (no-op for non-casters and inn/tier-1 rests). + slotBonus, _ := applyLongRestSpellSlots(c, restTier) // Phase 9: Cleric prep flags reset (SP4) β€” until SP4 ships, default // Cleric grants are already prepared=1 so this is a no-op for them. // Voluntary concentration ends at long rest (mage_armor's 8h is exactly @@ -263,5 +271,13 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { if line := dndRestLongFlavorLine(hasHousing); line != "" { msg += "\n\n_" + line + "_" } + if bits := wellRestedSummary(c.TempHP, slotBonus); bits != "" { + homeName := "home" + if def := houseTierByTier(restTier); def != nil { + homeName = def.Name + } + msg += fmt.Sprintf("\n\nπŸ›οΈ _Well-rested at your %s β€” %s until your next long rest._", + strings.ToLower(homeName), bits) + } return p.SendDM(ctx.Sender, msg) } diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index dfb3ec9..588bbf2 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -564,6 +564,48 @@ func refreshSpellSlots(userID id.UserID) error { return err } +// applyLongRestSpellSlots resets a caster's slot pool to its base (class + +// subclass) total with used=0, then folds in any well-rested bonus slots for a +// home rest of the given tier. The bonus lives in `total`, so it is spent like +// an ordinary slot and is wiped by the *next* long rest's reset β€” expiry needs +// no separate bookkeeping. It is safe to call on every long rest because +// ensureSpellsForCharacter already keeps `total` == slotsForCharacter(c) on +// load, so the base reset is a no-op except for clearing a prior bonus. +// +// Non-casters have an empty pool; the function falls back to the plain +// used=0 refresh and grants nothing. Returns the bonus actually granted (nil if +// none) so the caller can name it in the rest message. +func applyLongRestSpellSlots(c *DnDCharacter, restTier int) (map[int]int, error) { + pool := slotsForCharacter(c) + if len(pool) == 0 { + return nil, refreshSpellSlots(c.UserID) + } + bonus := wellRestedSlotBonus(restTier, pool) + tx, err := db.Get().Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(c.UserID)); err != nil { + return nil, err + } + for lvl, total := range pool { + if _, err := tx.Exec(` + INSERT INTO dnd_spell_slots (user_id, slot_level, total, used) + VALUES (?, ?, ?, 0)`, + string(c.UserID), lvl, total+bonus[lvl]); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + if len(bonus) == 0 { + return nil, nil + } + return bonus, nil +} + // partialRefreshSpellSlots restores spell slots on short rest. All L1 slots // come back, plus floor(charLevel/4) additional slots distributed // lowest-first across tiers β‰₯2. Returns slot_levelβ†’count restored so the