Merge pull request #9 from prosolis/holdem-tips-validation

Crafting feedback loop + consumable protection + housekeeping
This commit is contained in:
prosolis
2026-04-30 07:50:24 -07:00
committed by GitHub
9 changed files with 140 additions and 8 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ data/
.env.*
!.env.example
gogobee
gensolver
holdem-train

View File

@@ -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 T1T5, 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 <name>` — 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 <slot>` commits. Visiting the blacksmith counts as your daily action.

View File

@@ -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 {

View File

@@ -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 <slot>`" + ` — 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

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 <name>` 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 <name>` 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 <name>`.)", keptConsumable)
}
return msg
}