Files
gogobee/internal/plugin/dnd_economy.go
prosolis c170adaf05 Adv 2.0 D&D Phase R R3-R5: Combat-link, zone loot, fishing, economy
R3 — Combat Event Integration:
- dnd_expedition_combat.go: Combat Interrupt rolls (§4.2) with
  threat-clock and Ranger-wilderness modifiers; Patrol Encounters
  scaled by threat level; recordZoneKill writer with monsterKillTags.
- Interrupt gate at head of handleHarvestCmd; patrol gate before
  resolveRoom in zoneCmdAdvance; kill writer wired into combat-win
  paths.

R4a — Zone Loot Tables:
- dnd_zone_loot.go: §5 loot drop tables for all 10 zones × 5 tiers
  with §8.1 sell-value bands. dropTierFromCR brackets + boss floor.
- Hooks on combat-win in resolveCombatRoom, resolveBossRoom,
  runHarvestInterrupt, tryPatrolEncounter.

R4b — Fishing Integration:
- dnd_zone_fish.go: fishingZones allow-list, fishingSkillBonus,
  rangerRareCatchUpgrade (§6.2), feywildFishDistortion narration.
- §6.1 fish entries added to resource registry for Forest, Sunken
  Temple, Underdark, Feywild zones.
- !fish wired through handleHarvestCmd; zoneItemFlavor matrix
  populated for all 10 zones × all loot items.

R5 — Economy Integration:
- dnd_economy.go: !sell (post-expedition gate, single CHA Persuasion
  DC 17 → +15% bump), !craft (§8.2 4 exemplar recipes), !lore
  (INT/Arcana DC 15 recipe discovery).
- dnd_known_recipe table for persistent recipe discovery.

Flavor reuse: HarvestInterrupt, PatrolEncounter, CombatVictory,
PlayerDeath, LootDrop*, FeywildTimeDistortion* — all existing pools.
No new flavor file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

