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 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/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.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_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..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,9 +341,74 @@ 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 — +// 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 +460,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 +474,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) + } } } 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) 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 }