package game import ( "fmt" "math/rand" "sort" "strconv" "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/color" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) type recipeEntry struct { ItemName string Recipe action.RecipeDef } type productionTypeInfo struct { ActionType string DisplayVerb string } var productionTypes = map[string]productionTypeInfo{ "cooking": {"cook", "cooking"}, "smelting": {"smelt", "smelting"}, "smithing": {"smith", "smithing"}, "crafting": {"craft", "crafting"}, "combine": {"combine", "combining"}, "fletching": {"fletch", "fletching"}, "pharmacy": {"mix", "mixing"}, } var productionActionTypes map[string]bool func init() { productionActionTypes = make(map[string]bool) for _, pt := range productionTypes { productionActionTypes[pt.ActionType] = true } } func recipeMenuData(recipes []action.RecipeDef) []map[string]string { out := make([]map[string]string, len(recipes)) for i, r := range recipes { out[i] = map[string]string{"recipe_id": r.ID} } return out } func entryMenuData(entries []recipeEntry) []map[string]string { out := make([]map[string]string, len(entries)) for i, e := range entries { out[i] = map[string]string{"recipe_id": e.Recipe.ID} } return out } func (g *Game) promptHowMany(sess *net.Session, recipeID string) { sess.PendingRecipeID = recipeID sess.State = net.StateHowMany sess.Write("How many (return for all)?: ") } type recipeSelection struct { Recipe *action.RecipeDef Count int } func (g *Game) resolveRecipeByInput(input string, recipes []action.RecipeDef, lastRecipeID string) (sel recipeSelection, errMsg string) { if input == "" { if lastRecipeID == "" { return sel, "never_mind" } for i := range recipes { if recipes[i].ID == lastRecipeID { sel.Recipe = &recipes[i] return sel, "" } } return sel, "never_mind" } qty, productName := parseQty(input) productName = strings.TrimSpace(productName) if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { sel.Recipe = &recipes[idx-1] sel.Count = qty return sel, "" } matchCount := 0 for i := range recipes { outDef, _ := g.ItemStore.Load(recipes[i].Output) name := recipes[i].Output if outDef != nil { name = outDef.Name } if world.WordPrefixMatch(productName, name) { if matchCount == 0 { sel.Recipe = &recipes[i] sel.Count = qty } matchCount++ } } if matchCount > 1 { return sel, "ambiguous" } if sel.Recipe == nil { return sel, "never_mind" } return sel, "" } func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { p := sess.Player.(*player.Player) tbl := &Table{Title: title} for i, name := range names { tbl.Rows = append(tbl.Rows, []string{fmt.Sprintf("%d", i+1), name}) } unicode := p.OptionBool("unicode") sess.WriteLine("") for _, line := range tbl.Render(unicode) { sess.WriteLine(line) } } func (g *Game) handleHowMany(sess *net.Session, input string) { p := sess.Player.(*player.Player) recipeID := sess.PendingRecipeID sess.PendingRecipeID = "" sess.State = net.StateGame input = strings.TrimSpace(input) var count int if input == "" { count = 0 } else if n, err := strconv.Atoi(input); err == nil && n > 0 { count = n } else { sess.WriteLine("Never mind.") g.reprompt(sess) return } var recipe *action.RecipeDef if strings.HasPrefix(recipeID, "combine_") { itemID := strings.TrimPrefix(recipeID, "combine_") itemDef, err := g.ItemStore.Load(itemID) if err != nil || len(itemDef.MadeFrom) == 0 { g.reprompt(sess) return } recipe = g.buildCombineRecipe(itemDef) } else { all, err := g.RecipeStore.LoadAll() if err != nil { g.reprompt(sess) return } for _, r := range all { if r.ID == recipeID { recipe = &r break } } } if recipe == nil { g.reprompt(sess) return } g.startProductionFromRecipe(sess, p, recipe, count) } func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { var consume []action.ConsumeEntry for _, mf := range def.MadeFrom { consume = append(consume, action.ConsumeEntry{ Items: mf.Items, Qty: mf.Qty, Byproducts: mf.Byproducts, }) } wait := def.Ticks if wait <= 0 { wait = 2 } return &action.RecipeDef{ ID: "combine_" + def.ID, Type: "combine", Wait: wait, Consume: consume, Output: def.ID, Message: fmt.Sprintf("You create %s.", def.Name), } } func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { g.CancelAction(p) g.CancelBackgroundAction(p) skill := recipe.EffectiveSkill() if skill != "" { skillLevel := p.Level(player.SkillName(skill)) if skillLevel < recipe.Level { sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", recipe.Level, skill)) g.reprompt(sess) return } } if !recipe.HasAllItemsQty(p.CountItem) { sess.WriteLine("You don't have the required materials.") g.reprompt(sess) return } outDef, _ := g.ItemStore.Load(recipe.Output) wait := recipe.Wait if wait <= 0 { wait = 4 } outputName := recipe.Output if outDef != nil { outputName = outDef.Name } p.Action = &action.Action{ Type: actionType, TargetID: recipe.ID, TargetName: outputName, Data: map[string]any{ "recipe_id": recipe.ID, "phase": 0, "wait": wait, "start_msg": startMsg, "end_msg": endMsg, "remaining": count, }, WaitLeft: engine.ToTicks(1), } p.ActionState = &ActionState{Type: ActionProducing, TargetName: outputName, Verb: displayVerb} } func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { _, stationName := g.findStation(p.RoomID, recipe.Station) if stationName == "" { stationName = "inventory" } firstItem := recipe.FirstItemName(func(id string) (string, bool) { def, err := g.ItemStore.Load(id) if err != nil { return id, false } return def.Name, true }) info, ok := productionTypes[recipe.Type] if !ok { info = productionTypeInfo{recipe.Type, recipe.Type} } actionType := info.ActionType displayVerb := info.DisplayVerb var startMsg, endMsg string if stationName != "inventory" { startMsg = fmt.Sprintf("You start %s %s on the %s.", displayVerb, firstItem, stationName) } else { startMsg = fmt.Sprintf("You start %s %s.", displayVerb, firstItem) } endMsg = fmt.Sprintf("You've finished %s.", displayVerb) g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) } func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { all, err := g.RecipeStore.LoadAll() if err == nil { for _, r := range all { if r.ID == recipeID { return &r } } } if strings.HasPrefix(recipeID, "combine_") { itemID := strings.TrimPrefix(recipeID, "combine_") if itemDef, err := g.ItemStore.Load(itemID); err == nil && len(itemDef.MadeFrom) > 0 { return g.buildCombineRecipe(itemDef) } } return nil } func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { recipeID := p.Action.Data["recipe_id"].(string) phase := p.Action.Data["phase"].(int) wait := p.Action.Data["wait"].(float64) startMsg, _ := p.Action.Data["start_msg"].(string) endMsg, _ := p.Action.Data["end_msg"].(string) remaining, _ := p.Action.Data["remaining"].(int) recipe := g.loadProductionRecipe(recipeID) if recipe == nil { g.CancelAction(p) return false } if phase == 0 { if startMsg != "" { sess.WriteLine(fmt.Sprintf("\n%s", startMsg)) } p.Action.Data["phase"] = 1 p.Action.WaitLeft = engine.ToTicks(wait) return true } skill := recipe.EffectiveSkill() skillLevel := p.Level(player.SkillName(skill)) chance := 1.0 if recipe.Success != nil { chance = action.SuccessChance(*recipe.Success, skillLevel, recipe.Level) } if rand.Float64() < chance { outputQty := recipe.OutputQty if outputQty <= 0 { outputQty = 1 } byproducts := g.collectByproducts(p, recipe) placed := false outDef, _ := g.ItemStore.Load(recipe.Output) if outDef != nil && outDef.Stackable { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == recipe.Output { recipe.ConsumeAll(p.HasItem, p.RemoveItem) slot.Quantity += outputQty placed = true break } } } if !placed { recipe.ConsumeAll(p.HasItem, p.RemoveItem) freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is too full!") g.CancelAction(p) return false } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } for _, bp := range byproducts { if slot := p.FirstFreeSlot(); slot >= 0 { p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) } } if recipe.XP > 0 { if newLevel := p.AddSkillXP(player.SkillName(skill), recipe.XP); newLevel > 0 { sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) } } g.AccountStore.SaveCharacter(p) msg := recipe.Message if msg == "" { outputName := recipe.Output if outDef != nil { outputName = outDef.Name } msg = fmt.Sprintf("You produce %s.", outputName) } if p.OptionBool("xp_drops") && recipe.XP > 0 { abbr := player.SkillAbbr[player.SkillName(skill)] msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) } sess.WriteLine(msg) } else { recipe.ConsumeAll(p.HasItem, p.RemoveItem) if recipe.Fail != "" { freeSlot := p.FirstFreeSlot() if freeSlot >= 0 { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Fail, Quantity: 1}) } } g.AccountStore.SaveCharacter(p) msg := recipe.FailMessage if msg == "" { msg = "You fail and the materials are lost." } sess.WriteLine(g.colorize(sess, "damage", msg)) } if remaining > 0 { remaining-- p.Action.Data["remaining"] = remaining if remaining <= 0 { if endMsg != "" { sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) } g.CancelAction(p) return false } } if g.canContinueProduction(p, recipe) { p.Action.WaitLeft = engine.ToTicks(wait) return true } if endMsg != "" { sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) } g.CancelAction(p) return false } func (g *Game) collectByproducts(p *player.Player, recipe *action.RecipeDef) []string { var byproducts []string for _, e := range recipe.Consume { if len(e.Byproducts) == 0 { continue } for i, id := range e.Items { if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" { byproducts = append(byproducts, e.Byproducts[i]) break } } } return byproducts } func (g *Game) canContinueProduction(p *player.Player, recipe *action.RecipeDef) bool { if !recipe.HasAllItemsQty(p.CountItem) { return false } outputQty := recipe.OutputQty if outputQty <= 0 { outputQty = 1 } outDef, _ := g.ItemStore.Load(recipe.Output) if outDef != nil && outDef.Stackable { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == recipe.Output { return true } } } return p.FirstFreeSlot() >= 0 } func (g *Game) handleRecipeChoice(sess *net.Session, input string) { input = strings.TrimSpace(input) menuData := sess.PendingMenu if len(menuData) == 0 { sess.State = net.StateGame g.reprompt(sess) return } if input == "" { sess.PendingMenu = nil sess.State = net.StateGame sess.WriteLine("Never mind.") g.reprompt(sess) return } if choice, err := strconv.Atoi(input); err == nil { sess.PendingMenu = nil sess.State = net.StateGame if choice <= 0 || choice > len(menuData) { sess.WriteLine("Never mind.") g.reprompt(sess) return } g.dispatchMenuEntry(sess, menuData[choice-1]) return } lower := strings.ToLower(input) matchIdx := -1 for i, entry := range menuData { name := g.menuEntryName(entry) if name == "" { continue } if strings.ToLower(name) == lower || world.WordPrefixMatch(input, name) { if matchIdx >= 0 { sess.WriteLine("That's ambiguous.") return } matchIdx = i } } if matchIdx >= 0 { sess.PendingMenu = nil sess.State = net.StateGame g.dispatchMenuEntry(sess, menuData[matchIdx]) return } sess.PendingMenu = nil sess.State = net.StateGame sess.WriteLine("Never mind.") g.reprompt(sess) } func (g *Game) menuEntryName(entry map[string]string) string { if rid, ok := entry["recipe_id"]; ok { all, _ := g.RecipeStore.LoadAll() for _, r := range all { if r.ID == rid { if def, err := g.ItemStore.Load(r.Output); err == nil { return def.Name } return r.Output } } } if rid, ok := entry["fletch_recipe_id"]; ok { all, _ := g.RecipeStore.LoadAll() for _, r := range all { if r.ID == rid { if def, err := g.ItemStore.Load(r.Output); err == nil { return def.Name } return r.Output } } } if barID, ok := entry["bar_id"]; ok { if def, _ := g.ItemStore.Load(barID); def != nil { return def.Name } return barID } if cid, ok := entry["combine_item"]; ok { if def, _ := g.ItemStore.Load(cid); def != nil { return def.Name } return cid } return "" } func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { if cid, ok := entry["combine_item"]; ok { g.promptHowMany(sess, "combine_"+cid) return } if barID, ok := entry["bar_id"]; ok { p := sess.Player.(*player.Player) allRecipes, _ := g.RecipeStore.LoadAll() g.showSmithTable(sess, p, barID, allRecipes) return } if rid, ok := entry["fletch_recipe_id"]; ok { p := sess.Player.(*player.Player) allRecipes, _ := g.RecipeStore.LoadAll() for _, r := range allRecipes { if r.ID == rid { g.startFletchAction(sess, p, &r, 0) return } } g.reprompt(sess) return } if rid, ok := entry["recipe_id"]; ok { g.promptHowMany(sess, rid) return } g.reprompt(sess) } func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { var parts []string for _, e := range r.Consume { itemName := e.Items[0] if def, err := g.ItemStore.Load(e.Items[0]); err == nil { itemName = def.Name } if e.Qty > 1 { parts = append(parts, fmt.Sprintf("%s x%d", itemName, e.Qty)) } else { parts = append(parts, itemName) } } return strings.Join(parts, ", ") } func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes []action.RecipeDef, title, skill, lastFlagKey, promptVerb string, background bool) { sort.Slice(recipes, func(i, j int) bool { return recipes[i].Level < recipes[j].Level }) mode := g.colorMode(sess) skillLevel := p.Level(player.SkillName(skill)) dimSpec := color.Parse("240") tbl := &Table{ Title: title, Columns: []string{"#", "Product", "Materials", "Level"}, } for i, r := range recipes { outDef, _ := g.ItemStore.Load(r.Output) productName := r.Output if outDef != nil { productName = outDef.Name } outputQty := r.OutputQty if outputQty <= 0 { outputQty = 1 } if outDef != nil && outDef.Stackable && outputQty > 1 { productName = fmt.Sprintf("%s x%d", productName, outputQty) } matStr := g.formatRecipeMaterials(&r) levelStr := fmt.Sprint(r.Level) numStr := fmt.Sprintf("%d", i+1) canMake := skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) if canMake { productName = g.itemColorize(sess, outDef, productName) } else { productName = color.Render(mode, dimSpec, productName) matStr = color.Render(mode, dimSpec, matStr) levelStr = color.Render(mode, dimSpec, levelStr) numStr = color.Render(mode, dimSpec, numStr) } tbl.Rows = append(tbl.Rows, []string{numStr, productName, matStr, levelStr}) } unicode := p.OptionBool("unicode") sess.WriteLine("") for _, line := range tbl.Render(unicode) { sess.WriteLine(line) } lastRecipeID, _ := p.Flags[lastFlagKey].(string) hint := "" if lastRecipeID != "" { for _, r := range recipes { if r.ID == lastRecipeID { if outDef, err := g.ItemStore.Load(r.Output); err == nil { hint = outDef.Name } break } } } if hint != "" { sess.Write(fmt.Sprintf("%s what (enter for all %s): ", promptVerb, hint)) } else { sess.Write(fmt.Sprintf("%s what: ", promptVerb)) } sess.PendingMenu = recipeMenuData(recipes) sess.PendingSkill = skill sess.PendingLastFlag = lastFlagKey sess.PendingBackground = background sess.State = net.StateProductChoice } func (g *Game) handleProductChoice(sess *net.Session, input string) { p := sess.Player.(*player.Player) menuData := sess.PendingMenu skill := sess.PendingSkill lastFlagKey := sess.PendingLastFlag background := sess.PendingBackground sess.PendingMenu = nil sess.PendingSkill = "" sess.PendingLastFlag = "" sess.PendingBackground = false sess.State = net.StateGame input = strings.TrimSpace(input) allRecipes, err := g.RecipeStore.LoadAll() if err != nil { g.reprompt(sess) return } var recipes []action.RecipeDef for _, entry := range menuData { rid := entry["recipe_id"] for _, r := range allRecipes { if r.ID == rid { recipes = append(recipes, r) break } } } if len(recipes) == 0 { g.reprompt(sess) return } sort.Slice(recipes, func(i, j int) bool { return recipes[i].Level < recipes[j].Level }) lastRecipeID, _ := p.Flags[lastFlagKey].(string) sel, errMsg := g.resolveRecipeByInput(input, recipes, lastRecipeID) switch errMsg { case "ambiguous": sess.WriteLine("That's ambiguous.") g.reprompt(sess) return case "never_mind": sess.WriteLine("Never mind.") g.reprompt(sess) return } skillLevel := p.Level(player.SkillName(skill)) if skillLevel < sel.Recipe.Level { sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", sel.Recipe.Level, skill)) g.reprompt(sess) return } if !sel.Recipe.HasAllItemsQty(p.CountItem) { sess.WriteLine("You don't have the materials for that.") g.reprompt(sess) return } if p.Flags == nil { p.Flags = make(map[string]any) } p.Flags[lastFlagKey] = sel.Recipe.ID g.AccountStore.SaveCharacter(p) if background { g.startFletchAction(sess, p, sel.Recipe, sel.Count) } else { g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) } } func (g *Game) hasToolType(p *player.Player, toolType string) bool { if itemID, ok := p.Equipment[object.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { return true } } for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot == nil { continue } if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { return true } } return false }