mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N4/E1: T3 housing payoffs + a home-rest "well-rested" buff
Turn the dead top housing tiers into something worth buying. All three
land without a schema bump, and TestCombatCharacterization stays
byte-identical (the balance corpus never sets the new fields).
T3 trophy room: treasure cap 3->4 at HouseTier>=3 (maxTreasuresForTier).
Enforced at the save gate only -- HouseTier is never written downward,
so a held 4th treasure below tier 3 is unreachable and a load-time cap in
computeAdvBonuses would just add a query for an impossible state. The
all-irreplaceable manual discard prompt now lists the 4th slot too.
T3 workshop: +5% craft success at HouseTier>=3, lifting the cap 0.95->0.98
so a maxed forager still gains. craftingSuccessRate takes a workshopBonus,
threaded through autoCraftConsumables and renderRecipesKnown.
Well-rested buff (replaces the plan's T4 "inn-quality rest", a verified
no-op -- home rest already equals inn rest). Home-only (the inn and a
tier-1 shack grant nothing), starts at T2 and grows through T4, expires at
the next long rest:
- Temp HP cushion (8/12/16% of MaxHP). TempHP was a dormant field; wired
into applyDnDHPScaling as MaxHP headroom -- additive and golden-safe.
- Bonus spell slots (+1/+2/+3 at the caster's highest slot level), the
real reward, lifting casters who trail on spell-pool richness.
applyLongRestSpellSlots resets the pool to base then folds in the
bonus; expiry is stateless (next rest's reset drops it).
Magnitudes are tunable defaults; revisit against the post-parties
re-baseline.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
110
internal/plugin/adventure_well_rested.go
Normal file
110
internal/plugin/adventure_well_rested.go
Normal file
@@ -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, ", ")
|
||||
}
|
||||
192
internal/plugin/adventure_well_rested_test.go
Normal file
192
internal/plugin/adventure_well_rested_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user