From 671773abd56df875363704234179f382749648b9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:13:09 -0700 Subject: [PATCH 1/5] Adventure: protect consumables from Robbie and 'sell all' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumables (auto-crafted from foraged ingredients or dropped from T2+ activities) are a curated stockpile — selling them should be an explicit choice, not a side effect. Two surfaces tightened: - Robbie's qualifying-items filter now skips Type == "consumable" the same way it skips Arena gear and cards. - !adventure sell all leaves consumables in inventory and reports them in the kept-items tally. The merchant message points players at !adventure sell for explicit consumable sales. !adventure sell still works fine on consumables — that path is already explicit by definition. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure_robbie.go | 6 ++++-- internal/plugin/adventure_shop.go | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index 50f9221..3221766 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -184,8 +184,10 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem { var result []AdvItem for _, item := range inv { - // Never touch Arena gear or cards - if item.Type == "ArenaGear" || item.Type == "card" { + // Never touch Arena gear, cards, or consumables. Consumables are a + // player-curated stockpile (crafted or dropped); selling them is an + // explicit decision the player must make themselves. + if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" { continue } diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 1fcaebd..4b15987 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -778,11 +778,16 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { var total float64 var sold int var keptSpecial int + var keptConsumable int for _, item := range items { if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { keptSpecial++ continue } + if item.Type == "consumable" { + keptConsumable++ + continue + } if err := removeAdvInventoryItem(item.ID); err != nil { continue } @@ -791,8 +796,13 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { } if sold == 0 { - if keptSpecial > 0 { + switch { + case keptSpecial > 0 && keptConsumable > 0: + return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell ` for individual consumables." + case keptSpecial > 0: return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead." + case keptConsumable > 0: + return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell ` to sell a specific consumable." } return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time." } @@ -811,6 +821,9 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { if keptSpecial > 0 { msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial) } + if keptConsumable > 0 { + msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell `.)", keptConsumable) + } return msg } From 8e3b8377c036c8828a74a86a9fb4fef4c5e234ad Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:19:06 -0700 Subject: [PATCH 2/5] Adventure crafting: grant foraging XP per attempt, track lifetime crafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crafting was a downstream consumer of Foraging skill but never gave any XP back, so the feedback loop was one-directional — only gathering contributed to the level that gates better recipes. Now: each auto-craft attempt grants Foraging XP (success > failure). Per-tier values: tier 1: +12 / +3 tier 2: +25 / +5 tier 3: +40 / +8 tier 4: +60 / +12 tier 5: +90 / +18 Successful T1 ≈ 30% of a Foraging Success haul; T5 ≈ 40%. Failures get a small consolation grant — the attempt still produced practical knowledge (and lost ingredients). Schema: adventure_characters.crafts_succeeded INT for lifetime tracking. Migration entry included. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/db/db.go | 1 + internal/plugin/adventure_character.go | 13 ++++++++----- internal/plugin/adventure_consumables.go | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index b55e7a5..b1a16ac 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -141,6 +141,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE coop_dungeon_gifts ADD COLUMN expires_at DATETIME`, `ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`, `ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`, + `ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 515ba42..98ff61c 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -95,6 +95,7 @@ type AdventureCharacter struct { PetMorningDefense bool AutoBabysit bool StreakDecayed bool + CraftsSucceeded int } type AdvEquipment struct { @@ -445,7 +446,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { pet_chased_away, pet_reactivated, pet_arrived, misty_encounter_count, misty_donated_count, thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed + pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -470,7 +471,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &petChasedAway, &petReactivated, &petArrived, &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, &autoBabysit, &streakDecayed, + &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, ) if err != nil { return nil, err @@ -638,7 +639,8 @@ func saveAdvCharacter(char *AdventureCharacter) error { thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?, pet_morning_defense = ?, auto_babysit = ?, - streak_decayed = ? + streak_decayed = ?, + crafts_succeeded = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -664,6 +666,7 @@ func saveAdvCharacter(char *AdventureCharacter) error { petMorningDef, autoBabysit, streakDecayed, + char.CraftsSucceeded, string(char.UserID), ) return err @@ -794,7 +797,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { pet_chased_away, pet_reactivated, pet_arrived, misty_encounter_count, misty_donated_count, thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed + pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded FROM adventure_characters`) if err != nil { return nil, err @@ -834,7 +837,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { &petChasedAway, &petReactivated, &petArrived, &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, - &petMorningDef, &autoBabysit, &streakDecayed, + &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, ); err != nil { return nil, err } diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 970bb51..fe6d048 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -339,9 +339,19 @@ type CraftResult struct { Success bool } +// craftXPRewards: per-tier foraging XP granted on successful (and tiny on +// failed) auto-crafts. Successful T1 ≈ 30% of a Foraging Success haul, scaling +// up so T5 grants are meaningful. Failures get a token consolation grant — +// you tried. +var craftXPSuccess = map[int]int{1: 12, 2: 25, 3: 40, 4: 60, 5: 90} +var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18} + // autoCraftConsumables scans the player's inventory for craftable recipes and // attempts to craft the highest-tier recipe available. Consumes ingredients on // attempt; on failure one ingredient is lost. Returns crafted items and results. +// +// 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) { if foragingLevel < 10 { return nil, items @@ -393,6 +403,13 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) } results = append(results, CraftResult{Recipe: bestRecipe, Success: true}) crafted++ + // Foraging XP + lifetime counter bump. + if char, err := loadAdvCharacter(userID); err == nil { + char.ForagingXP += craftXPSuccess[bestRecipe.Tier] + char.CraftsSucceeded++ + checkAdvLevelUp(char, "foraging") + _ = saveAdvCharacter(char) + } } else { // Failure: all ingredients destroyed for _, id := range ingredientIDs { @@ -400,6 +417,12 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) } remaining = removeItemsByIDs(remaining, ingredientIDs) results = append(results, CraftResult{Recipe: bestRecipe, Success: false}) + // Token foraging XP for the attempt. + if char, err := loadAdvCharacter(userID); err == nil { + char.ForagingXP += craftXPFailure[bestRecipe.Tier] + checkAdvLevelUp(char, "foraging") + _ = saveAdvCharacter(char) + } } } From 0bebcb56cd1874f144cb2495e363ff75bd88bb2c Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:23:53 -0700 Subject: [PATCH 3/5] =?UTF-8?q?Adventure:=20!adventure=20recipes=20?= =?UTF-8?q?=E2=80=94=20list=20known=20crafting=20recipes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Players had no way to see what they could craft without grinding ingredients and watching combat narrative. New command lists every recipe unlocked at the player's current Foraging level, grouped by tier, with: - Result name + ingredient list (so players know what to gather) - Per-recipe success rate at the player's current level - Locked-recipe count + next-unlock threshold - Max auto-crafts per combat (1 + (level-10)/10) Sub-Foraging-10 players see only the unlock hint. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 11 +++++ internal/plugin/adventure_consumables.go | 57 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 609334a..7184ad7 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -267,6 +267,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:])) case lower == "boost": return p.handleBoostCmd(ctx) + case lower == "recipes": + return p.handleRecipesCmd(ctx) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -290,6 +292,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs) ` + "`!adventure repair all`" + ` — Repair all damaged equipment ` + "`!adventure repair `" + ` — Repair a specific slot +` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level ` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `. ` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead) ` + "`!thom`" + ` — Visit Thom Krooke (housing and loans) @@ -1343,6 +1346,14 @@ func advApplyBoost(result *AdvActionResult) { } } +func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error { + char, _, err := p.ensureCharacter(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to load your character.") + } + return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill)) +} + func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error { if !p.IsAdmin(ctx.Sender) { return nil diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index fe6d048..0e33213 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -1,8 +1,10 @@ package plugin import ( + "fmt" "log/slog" "math/rand/v2" + "strings" "maunium.net/go/mautrix/id" ) @@ -339,6 +341,61 @@ type CraftResult struct { Success bool } +// renderRecipesKnown returns a player-facing list of recipes available at +// 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 { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel)) + + if foragingLevel < 10 { + sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._") + return sb.String() + } + + // Group recipes by tier, list those known. + known := map[int][]CraftingRecipe{} + totalKnown := 0 + totalLocked := 0 + nextUnlock := 0 + for _, r := range craftingRecipes { + if r.MinForaging <= foragingLevel { + known[r.Tier] = append(known[r.Tier], r) + totalKnown++ + } else { + totalLocked++ + if nextUnlock == 0 || r.MinForaging < nextUnlock { + nextUnlock = r.MinForaging + } + } + } + + for tier := 1; tier <= 5; tier++ { + recipes := known[tier] + if len(recipes) == 0 { + continue + } + sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging)) + for _, r := range recipes { + rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100 + sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n", + r.Result, strings.Join(r.Ingredients, " + "), rate)) + } + } + + sb.WriteString(fmt.Sprintf("\nKnown: %d · ", totalKnown)) + if totalLocked == 0 { + sb.WriteString("All recipes unlocked. ⭐") + } else { + sb.WriteString(fmt.Sprintf("Locked: %d (next unlock at Foraging Lv.%d)", totalLocked, nextUnlock)) + } + + maxCrafts := 1 + (foragingLevel-10)/10 + sb.WriteString(fmt.Sprintf("\nMax auto-crafts per combat: %d.", maxCrafts)) + return sb.String() +} + // craftXPRewards: per-tier foraging XP granted on successful (and tiny on // failed) auto-crafts. Successful T1 ≈ 30% of a Foraging Success haul, scaling // up so T5 grants are meaningful. Failures get a token consolation grant — From d9ac99845844f89b6c6a3254033bc864b45df496 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:26:06 -0700 Subject: [PATCH 4/5] Adventure: surface crafts in !adventure status + document crafting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status sheet now shows lifetime craft count + a hint pointing at !adventure recipes (only displayed once the player has crafted at least once — keeps the line absent for non-foragers). - README gains a Crafting subsection documenting the auto-craft flow, XP rewards, level gates, the recipes command, and the consumable protection (Robbie + sell-all both skip them). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 14 ++++++++++++++ internal/plugin/adventure_render.go | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 8360554..9ad9478 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,20 @@ Four activity types across 5 tiers of locations. Higher tiers require higher cha - **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters. - **Chat level perks** — active chat participation boosts your adventurer. +5% XP per 10 chat levels (capped at +25% at level 50+), plus +0.5% rare drop chance per 10 levels. +#### Crafting + +Auto-crafting kicks in at Foraging Lv.10. Before each combat action, the system scans your inventory for matching ingredients and assembles the highest-tier recipe you qualify for. 12 recipes spanning T1–T5, gated at Foraging 10/15/20/25/30. Per-recipe success rate is 50% at the unlock level, +3% per 5 levels above (capped 95%). Failed crafts destroy the ingredients — you tried. + +Per attempt: +- **Successful crafts** grant Foraging XP (T1: +12, T2: +25, T3: +40, T4: +60, T5: +90) and bump a lifetime `CraftsSucceeded` counter shown on `!adventure status`. +- **Failed crafts** grant a token Foraging XP (1/4 of success). + +Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30+. + +`!adventure recipes` lists every recipe unlocked at your current Foraging level with ingredients and per-recipe success rate, plus a teaser for the next unlock threshold. + +**Consumable protection:** crafted/dropped consumables (Type `consumable`) are excluded from `!adventure sell all` and Robbie the Friendly Bandit's pickup filter. Selling consumables requires explicit `!adventure sell ` — no accidental mass-sells, no surprise donations to the community pot. + #### Blacksmith & Repair Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair ` commits. Visiting the blacksmith counts as your daily action. diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 9fe96c6..7e8f7c7 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -120,6 +120,12 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* sb.WriteString(fmt.Sprintf(" (best: %d)\n", char.BestStreak)) } + // Crafting (only show once they've actually crafted something) + if char.CraftsSucceeded > 0 { + sb.WriteString(fmt.Sprintf("🧪 Crafts: %d successful (Foraging Lv.%d — `!adventure recipes`)\n", + char.CraftsSucceeded, char.ForagingSkill)) + } + // Equipment sb.WriteString("\n🛡️ Equipment:\n") eqScore := advEquipmentScore(equip) From c66c32e698d5fed413c9a4c28f20f0b701df4eab Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:49:40 -0700 Subject: [PATCH 5/5] .gitignore: skip cmd/ binaries (gensolver, holdem-train) Both are 16MB Go binaries built from cmd/gensolver and cmd/holdem-train. They were showing up as untracked on every status check. Also drops a stray 0-byte internal/db/gogobee.db that someone created at the wrong path (real DB lives in data/, already gitignored). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 036cb99..91e32ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ data/ .env.* !.env.example gogobee +gensolver +holdem-train