Files
gogobee/internal/plugin/dnd_economy.go
prosolis d9541f07f1 adventure: Tier 6 postgame "Mythic" dungeons (P1–P7 calibration)
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.

- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
  T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
  boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
  secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
  five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
  IsPostgameZone). First calibration pass on millenia — hardened ossuary +
  drowned_star, softened first_hoard + unplace; last_meridian in band.

Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
2026-07-15 23:17:07 -07:00

773 lines
26 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 (exact match)
// BladeWeaponCount is the count of generic "any blade weapon"
// inventory items consumed (§8.2 Drow Poison Blade). 0 = none.
BladeWeaponCount int
OutputName string
OutputType string // material | item | weapon | armor | accessory | consumable | magic_item
OutputTier int
OutputValue int64
// OutputSkillSource, when set, is stamped on the crafted AdvItem's
// SkillSource — used for OutputType "magic_item" so the result equips and
// resolves against the registry exactly like a looted magic item
// ("magic_item:<id>").
OutputSkillSource string
Description string
DiscoveryHint string // shown when newly discovered via !lore
// AlwaysKnown recipes are never gated behind !lore discovery — they're the
// T6 signature-item pity path, craftable the moment a player holds enough
// of the zone's crafting anchor. They stay hidden from the recipe book
// until the player actually has an anchor in hand (see renderRecipeBook).
AlwaysKnown bool
}
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.\"",
},
{
ID: "drow_poison_blade",
Name: "Drow Poison Blade",
Ingredients: map[string]int{
"Drow Poison (diluted)": 1,
},
BladeWeaponCount: 1,
OutputName: "Drow Poison Blade",
OutputType: "weapon",
OutputTier: 4,
OutputValue: 540,
Description: "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)",
DiscoveryHint: "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"",
},
// ── T6 signature-item pity path ────────────────────────────────────────
// AlwaysKnown: no !lore gate. Each consumes 5 of the zone's crafting anchor
// (one guaranteed per clear) so a bad-luck run of the 46% signature drops
// still converts ~5 clears into a guaranteed best-in-slot. Output is a real
// magic_item — same SkillSource shape the loot path stamps — so it equips
// and carries its postgameSignatureEffects delta.
{
ID: "anchor_crown_of_the_patient_king",
Name: "Crown of the Patient King",
Ingredients: map[string]int{
"Verse Bound Folio": 5,
},
OutputName: "Crown of the Patient King",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:crown_of_the_patient_king",
AlwaysKnown: true,
Description: "Legendary crown of Valdris — the patient plan, worn on the head. (Pity craft: 5 Ossuary clears.)",
},
{
ID: "anchor_aegis_of_the_first_scale",
Name: "Aegis of the First Scale",
Ingredients: map[string]int{
"Cooling Ember Of Aurvandryx": 5,
},
OutputName: "Aegis of the First Scale",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 9000,
OutputSkillSource: "magic_item:aegis_of_the_first_scale",
AlwaysKnown: true,
Description: "Legendary breastplate of the Ember Before Fire — best armor in the game. (Pity craft: 5 First Hoard clears.)",
},
{
ID: "anchor_needle_of_the_seam",
Name: "Needle of the Seam",
Ingredients: map[string]int{
"Spool Of Undone Geometry": 5,
},
OutputName: "Needle of the Seam",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:needle_of_the_seam",
AlwaysKnown: true,
Description: "Legendary needle-dagger of the Seamstress — best caster weapon. (Pity craft: 5 Unplace clears.)",
},
{
ID: "anchor_halo_of_the_sunken_dawn",
Name: "Halo of the Sunken Dawn",
Ingredients: map[string]int{
"Ember Of The Drowned Star": 5,
},
OutputName: "Halo of the Sunken Dawn",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:halo_of_the_sunken_dawn",
AlwaysKnown: true,
Description: "Legendary halo of Seraphel — the first true healer's crown. (Pity craft: 5 Drowned Star clears.)",
},
{
ID: "anchor_the_second_hand",
Name: "The Second Hand",
Ingredients: map[string]int{
"Unspent Hour": 5,
},
OutputName: "The Second Hand",
OutputType: "magic_item",
OutputTier: 5,
OutputValue: 8000,
OutputSkillSource: "magic_item:the_second_hand",
AlwaysKnown: true,
Description: "Legendary sidearm of the Last Meridian — best martial sidearm. (Pity craft: 5 Last Meridian clears.)",
},
}
// bladeWeaponKeywords are the name-substrings that qualify an AdvItem
// (Type=="weapon") as a blade for the §8.2 "any blade weapon" matcher.
var bladeWeaponKeywords = []string{
"sword", "dagger", "blade", "scimitar", "rapier",
"knife", "cutlass", "saber", "katana", "falchion",
"shortsword", "longsword", "greatsword",
}
// isBladeWeapon reports whether an inventory item qualifies as a blade
// for crafting purposes — type "weapon" plus a blade keyword in the name.
func isBladeWeapon(it AdvItem) bool {
if it.Type != "weapon" {
return false
}
low := strings.ToLower(it.Name)
for _, kw := range bladeWeaponKeywords {
if strings.Contains(low, kw) {
return true
}
}
return false
}
// recipeCraftable reports whether a player may craft a recipe: either they've
// discovered it via !lore, or it's an AlwaysKnown pity recipe (which skips
// discovery entirely — the crafting anchor it needs is the real gate).
func recipeCraftable(known map[string]bool, r ThomCraftRecipe) bool {
return r.AlwaysKnown || known[r.ID]
}
// 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. A seated member is mid-expedition too — reading
// only their own row would let them run a shop from inside the dungeon.
exp, _, err := activeExpeditionFor(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 !recipeCraftable(known, recipe) {
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)
shownCount := 0
for _, r := range thomCraftRecipes {
// AlwaysKnown pity recipes only surface once the player actually holds
// one of the crafting anchor — no need to spoil T6 chase items to a
// level-3 player. Discovered recipes always show.
switch {
case known[r.ID]:
case r.AlwaysKnown && recipeHasAnyIngredient(have, r):
default:
continue
}
shownCount++
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
}
}
if r.BladeWeaponCount > 0 {
haveBlades := 0
for _, it := range items {
if isBladeWeapon(it) {
haveBlades++
}
}
parts = append(parts, fmt.Sprintf("any blade weapon ×%d (%d)", r.BladeWeaponCount, haveBlades))
if haveBlades < r.BladeWeaponCount {
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 shownCount == 0 {
sb.WriteString("_You don't know any recipes yet. Try `!lore` at Thom Krooke's._\n\n")
}
// Only discoverable (non-AlwaysKnown) recipes count toward the !lore hint;
// pity recipes are never "discovered", so they'd make the count never zero.
hidden := 0
for _, r := range thomCraftRecipes {
if !r.AlwaysKnown && !known[r.ID] {
hidden++
}
}
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()
}
// recipeHasAnyIngredient reports whether the player holds at least one unit of
// any of a recipe's named ingredients — the gate for surfacing an AlwaysKnown
// pity recipe in the book.
func recipeHasAnyIngredient(have map[string]int, r ThomCraftRecipe) bool {
for ing := range r.Ingredients {
if have[ing] > 0 {
return true
}
}
return false
}
// 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 plus any
// generic "blade weapon" slots required by the recipe.
consume, missing := pickIngredients(items, r.Ingredients)
bladeIDs, bladeShort := pickBladeWeapons(items, r.BladeWeaponCount, consume)
consume = append(consume, bladeIDs...)
if len(missing) > 0 || bladeShort > 0 {
var lines []string
for ing, short := range missing {
lines = append(lines, fmt.Sprintf(" • %s — short %d", ing, short))
}
if bladeShort > 0 {
lines = append(lines, fmt.Sprintf(" • any blade weapon — short %d", bladeShort))
}
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,
SkillSource: r.OutputSkillSource,
}
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))
}
// pickBladeWeapons returns inventory IDs covering the requested generic
// blade-weapon count, skipping any IDs already in `exclude`. Returns the
// chosen IDs plus a shortfall count when inventory can't cover.
func pickBladeWeapons(items []AdvItem, count int, exclude []int64) ([]int64, int) {
if count <= 0 {
return nil, 0
}
used := map[int64]bool{}
for _, id := range exclude {
used[id] = true
}
var picked []int64
for _, it := range items {
if len(picked) >= count {
break
}
if used[it.ID] {
continue
}
if isBladeWeapon(it) {
picked = append(picked, it.ID)
used[it.ID] = true
}
}
return picked, count - len(picked)
}
// 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 {
// AlwaysKnown recipes are never in the discovery pool — they can't be
// "found" and shouldn't dilute the odds of finding the real ones.
if r.AlwaysKnown {
continue
}
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
}