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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count < advMaxTreasures {
|
if count < maxTreasuresForTier(char.HouseTier) {
|
||||||
// Directly save
|
// Directly save
|
||||||
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||||||
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
|
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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
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 {
|
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},
|
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||||
}
|
}
|
||||||
|
|
||||||
// craftingSuccessRate returns the success chance for a recipe given foraging level.
|
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop —
|
||||||
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
|
// a flat craft-success bonus while crafting at home.
|
||||||
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
|
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
|
base := 0.50
|
||||||
levelsAbove := foragingLevel - minForaging
|
levelsAbove := foragingLevel - minForaging
|
||||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||||
rate := base + bonus
|
rate := base + bonus + workshopBonus
|
||||||
if rate > 0.95 {
|
cap := 0.95
|
||||||
return 0.95
|
if workshopBonus > 0 {
|
||||||
|
cap = 0.98
|
||||||
|
}
|
||||||
|
if rate > cap {
|
||||||
|
return cap
|
||||||
}
|
}
|
||||||
return rate
|
return rate
|
||||||
}
|
}
|
||||||
@@ -369,7 +388,7 @@ type CraftResult struct {
|
|||||||
// their current foraging level, plus a teaser line for the next unlock
|
// their current foraging level, plus a teaser line for the next unlock
|
||||||
// threshold. Hides exact ingredient lists for recipes the player hasn't
|
// threshold. Hides exact ingredient lists for recipes the player hasn't
|
||||||
// unlocked — only the count of locked recipes is shown.
|
// 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
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
|
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._")
|
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
|
||||||
return sb.String()
|
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.
|
// Group recipes by tier, list those known.
|
||||||
known := map[int][]CraftingRecipe{}
|
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))
|
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
|
||||||
for _, r := range recipes {
|
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",
|
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
|
||||||
r.Result, strings.Join(r.Ingredients, " + "), rate))
|
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
|
// Side effects: grants foraging XP per attempt (success > failure) and bumps
|
||||||
// the player's CraftsSucceeded counter on each success.
|
// 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 {
|
if foragingLevel < 10 {
|
||||||
return nil, items
|
return nil, items
|
||||||
}
|
}
|
||||||
@@ -452,7 +474,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int)
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
|
||||||
success := rand.Float64() < rate
|
success := rand.Float64() < rate
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
|
|||||||
@@ -1037,8 +1037,17 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
|||||||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||||
if len(TreasureInventoryCap) == 0 {
|
// The flavor templates below are written for the base 3-slot set. With a
|
||||||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
// 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
|
// Build substitution map with existing treasure info
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ var advTreasureDropRates = map[int]float64{
|
|||||||
|
|
||||||
const advMaxTreasures = 3
|
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 ─────────────────────────────────────────────────────
|
// ── Treasure Definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
var advAllTreasures = map[int][]AdvTreasureDef{
|
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)
|
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+
|
// Auto-craft if player has foraging level 10+
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err == nil && char.ForagingSkill >= 10 {
|
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 {
|
if len(craftResults) > 0 {
|
||||||
items = updatedItems
|
items = updatedItems
|
||||||
for _, cr := range craftResults {
|
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
|
// wound layered with the fresh equipment cushion. There is no scale
|
||||||
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
||||||
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
||||||
if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
|
if c == nil || c.HPMax <= 0 {
|
||||||
return
|
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 {
|
if startHP < 1 {
|
||||||
startHP = 1
|
startHP = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,8 +222,15 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
|||||||
innPaid = true
|
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.HPCurrent = c.HPMax
|
||||||
c.TempHP = 0
|
c.TempHP = wellRestedTempHP(c.HPMax, restTier)
|
||||||
c.ShortRestCharges = c.Level
|
c.ShortRestCharges = c.Level
|
||||||
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
|
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
|
||||||
// long rest clears one). For Berserker who racks up exhaustion via
|
// 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)
|
markActedToday(ctx.Sender)
|
||||||
_ = refreshAllResources(ctx.Sender)
|
_ = refreshAllResources(ctx.Sender)
|
||||||
// Phase 9: spell slots refresh on long rest.
|
// Phase 9: spell slots refresh on long rest. A home rest also folds in the
|
||||||
_ = refreshSpellSlots(ctx.Sender)
|
// 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
|
// 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.
|
// 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
|
// 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 != "" {
|
if line := dndRestLongFlavorLine(hasHousing); line != "" {
|
||||||
msg += "\n\n_" + 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)
|
return p.SendDM(ctx.Sender, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,6 +564,48 @@ func refreshSpellSlots(userID id.UserID) error {
|
|||||||
return err
|
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
|
// partialRefreshSpellSlots restores spell slots on short rest. All L1 slots
|
||||||
// come back, plus floor(charLevel/4) additional slots distributed
|
// come back, plus floor(charLevel/4) additional slots distributed
|
||||||
// lowest-first across tiers ≥2. Returns slot_level→count restored so the
|
// lowest-first across tiers ≥2. Returns slot_level→count restored so the
|
||||||
|
|||||||
Reference in New Issue
Block a user