562 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
// Phase R5 — Thom Krooke economy: !sell, !craft, !lore.
// Spec: gogobee_resource_combat_integration.md §8.
//
// !sell Post-expedition only. Liquidates harvested materials/fish/items
// from inventory at the §8.1 sell-value baseline already stored
// in AdvItem.Value. CHA Persuasion DC 17 — pass = +15% on the
// entire transaction. Excludes equipment (Masterwork/Arena/etc.).
// !craft Manual crafting at Thom Krooke from the §8.2 exemplar recipes.
// Requires the recipe to be discovered (!lore) and all named
// ingredients in inventory. Consumes ingredients, deposits the
// output as a new AdvItem.
// !lore INT/Arcana DC 15 — on success, reveals one undiscovered recipe.
// Up to one attempt per long rest (uses dnd_character.last_long_rest_at
// via the existing rest plumbing as a soft cooldown anchor).
import (
"database/sql"
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Recipe Registry (§8.2) ───────────────────────────────────────────────────
// ThomCraftRecipe is one §8.2 recipe. Ingredient names match the registry
// Name field exactly so inventory lookups succeed without fuzzy matching.
type ThomCraftRecipe struct {
ID string
Name string
Ingredients map[string]int // Name → count
OutputName string
OutputType string // material | item | weapon | armor | accessory | consumable
OutputTier int
OutputValue int64
Description string
DiscoveryHint string // shown when newly discovered via !lore
}
var thomCraftRecipes = []ThomCraftRecipe{
{
ID: "potion_fire_resist",
Name: "Potion of Fire Resistance",
Ingredients: map[string]int{
"Salamander Oil": 1,
"Fire Essence": 2,
},
OutputName: "Potion of Fire Resistance",
OutputType: "consumable",
OutputTier: 3,
OutputValue: 220,
Description: "Resist fire damage for one combat. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom rumbles: \"Salamander oil thinned with two pinches of fire essence. Don't ask me how I know. The dosage matters.\"",
},
{
ID: "mithral_dagger",
Name: "Mithral Dagger",
Ingredients: map[string]int{
"Mithral Trace": 1,
"Scrap Iron": 3,
},
OutputName: "Mithral Dagger",
OutputType: "weapon",
OutputTier: 4,
OutputValue: 650,
Description: "Light blade. Bypasses light-armor proficiency restriction. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom: \"Mithral trace alloyed with three measures of scrap iron. The cheap iron is what holds the edge. Most apprentices get that backwards.\"",
},
{
ID: "undead_ward_charm",
Name: "Undead Ward Charm",
Ingredients: map[string]int{
"Silver Grave Dust": 1,
"Wraith Core": 1,
},
OutputName: "Undead Ward Charm",
OutputType: "accessory",
OutputTier: 4,
OutputValue: 720,
Description: "+2 to saves vs. undead. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom turns the dust through his fingers. \"Silver dust, wraith's core. Bind them, the wraith forgets it ever wanted you. Strange but reliable.\"",
},
{
ID: "shadow_step_cloak",
Name: "Shadow Step Cloak",
Ingredients: map[string]int{
"Ghost Silk": 3,
"Shadow Essence": 1,
},
OutputName: "Shadow Step Cloak",
OutputType: "armor",
OutputTier: 4,
OutputValue: 780,
Description: "Light armor. Stealth advantage 1/day. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom: \"Three lengths of ghost silk woven against one drop of shadow essence. The silk fights you while you stitch. That's how you know it's working.\"",
},
}
// findRecipeByID returns the recipe with the given ID.
func findRecipeByID(id string) (ThomCraftRecipe, bool) {
for _, r := range thomCraftRecipes {
if r.ID == id {
return r, true
}
}
return ThomCraftRecipe{}, false
}
// findRecipeByName fuzzy-matches a recipe name (case-insensitive prefix or
// substring). Returns ok=false if zero or >1 matches.
func findRecipeByName(query string) (ThomCraftRecipe, bool) {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return ThomCraftRecipe{}, false
}
var matches []ThomCraftRecipe
for _, r := range thomCraftRecipes {
if strings.Contains(strings.ToLower(r.Name), query) {
matches = append(matches, r)
}
}
if len(matches) != 1 {
return ThomCraftRecipe{}, false
}
return matches[0], true
}
// ── Recipe discovery persistence ─────────────────────────────────────────────
func loadKnownRecipes(userID id.UserID) (map[string]bool, error) {
d := db.Get()
rows, err := d.Query(`SELECT recipe_id FROM dnd_known_recipe WHERE user_id = ?`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
known := map[string]bool{}
for rows.Next() {
var rid string
if err := rows.Scan(&rid); err != nil {
return nil, err
}
known[rid] = true
}
return known, rows.Err()
}
func recordKnownRecipe(userID id.UserID, recipeID string) error {
d := db.Get()
_, err := d.Exec(`
INSERT OR IGNORE INTO dnd_known_recipe (user_id, recipe_id) VALUES (?, ?)`,
string(userID), recipeID)
return err
}
// ── !sell ────────────────────────────────────────────────────────────────────
// handleResourceSellCmd is the top-level !sell handler. The legacy
// `!adventure sell` path remains intact; this is the new R5 surface that
// pays out at §8.1 baselines and applies the CHA DC 17 +15% bump.
//
// Forms:
//
// !sell — list sellable inventory + total at base price
// !sell list — alias of bare !sell
// !sell all — liquidate everything sellable in one transaction
// !sell <name> — fuzzy-match a single item by name
func (p *AdventurePlugin) handleResourceSellCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(args)
lower := strings.ToLower(args)
// Post-expedition gate.
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
}
if exp != nil && exp.IsActive() {
return p.SendDM(ctx.Sender,
"Thom Krooke doesn't deal with you mid-expedition. Extract first, then bring the goods to him.")
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load inventory.")
}
sellable := filterSellable(items)
if len(sellable) == 0 {
return p.SendDM(ctx.Sender,
"You've got nothing Thom Krooke wants to buy. Bring back something from an expedition first.")
}
if args == "" || lower == "list" {
return p.SendDM(ctx.Sender, renderSellList(sellable))
}
var toSell []AdvItem
if lower == "all" {
toSell = sellable
} else {
match := findInventoryMatch(sellable, args)
if match == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No sellable item matching '%s'. Try `!sell list` to see what Thom will take.", args))
}
toSell = []AdvItem{*match}
}
return p.executeResourceSell(ctx.Sender, toSell)
}
// filterSellable returns inventory items eligible for the §8.1 path:
// harvest materials, fish, and zone-loot items. Equipment, consumables,
// arena gear, and cards are left to the legacy `!adventure sell`.
func filterSellable(items []AdvItem) []AdvItem {
var out []AdvItem
for _, it := range items {
switch it.Type {
case "material", "fish", "item":
out = append(out, it)
}
}
return out
}
// findInventoryMatch returns the first sellable item whose name contains
// the query (case-insensitive). Returns nil if no match.
func findInventoryMatch(items []AdvItem, query string) *AdvItem {
q := strings.ToLower(query)
for i := range items {
if strings.Contains(strings.ToLower(items[i].Name), q) {
return &items[i]
}
}
return nil
}
// renderSellList shows what Thom will buy with running totals.
func renderSellList(items []AdvItem) string {
var sb strings.Builder
sb.WriteString("**Thom Krooke** runs his thumb down the page.\n\n")
grouped := map[string]struct {
count int
value int64
}{}
order := []string{}
for _, it := range items {
g, seen := grouped[it.Name]
if !seen {
order = append(order, it.Name)
}
g.count++
g.value += it.Value
grouped[it.Name] = g
}
var total int64
for _, name := range order {
g := grouped[name]
total += g.value
sb.WriteString(fmt.Sprintf("• %s × %d — €%d\n", name, g.count, g.value))
}
sb.WriteString(fmt.Sprintf("\nBase total: **€%d** across %d item(s).\n", total, len(items)))
sb.WriteString("\n`!sell all` to liquidate everything · `!sell <name>` for a single item.")
sb.WriteString("\nThom will roll a Persuasion check (DC 17) — pass = +15% on the take.")
return sb.String()
}
// executeResourceSell consumes the items, rolls one CHA Persuasion check,
// credits the payout, and DMs the result.
func (p *AdventurePlugin) executeResourceSell(userID id.UserID, items []AdvItem) error {
if len(items) == 0 {
return p.SendDM(userID, "Nothing to sell.")
}
var base int64
for _, it := range items {
base += it.Value
}
// CHA Persuasion DC 17 — single roll for the whole transaction.
var bumpApplied bool
var rollLine string
if char, err := LoadDnDCharacter(userID); err == nil && char != nil && !char.PendingSetup {
res := performSkillCheck(char, SkillPersuasion, 17)
bumpApplied = res.Success
if res.Auto {
rollLine = fmt.Sprintf("_Persuasion (auto-%s)_",
ternary(res.Success, "success on a nat 20", "fail on a nat 1"))
} else {
rollLine = fmt.Sprintf("_Persuasion: rolled %d + %d = %d vs DC 17 — %s_",
res.Roll, res.Mod, res.Total, ternary(res.Success, "+15%", "no bump"))
}
}
final := base
if bumpApplied {
final = base + (base*15)/100
}
// Remove items.
removed := 0
for _, it := range items {
if err := removeAdvInventoryItem(it.ID); err == nil {
removed++
}
}
if removed == 0 {
return p.SendDM(userID, "Couldn't remove items from inventory. Nothing sold.")
}
if p.euro != nil {
reason := "dnd_resource_sell"
if len(items) == 1 {
reason = "dnd_resource_sell_" + items[0].Name
}
p.euro.Credit(userID, float64(final), reason)
}
var sb strings.Builder
sb.WriteString("**Thom Krooke** finishes counting and slides a stack across the counter.\n\n")
if len(items) == 1 {
sb.WriteString(fmt.Sprintf("Sold **%s** at base €%d.\n", items[0].Name, base))
} else {
sb.WriteString(fmt.Sprintf("Sold **%d items** at base total €%d.\n", removed, base))
}
if rollLine != "" {
sb.WriteString(rollLine + "\n")
}
if bumpApplied {
sb.WriteString(fmt.Sprintf("Final payout: **€%d** (+15%%).\n", final))
sb.WriteString("\n_Thom: \"Reasonable.\"_")
} else {
sb.WriteString(fmt.Sprintf("Final payout: **€%d**.\n", final))
sb.WriteString("\n_Thom: \"Standard rate. Don't take it personally.\"_")
}
return p.SendDM(userID, sb.String())
}
func ternary(b bool, t, f string) string {
if b {
return t
}
return f
}
// ── !craft ───────────────────────────────────────────────────────────────────
// handleCraftCmd dispatches `!craft`, `!craft list`, and `!craft <recipe>`.
func (p *AdventurePlugin) handleCraftCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(args)
lower := strings.ToLower(args)
known, err := loadKnownRecipes(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your recipe book.")
}
if args == "" || lower == "list" {
return p.SendDM(ctx.Sender, renderRecipeBook(ctx.Sender, known))
}
recipe, ok := findRecipeByName(args)
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"No recipe matches '%s'. Try `!craft list` to see what you've discovered.", args))
}
if !known[recipe.ID] {
return p.SendDM(ctx.Sender,
"_Thom: \"Doesn't ring a bell. If you don't know the recipe, I don't either.\"_\n\nDiscover it via `!lore` first.")
}
return p.executeCraft(ctx.Sender, recipe)
}
// renderRecipeBook lists known recipes, ingredient lists, and a count of
// undiscovered ones.
func renderRecipeBook(userID id.UserID, known map[string]bool) string {
var sb strings.Builder
sb.WriteString("📜 **Thom Krooke — Recipe Book**\n\n")
items, _ := loadAdvInventory(userID)
have := inventoryByName(items)
knownCount := 0
for _, r := range thomCraftRecipes {
if !known[r.ID] {
continue
}
knownCount++
sb.WriteString(fmt.Sprintf("**%s**\n", r.Name))
var parts []string
ready := true
for ing, need := range r.Ingredients {
got := have[ing]
parts = append(parts, fmt.Sprintf("%s ×%d (%d)", ing, need, got))
if got < need {
ready = false
}
}
sb.WriteString(" Ingredients: " + strings.Join(parts, " + ") + "\n")
sb.WriteString(" " + r.Description + "\n")
if ready {
sb.WriteString(" ✅ Ready to craft.\n")
}
sb.WriteString("\n")
}
if knownCount == 0 {
sb.WriteString("_You don't know any recipes yet. Try `!lore` at Thom Krooke's._\n\n")
}
hidden := len(thomCraftRecipes) - knownCount
if hidden > 0 {
sb.WriteString(fmt.Sprintf("_%d undiscovered recipe(s). Use `!lore` to dig._", hidden))
} else {
sb.WriteString("_All recipes discovered._")
}
return sb.String()
}
// inventoryByName counts items grouped by name.
func inventoryByName(items []AdvItem) map[string]int {
out := map[string]int{}
for _, it := range items {
out[it.Name]++
}
return out
}
// executeCraft consumes ingredients and deposits the output. Returns a DM.
func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) error {
items, err := loadAdvInventory(userID)
if err != nil {
return p.SendDM(userID, "Couldn't load inventory.")
}
// Pick item IDs to consume — first-fit per ingredient name.
consume, missing := pickIngredients(items, r.Ingredients)
if len(missing) > 0 {
var lines []string
for ing, short := range missing {
lines = append(lines, fmt.Sprintf(" • %s — short %d", ing, short))
}
return p.SendDM(userID, fmt.Sprintf(
"_Thom: \"Not enough material. Come back when you've got the rest.\"_\n\n%s",
strings.Join(lines, "\n")))
}
for _, itemID := range consume {
if err := removeAdvInventoryItem(itemID); err != nil {
return p.SendDM(userID, "Crafting failed mid-step. Inventory may be partially consumed.")
}
}
out := AdvItem{
Name: r.OutputName,
Type: r.OutputType,
Tier: r.OutputTier,
Value: r.OutputValue,
}
if err := addAdvInventoryItem(userID, out); err != nil {
return p.SendDM(userID, "Crafted item couldn't be deposited. Tell an admin.")
}
return p.SendDM(userID, fmt.Sprintf(
"**Thom Krooke** lays the materials out, works for a moment, and slides the result back to you.\n\n"+
"Crafted: **%s** _(tier %d, sell value €%d)_\n%s",
r.OutputName, r.OutputTier, r.OutputValue, r.Description))
}
// pickIngredients walks the inventory and selects item IDs to consume,
// covering all required (Name → count). Returns (idsToRemove, missingByName).
func pickIngredients(items []AdvItem, need map[string]int) ([]int64, map[string]int) {
remaining := map[string]int{}
for k, v := range need {
remaining[k] = v
}
var consume []int64
for _, it := range items {
if remaining[it.Name] > 0 {
consume = append(consume, it.ID)
remaining[it.Name]--
}
}
missing := map[string]int{}
for name, left := range remaining {
if left > 0 {
missing[name] = left
}
}
return consume, missing
}
// ── !lore ────────────────────────────────────────────────────────────────────
// handleLoreCmd rolls INT/Arcana DC 15. On success, reveals one
// undiscovered recipe at random.
func (p *AdventurePlugin) handleLoreCmd(ctx MessageContext) error {
char, err := LoadDnDCharacter(ctx.Sender)
if err != nil || char == nil || char.PendingSetup {
return p.SendDM(ctx.Sender,
"You need a D&D character to dig through Thom Krooke's lore stacks. Run `!setup`.")
}
known, err := loadKnownRecipes(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your recipe book.")
}
var undiscovered []ThomCraftRecipe
for _, r := range thomCraftRecipes {
if !known[r.ID] {
undiscovered = append(undiscovered, r)
}
}
if len(undiscovered) == 0 {
return p.SendDM(ctx.Sender,
"_Thom: \"You've already pulled every loose page out of these shelves. Nothing new to find.\"_")
}
res := performSkillCheck(char, SkillArcana, 15)
var sb strings.Builder
sb.WriteString("**Thom Krooke** waves at the stacks. \"Help yourself. Don't drop anything.\"\n\n")
if res.Auto {
sb.WriteString(fmt.Sprintf("_Arcana (auto-%s)_\n",
ternary(res.Success, "success on a nat 20", "fail on a nat 1")))
} else {
sb.WriteString(fmt.Sprintf("_Arcana: rolled %d + %d = %d vs DC 15_\n",
res.Roll, res.Mod, res.Total))
}
if !res.Success {
sb.WriteString("\nThe pages don't connect for you today. Come back later.")
return p.SendDM(ctx.Sender, sb.String())
}
pick := undiscovered[rand.IntN(len(undiscovered))]
if err := recordKnownRecipe(ctx.Sender, pick.ID); err != nil {
return p.SendDM(ctx.Sender, "Lore check passed but the page wouldn't stay copied. Tell an admin.")
}
sb.WriteString(fmt.Sprintf("\n📖 **New recipe discovered: %s**\n%s\n\n_Use `!craft list` to review your book._",
pick.Name, pick.DiscoveryHint))
return p.SendDM(ctx.Sender, sb.String())
}
// ── Test helper ──────────────────────────────────────────────────────────────
// resetKnownRecipesForTest wipes recipe discovery for a user. Test-only.
func resetKnownRecipesForTest(userID id.UserID) error {
d := db.Get()
_, err := d.Exec(`DELETE FROM dnd_known_recipe WHERE user_id = ?`, string(userID))
if err == sql.ErrNoRows {
return nil
}
return err
}