mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Merge pull request #9 from prosolis/holdem-tips-validation
Crafting feedback loop + consumable protection + housekeeping
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ data/
|
||||
.env.*
|
||||
!.env.example
|
||||
gogobee
|
||||
gensolver
|
||||
holdem-train
|
||||
|
||||
14
README.md
14
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 <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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user