diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/action/behavior.go | 1 | ||||
| -rw-r--r-- | internal/game/action.go | 23 | ||||
| -rw-r--r-- | internal/game/action_burn.go | 2 | ||||
| -rw-r--r-- | internal/game/action_fletch.go | 215 | ||||
| -rw-r--r-- | internal/game/action_gather.go | 3 | ||||
| -rw-r--r-- | internal/game/action_production.go | 53 | ||||
| -rw-r--r-- | internal/game/action_state.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 1 | ||||
| -rw-r--r-- | internal/game/cmd_cook.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_equipment.go | 7 | ||||
| -rw-r--r-- | internal/game/cmd_fletch.go | 383 | ||||
| -rw-r--r-- | internal/game/cmd_remove.go | 102 | ||||
| -rw-r--r-- | internal/game/cmd_score.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_smelt.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_smith.go | 103 | ||||
| -rw-r--r-- | internal/game/cmd_use.go | 19 | ||||
| -rw-r--r-- | internal/game/cmd_wear.go | 106 | ||||
| -rw-r--r-- | internal/game/game.go | 17 | ||||
| -rw-r--r-- | internal/net/server.go | 1 | ||||
| -rw-r--r-- | internal/player/player.go | 7 |
20 files changed, 978 insertions, 82 deletions
diff --git a/internal/action/behavior.go b/internal/action/behavior.go index c7ed0c4..67baab0 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -14,7 +14,6 @@ type GatherConfig struct { FailMsg string `yaml:"fail_message"` Drops []DropEntry `yaml:"drops"` RespawnTimer float64 `yaml:"respawn_timer"` - RespawnMsg string `yaml:"respawn_message"` RespawnBroadcast string `yaml:"respawn_broadcast"` DepleteTimer float64 `yaml:"deplete_timer"` NestChance int `yaml:"nest_chance"` diff --git a/internal/game/action.go b/internal/game/action.go index a62ccec..3d0223a 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -53,6 +53,7 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { if p.Action != nil { g.CancelAction(p) } + g.CancelBackgroundAction(p) lower := strings.ToLower(target) instanceIdx := -1 @@ -202,6 +203,11 @@ func (g *Game) CancelAction(p *player.Player) { p.ClearMoveState() } +func (g *Game) CancelBackgroundAction(p *player.Player) { + p.BackgroundAction = nil + p.BackgroundActionState = nil +} + func (g *Game) AdvanceActions() { if g.Hub == nil { return @@ -238,6 +244,23 @@ func (g *Game) AdvanceActions() { g.writePrompt(sess) } } + + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p.BackgroundAction == nil { + continue + } + if !p.BackgroundAction.Advance() { + continue + } + switch p.BackgroundAction.Type { + case "fletch": + g.advanceFletch(sess, p) + } + if p.BackgroundAction == nil { + g.writePrompt(sess) + } + } } func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 01bf385..0b7db2c 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -15,6 +15,7 @@ func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { if p.Action != nil { g.CancelAction(p) } + g.CancelBackgroundAction(p) itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) if ambiguous { @@ -33,6 +34,7 @@ func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { if p.Action != nil { g.CancelAction(p) } + g.CancelBackgroundAction(p) if !g.hasFireObject(p.RoomID) { sess.WriteLine("There's no fire here to stoke.") diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go new file mode 100644 index 0000000..734453b --- /dev/null +++ b/internal/game/action_fletch.go @@ -0,0 +1,215 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) startFletchAction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags["last_fletch"] = recipe.ID + g.AccountStore.SaveCharacter(p) + + if recipe.Wait <= 0 { + g.doInstantFletch(sess, p, recipe, count) + return + } + + g.startTimedFletch(sess, p, recipe, count) +} + +func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + outDef, _ := g.ItemStore.Load(recipe.Output) + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + batches := 0 + totalOutput := 0 + for { + if !g.canDoFletchRecipe(p, recipe) { + break + } + + placed := false + 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 { + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) + } + + if recipe.XP > 0 { + if newLevel := p.AddSkillXP(player.Fletching, recipe.XP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d fletching! ***", newLevel))) + } + } + + batches++ + totalOutput += outputQty + + if count > 0 { + count-- + if count <= 0 { + break + } + } + } + + if batches == 0 { + sess.WriteLine("You don't have the materials for that.") + return + } + + g.AccountStore.SaveCharacter(p) + + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + + msg := fmt.Sprintf("You fletch %d %s.", totalOutput, outputName) + if p.OptionBool("xp_drops") && recipe.XP > 0 { + totalXP := recipe.XP * batches + abbr := player.SkillAbbr[player.Fletching] + msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", totalXP, abbr)) + } + sess.WriteLine(msg) +} + +func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + outDef, _ := g.ItemStore.Load(recipe.Output) + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + + p.BackgroundAction = &action.Action{ + Type: "fletch", + TargetID: recipe.ID, + TargetName: outputName, + Data: map[string]any{ + "recipe_id": recipe.ID, + "phase": 0, + "wait": recipe.Wait, + "remaining": count, + }, + WaitLeft: engine.ToTicks(1), + } + p.BackgroundActionState = &ActionState{Type: ActionFletching, TargetName: outputName} + + sess.WriteLine(fmt.Sprintf("\nYou begin fletching %s.", outputName)) +} + +func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { + recipeID, _ := p.BackgroundAction.Data["recipe_id"].(string) + phase, _ := p.BackgroundAction.Data["phase"].(int) + wait, _ := p.BackgroundAction.Data["wait"].(float64) + remaining, _ := p.BackgroundAction.Data["remaining"].(int) + + recipe := g.loadProductionRecipe(recipeID) + if recipe == nil { + g.CancelBackgroundAction(p) + return + } + + if phase == 0 { + p.BackgroundAction.Data["phase"] = 1 + p.BackgroundAction.WaitLeft = engine.ToTicks(wait) + return + } + + if !g.canDoFletchRecipe(p, recipe) { + sess.WriteLine("\nYou've run out of fletching materials.") + g.CancelBackgroundAction(p) + return + } + + outDef, _ := g.ItemStore.Load(recipe.Output) + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + placed := false + 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("\nYour inventory is too full!") + g.CancelBackgroundAction(p) + return + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) + } + + if recipe.XP > 0 { + if newLevel := p.AddSkillXP(player.Fletching, recipe.XP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d fletching! ***", newLevel))) + } + } + g.AccountStore.SaveCharacter(p) + + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + msg := recipe.Message + if msg == "" { + msg = fmt.Sprintf("You fletch %s.", outputName) + } + if p.OptionBool("xp_drops") && recipe.XP > 0 { + abbr := player.SkillAbbr[player.Fletching] + msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) + } + sess.WriteLine(msg) + + if remaining > 0 { + remaining-- + p.BackgroundAction.Data["remaining"] = remaining + if remaining <= 0 { + sess.WriteLine("\nYou've finished fletching.") + g.CancelBackgroundAction(p) + return + } + } + + if !g.canContinueProduction(p, recipe) { + sess.WriteLine("\nYou've run out of fletching materials.") + g.CancelBackgroundAction(p) + return + } + + p.BackgroundAction.WaitLeft = engine.ToTicks(wait) +} diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 045c51b..ffd30e1 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -203,9 +203,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { p.Action.WaitLeft = engine.ToTicks(3) return } - if cfg.RespawnMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) - } p.Action.Data["step"] = 0 p.Action.WaitLeft = engine.ToTicks(wait) return diff --git a/internal/game/action_production.go b/internal/game/action_production.go index 0b8ca45..d4e2274 100644 --- a/internal/game/action_production.go +++ b/internal/game/action_production.go @@ -41,6 +41,58 @@ func (g *Game) promptHowMany(sess *net.Session, recipeID string) { 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} @@ -129,6 +181,7 @@ func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { 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) if recipe.Skill != "" { skillLevel := p.Level(player.SkillName(recipe.Skill)) diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 26c22f3..4510afd 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -20,6 +20,7 @@ const ( ActionResting ActionType = "resting" ActionWalking ActionType = "walking" ActionProducing ActionType = "producing" + ActionFletching ActionType = "fletching" ActionEating ActionType = "eating" ) @@ -68,6 +69,8 @@ func (a *ActionState) Description() string { return "walking somewhere with a purpose!" case ActionProducing: return a.Verb + " some " + a.TargetName + case ActionFletching: + return "fletching " + a.TargetName case ActionEating: return "eating " + a.TargetName } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index d7fb365..51ac54a 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -111,6 +111,7 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { g.cancelRest(p.Name) + g.CancelBackgroundAction(p) combat.EnterCombat(p.Name, mob.InstanceID) diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go index b27b293..9b76a84 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -102,6 +102,10 @@ func (g *Game) showCookMenu(sess *net.Session, p *player.Player, allRecipes []ac } if len(entries) == 1 { + if p.OptionBool("cook_all") { + g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) + return + } g.promptHowMany(sess, entries[0].Recipe.ID) return } diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go index 7113880..b256bc3 100644 --- a/internal/game/cmd_equipment.go +++ b/internal/game/cmd_equipment.go @@ -4,6 +4,7 @@ import ( "fmt" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -23,6 +24,10 @@ func (g *Game) doEquipment(sess *net.Session) { if err == nil { name = def.Name } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, def, name))) + if slot == object.SlotAmmo && p.AmmoQty > 0 { + sess.WriteLine(fmt.Sprintf(" %-12s %s (x%d)", slot, g.itemColorize(sess, def, name), p.AmmoQty)) + } else { + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, def, name))) + } } } diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go new file mode 100644 index 0000000..fe8c0b3 --- /dev/null +++ b/internal/game/cmd_fletch.go @@ -0,0 +1,383 @@ +package game + +import ( + "fmt" + "sort" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +var fletchLogItems = map[string]bool{ + "logs": true, "oak_logs": true, "willow_logs": true, + "maple_logs": true, "yew_logs": true, "magic_logs": true, +} + +var fletchGemItems = map[string]bool{ + "uncut_sapphire": true, "uncut_emerald": true, + "uncut_ruby": true, "uncut_diamond": true, +} + +func fletchNeedsKnife(r *action.RecipeDef) bool { + for _, e := range r.Consume { + for _, id := range e.Items { + if fletchLogItems[id] { + if strings.Contains(r.Output, "shaft") || strings.Contains(r.Output, "bow") { + return true + } + } + } + } + return false +} + +func fletchNeedsChisel(r *action.RecipeDef) bool { + for _, e := range r.Consume { + for _, id := range e.Items { + if fletchGemItems[id] { + return true + } + } + } + return false +} + +func (g *Game) doFletch(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if p.Action != nil { + p.Action = nil + p.ActionState = nil + } + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + sess.WriteLine("Error loading recipes.") + return + } + + var fletchRecipes []action.RecipeDef + for _, r := range allRecipes { + if r.Type != "fletching" { + continue + } + fletchRecipes = append(fletchRecipes, r) + } + + if input != "" { + g.doFletchWithInput(sess, p, fletchRecipes, input) + return + } + + lastRecipeID, _ := p.Flags["last_fletch"].(string) + if lastRecipeID != "" { + for i := range fletchRecipes { + if fletchRecipes[i].ID == lastRecipeID { + r := &fletchRecipes[i] + if g.canDoFletchRecipe(p, r) { + g.startFletchAction(sess, p, r, 0) + return + } + break + } + } + } + + available := g.availableFletchRecipes(p, fletchRecipes) + if len(available) == 0 { + sess.WriteLine("You don't have any materials to fletch.") + return + } + + if p.OptionBool("fletch_all") && len(available) == 1 { + g.startFletchAction(sess, p, &available[0], 0) + return + } + + g.showFletchTable(sess, p, available) +} + +func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, fletchRecipes []action.RecipeDef, input string) { + qty, productName := parseQty(input) + productName = strings.TrimSpace(productName) + + var matched []action.RecipeDef + for _, r := range fletchRecipes { + outDef, _ := g.ItemStore.Load(r.Output) + name := r.Output + if outDef != nil { + name = outDef.Name + } + if world.WordPrefixMatch(productName, name) { + matched = append(matched, r) + } + } + + if len(matched) == 0 { + sess.WriteLine("You can't fletch that.") + return + } + + var available []action.RecipeDef + for _, r := range matched { + if g.canDoFletchRecipe(p, &r) { + available = append(available, r) + } + } + + if len(available) == 0 { + sess.WriteLine("You don't have the materials for that.") + return + } + + if len(available) == 1 { + count := qty + g.startFletchAction(sess, p, &available[0], count) + return + } + + g.showFletchTable(sess, p, available) +} + +func (g *Game) hasFletchTools(p *player.Player, r *action.RecipeDef) bool { + if fletchNeedsKnife(r) && !g.hasToolType(p, "knife") { + return false + } + if fletchNeedsChisel(r) && !g.hasToolType(p, "chisel") { + return false + } + return true +} + +func (g *Game) canDoFletchRecipe(p *player.Player, r *action.RecipeDef) bool { + return p.Level(player.Fletching) >= r.Level && + r.HasAllItemsQty(p.CountItem) && + g.hasFletchTools(p, r) +} + +func (g *Game) availableFletchRecipes(p *player.Player, allFletch []action.RecipeDef) []action.RecipeDef { + var result []action.RecipeDef + for _, r := range allFletch { + if r.HasAllItemsQty(p.CountItem) && g.hasFletchTools(p, &r) { + result = append(result, r) + } + } + return result +} + +func (g *Game) showFletchTable(sess *net.Session, p *player.Player, available []action.RecipeDef) { + sort.Slice(available, func(i, j int) bool { + return available[i].Level < available[j].Level + }) + + mode := g.colorMode(sess) + skillLevel := p.Level(player.Fletching) + dimSpec := color.Parse("240") + + tbl := &Table{ + Title: "Fletching Actions", + Columns: []string{"#", "Product", "Materials", "Level"}, + } + + for i, r := range available { + 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) + } + + var matParts []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 { + matParts = append(matParts, fmt.Sprintf("%s x%d", itemName, e.Qty)) + } else { + matParts = append(matParts, itemName) + } + } + matStr := strings.Join(matParts, ", ") + levelStr := fmt.Sprint(r.Level) + numStr := fmt.Sprintf("%d", i+1) + + canMake := skillLevel >= r.Level + 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["last_fletch"].(string) + hint := "" + if lastRecipeID != "" { + for _, r := range available { + if r.ID == lastRecipeID { + if outDef, err := g.ItemStore.Load(r.Output); err == nil { + hint = outDef.Name + } + break + } + } + } + + if hint != "" { + sess.Write(fmt.Sprintf("Fletch what (enter for all %s): ", hint)) + } else { + sess.Write("Fletch what: ") + } + + sess.PendingMenu = recipeMenuData(available) + sess.State = net.StateFletchProduct +} + +func (g *Game) handleFletchProduct(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + menuData := sess.PendingMenu + sess.PendingMenu = nil + 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["last_fletch"].(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 + } + + if !g.canDoFletchRecipe(p, sel.Recipe) { + if p.Level(player.Fletching) < sel.Recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d fletching to make that.", sel.Recipe.Level)) + } else { + sess.WriteLine("You don't have the materials for that.") + } + g.reprompt(sess) + return + } + + g.startFletchAction(sess, p, sel.Recipe, sel.Count) +} + +func (g *Game) findFletchRecipesForItems(p *player.Player, itemAID, itemBID string) []action.RecipeDef { + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + return nil + } + + toolTypeA := "" + toolTypeB := "" + if defA, _ := g.ItemStore.Load(itemAID); defA != nil { + toolTypeA = defA.ToolType + } + if defB, _ := g.ItemStore.Load(itemBID); defB != nil { + toolTypeB = defB.ToolType + } + isToolA := toolTypeA == "knife" || toolTypeA == "chisel" + isToolB := toolTypeB == "knife" || toolTypeB == "chisel" + + var matched []action.RecipeDef + for _, r := range allRecipes { + if r.Type != "fletching" { + continue + } + + aMatch := false + bMatch := false + for _, e := range r.Consume { + for _, id := range e.Items { + if id == itemAID { + aMatch = true + } + if id == itemBID { + bMatch = true + } + } + } + + if aMatch && bMatch { + matched = append(matched, r) + continue + } + + if isToolA && bMatch { + if (fletchNeedsKnife(&r) && toolTypeA == "knife") || + (fletchNeedsChisel(&r) && toolTypeA == "chisel") { + matched = append(matched, r) + continue + } + } + if isToolB && aMatch { + if (fletchNeedsKnife(&r) && toolTypeB == "knife") || + (fletchNeedsChisel(&r) && toolTypeB == "chisel") { + matched = append(matched, r) + continue + } + } + } + return matched +} diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index 8f1a413..d36a8fd 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -24,6 +24,8 @@ func (g *Game) doRemove(sess *net.Session, input string) { g.CancelAction(p) + qty, itemName := parseQty(input) + var foundSlot object.EquipSlot var foundItemID string @@ -32,7 +34,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { if !ok { continue } - if strings.ToLower(string(slot)) == lower { + if strings.ToLower(string(slot)) == strings.ToLower(itemName) { foundSlot = slot foundItemID = itemID break @@ -41,7 +43,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { if err != nil { continue } - if def.MatchesName(input) { + if def.MatchesName(itemName) { foundSlot = slot foundItemID = itemID break @@ -53,6 +55,11 @@ func (g *Game) doRemove(sess *net.Session, input string) { return } + if foundSlot == object.SlotAmmo && p.AmmoQty > 0 { + g.removeAmmo(sess, p, qty) + return + } + freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Nowhere to put that!") @@ -79,6 +86,55 @@ func (g *Game) doRemove(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name))) } +func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { + ammoID := p.Equipment[object.SlotAmmo] + removeQty := p.AmmoQty + if qty > 0 && qty < removeQty { + removeQty = qty + } + + for i := 0; i < 28; i++ { + s := p.InvSlot(i) + if s != nil && s.ItemID == ammoID { + s.Quantity += removeQty + p.AmmoQty -= removeQty + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + } + g.AccountStore.SaveCharacter(p) + def, _ := g.ItemStore.Load(ammoID) + name := ammoID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You unequip %s (x%d).", g.itemColorize(sess, def, name), removeQty)) + return + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Nowhere to put that!") + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: removeQty}) + p.AmmoQty -= removeQty + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + } + g.AccountStore.SaveCharacter(p) + + def, _ := g.ItemStore.Load(ammoID) + name := ammoID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You unequip %s (x%d).", g.itemColorize(sess, def, name), removeQty)) +} + func (g *Game) doRemoveAll(sess *net.Session) { p := sess.Player.(*player.Player) @@ -87,7 +143,12 @@ func (g *Game) doRemoveAll(sess *net.Session) { return } - if len(p.Equipment) > p.FreeSlots() { + needed := len(p.Equipment) + if _, hasAmmo := p.Equipment[object.SlotAmmo]; hasAmmo { + needed-- + needed++ + } + if needed > p.FreeSlots() { sess.WriteLine("You don't have room to remove everything!") return } @@ -100,6 +161,11 @@ func (g *Game) doRemoveAll(sess *net.Session) { continue } + if slot == object.SlotAmmo && p.AmmoQty > 0 { + g.removeAllAmmo(sess, p, itemID) + continue + } + freeSlot := p.FirstFreeSlot() delete(p.Equipment, slot) invSlot := &player.InventorySlot{ItemID: itemID, Quantity: 1} @@ -122,3 +188,33 @@ func (g *Game) doRemoveAll(sess *net.Session) { g.AccountStore.SaveCharacter(p) } + +func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) { + for i := 0; i < 28; i++ { + s := p.InvSlot(i) + if s != nil && s.ItemID == itemID { + s.Quantity += p.AmmoQty + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name))) + return + } + } + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: p.AmmoQty}) + } + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name))) +} diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 40d3007..c31433b 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -22,8 +22,10 @@ func (g *Game) doScore(sess *net.Session) { t := &Table{Title: "Skills", Columns: []string{ color.Render(mode, color.Parse("75"), "Skill"), + color.Render(mode, color.Parse("245"), "Abbr"), color.Render(mode, color.Parse("230"), "Level"), color.Render(mode, color.Parse("222"), "XP"), + color.Render(mode, color.Parse("179"), "XP to Next"), }} for _, s := range player.AllSkills { level := p.Level(s) @@ -31,8 +33,10 @@ func (g *Game) doScore(sess *net.Session) { next := player.XPForNextLevel(xp) t.Rows = append(t.Rows, []string{ color.Render(mode, color.Parse("75"), string(s)), + color.Render(mode, color.Parse("245"), player.SkillAbbr[s]), color.Render(mode, color.Parse("230"), strconv.Itoa(level)), - color.Render(mode, color.Parse("222"), fmt.Sprintf("%d / %d XP", xp, xp+next)), + color.Render(mode, color.Parse("222"), strconv.Itoa(xp)), + color.Render(mode, color.Parse("179"), strconv.Itoa(next)), }) } for _, line := range t.Render(p.OptionBool("unicode")) { diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 1cd5436..a5bbc06 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -94,6 +94,10 @@ func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, allRecipes []a } if len(entries) == 1 { + if p.OptionBool("smelt_all") { + g.startProductionFromRecipe(sess, p, &entries[0].Recipe, 0) + return + } g.promptHowMany(sess, entries[0].Recipe.ID) return } diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index 7fc14d2..f2787a5 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -3,7 +3,6 @@ package game import ( "fmt" "sort" - "strconv" "strings" "thehouseoficarus/internal/action" @@ -11,7 +10,6 @@ import ( "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) doSmith(sess *net.Session, input string) { @@ -62,6 +60,12 @@ func (g *Game) doSmith(sess *net.Session, input string) { } if len(barTypes) == 1 { + if p.OptionBool("smith_all") { + if recipe := g.findSingleSmithRecipe(p, barTypes[0], allRecipes); recipe != nil { + g.startProductionFromRecipe(sess, p, recipe, 0) + return + } + } g.showSmithTable(sess, p, barTypes[0], allRecipes) return } @@ -203,72 +207,28 @@ func (g *Game) handleSmithProduct(sess *net.Session, input string) { return recipes[i].Level < recipes[j].Level }) - var targetRecipe *action.RecipeDef - var count int - - if input == "" { - lastFlag := fmt.Sprintf("last_smith_%s", barID) - lastRecipeID, _ := p.Flags[lastFlag].(string) - if lastRecipeID == "" { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - for i := range recipes { - if recipes[i].ID == lastRecipeID { - targetRecipe = &recipes[i] - break - } - } - if targetRecipe == nil { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - count = 0 - } else { - qty, productName := parseQty(input) - count = qty - productName = strings.TrimSpace(productName) - - if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { - targetRecipe = &recipes[idx-1] - } else { - 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 { - targetRecipe = &recipes[i] - } - matchCount++ - } - } - if matchCount > 1 { - sess.WriteLine("That's ambiguous.") - g.reprompt(sess) - return - } - } + lastFlag := fmt.Sprintf("last_smith_%s", barID) + lastRecipeID, _ := p.Flags[lastFlag].(string) - if targetRecipe == nil { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } + 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.Smithing) - if skillLevel < targetRecipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d smithing to make that.", targetRecipe.Level)) + if skillLevel < sel.Recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d smithing to make that.", sel.Recipe.Level)) g.reprompt(sess) return } - if !targetRecipe.HasAllItemsQty(p.CountItem) { + if !sel.Recipe.HasAllItemsQty(p.CountItem) { sess.WriteLine("You don't have enough bars.") g.reprompt(sess) return @@ -277,10 +237,10 @@ func (g *Game) handleSmithProduct(sess *net.Session, input string) { if p.Flags == nil { p.Flags = make(map[string]any) } - p.Flags[fmt.Sprintf("last_smith_%s", barID)] = targetRecipe.ID + p.Flags[lastFlag] = sel.Recipe.ID g.AccountStore.SaveCharacter(p) - g.startProductionFromRecipe(sess, p, targetRecipe, count) + g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) } func (g *Game) hasToolType(p *player.Player, toolType string) bool { @@ -355,6 +315,23 @@ func barMenuData(barIDs []string) []map[string]string { return out } +func (g *Game) findSingleSmithRecipe(p *player.Player, barID string, allRecipes []action.RecipeDef) *action.RecipeDef { + var makeable []*action.RecipeDef + skillLevel := p.Level(player.Smithing) + for i, r := range allRecipes { + if r.Type != "smithing" || !r.MatchesEntry(barID) { + continue + } + if skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) { + makeable = append(makeable, &allRecipes[i]) + } + } + if len(makeable) == 1 { + return makeable[0] + } + return nil +} + func titleCase(s string) string { words := strings.Fields(s) for i, w := range words { diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 7c7bca0..8b8f43e 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" @@ -170,6 +171,24 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, itemBID := matchesB[0].ID + fletchRecipes := g.findFletchRecipesForItems(p, itemAID, itemBID) + if len(fletchRecipes) > 0 { + var available []action.RecipeDef + for _, r := range fletchRecipes { + if g.canDoFletchRecipe(p, &r) { + available = append(available, r) + } + } + if len(available) == 1 { + g.startFletchAction(sess, p, &available[0], 0) + return + } + if len(available) > 1 { + g.showFletchTable(sess, p, available) + return + } + } + matched := g.findCombineResults(sess, p, itemAID, itemBID) if len(matched) == 1 { g.promptHowMany(sess, "combine_"+matched[0].ID) diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 8910e75..ddc970b 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -25,9 +25,11 @@ func (g *Game) doWear(sess *net.Session, input string) { g.CancelAction(p) - matches := g.findInventoryMatches(input, p) + qty, itemName := parseQty(input) + + matches := g.findInventoryMatches(itemName, p) if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", itemName)) return } @@ -52,6 +54,11 @@ func (g *Game) doWear(sess *net.Session, input string) { return } + if def.EquipSlot == object.SlotAmmo && def.Stackable { + g.equipAmmo(sess, p, match.Slot, slot, def, qty) + return + } + existingItemID, slotOccupied := p.Equipment[def.EquipSlot] if slotOccupied { @@ -88,13 +95,64 @@ func (g *Game) doWear(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf("You %s %s.", verb, g.itemColorize(sess, def, match.Name))) } +func (g *Game) equipAmmo(sess *net.Session, p *player.Player, invIdx int, slot *player.InventorySlot, def *object.ItemDef, qty int) { + transferQty := slot.Quantity + if qty > 0 && qty < transferQty { + transferQty = qty + } + + existingAmmoID, hasAmmo := p.Equipment[object.SlotAmmo] + if hasAmmo && existingAmmoID != slot.ItemID { + g.unequipAmmo(p) + } + + if slot.Quantity <= transferQty { + p.SetInvSlot(invIdx, nil) + } else { + slot.Quantity -= transferQty + } + + if hasAmmo && existingAmmoID == slot.ItemID { + p.AmmoQty += transferQty + } else { + p.Equipment[object.SlotAmmo] = slot.ItemID + p.AmmoQty = transferQty + } + + g.AccountStore.SaveCharacter(p) + displayName := g.itemColorize(sess, def, def.Name) + sess.WriteLine(fmt.Sprintf("You equip %s (x%d).", displayName, p.AmmoQty)) +} + +func (g *Game) unequipAmmo(p *player.Player) { + ammoID, ok := p.Equipment[object.SlotAmmo] + if !ok || p.AmmoQty <= 0 { + return + } + for i := 0; i < 28; i++ { + s := p.InvSlot(i) + if s != nil && s.ItemID == ammoID { + s.Quantity += p.AmmoQty + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + return + } + } + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: ammoID, Quantity: p.AmmoQty}) + } + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 +} + func (g *Game) doWearAll(sess *net.Session) { p := sess.Player.(*player.Player) g.CancelAction(p) bestPerSlot := make(map[object.EquipSlot]struct { - itemID string - value int + itemID string + value int invSlot int }) @@ -108,12 +166,16 @@ func (g *Game) doWearAll(sess *net.Session) { continue } current, exists := bestPerSlot[def.EquipSlot] - if !exists || def.Value > current.value { + val := def.Value + if def.EquipSlot == object.SlotAmmo && def.Stackable { + val = def.Value * slot.Quantity + } + if !exists || val > current.value { bestPerSlot[def.EquipSlot] = struct { - itemID string - value int + itemID string + value int invSlot int - }{slot.ItemID, def.Value, i} + }{slot.ItemID, val, i} } } @@ -125,7 +187,33 @@ func (g *Game) doWearAll(sess *net.Session) { var equipped []string for eqSlot, best := range bestPerSlot { if existing, ok := p.Equipment[eqSlot]; ok && existing == best.itemID { - continue + if eqSlot != object.SlotAmmo { + continue + } + } + + if eqSlot == object.SlotAmmo { + bestDef, _ := g.ItemStore.Load(best.itemID) + if bestDef != nil && bestDef.Stackable { + invSlot := p.InvSlot(best.invSlot) + if invSlot == nil { + continue + } + existingID, hasAmmo := p.Equipment[object.SlotAmmo] + if hasAmmo && existingID != best.itemID { + g.unequipAmmo(p) + } + transferQty := invSlot.Quantity + p.SetInvSlot(best.invSlot, nil) + if hasAmmo && existingID == best.itemID { + p.AmmoQty += transferQty + } else { + p.Equipment[object.SlotAmmo] = best.itemID + p.AmmoQty = transferQty + } + equipped = append(equipped, g.itemColorize(sess, bestDef, bestDef.Name)) + continue + } } if existing, ok := p.Equipment[eqSlot]; ok { diff --git a/internal/game/game.go b/internal/game/game.go index 87539b1..ef81fca 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -128,6 +128,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleHowMany(sess, input) case net.StateSmithProduct: g.handleSmithProduct(sess, input) + case net.StateFletchProduct: + g.handleFletchProduct(sess, input) case net.StateColorChoice: g.handleColorChoice(sess, input) } @@ -149,7 +151,7 @@ func classifyCommand(cmd string) CommandClass { "west", "w", "up", "u", "down", "d", "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith": return ClassActive - case "eat": + case "eat", "fletch": return ClassFree } if _, ok := verbAliases[cmd]; ok { @@ -379,6 +381,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI case "eat": g.doEat(sess, strings.Join(args, " ")) return + case "fletch": + g.doFletch(sess, strings.Join(args, " ")) + return case "talk", "speak", "ask": g.CancelAction(p) if len(args) == 0 { @@ -477,6 +482,16 @@ func (g *Game) ProcessQueuedCommands() { if !ok || p == nil { continue } + if p.BackgroundAction == nil { + p.BackgroundActionState = nil + } + } + + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil { + continue + } cmds := g.freeQueue[p.Name] for _, qc := range cmds { g.cancelRest(p.Name) diff --git a/internal/net/server.go b/internal/net/server.go index bb14b04..e74f15f 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -34,6 +34,7 @@ const ( StateRecipeChoice StateHowMany StateSmithProduct + StateFletchProduct StateColorChoice ) diff --git a/internal/player/player.go b/internal/player/player.go index e7d98ac..edfe5dd 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -127,6 +127,10 @@ var OptionDefs = []OptionDef{ {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"}, {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"}, {"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"}, + {"fletch_all", OptBool, false, nil, "Auto-start fletching when only one product is possible"}, + {"smith_all", OptBool, false, nil, "Auto-start smithing when only one product is possible"}, + {"cook_all", OptBool, false, nil, "Auto-start cooking when only one product is possible"}, + {"smelt_all", OptBool, false, nil, "Auto-start smelting when only one product is possible"}, } var optionByName map[string]*OptionDef @@ -157,8 +161,11 @@ type Player struct { Options map[string]any `yaml:"-"` Flags map[string]any `yaml:"flags"` RegenerateTick int + AmmoQty int `yaml:"ammo_qty,omitempty"` Action *action.Action `yaml:"-"` ActionState any `yaml:"-"` + BackgroundAction *action.Action `yaml:"-"` + BackgroundActionState any `yaml:"-"` WalkSequence []string `yaml:"-"` ConsumeCooldown int `yaml:"-"` MoveTicks int `yaml:"-"` |
