From 0ea4eec5dd2122c6704216971eb1249276297867 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 18:20:26 -0400 Subject: shop fixes, 'toggle' completely removed, movement speed increased --- internal/game/action.go | 56 +- internal/game/action_agility.go | 18 +- internal/game/action_burn.go | 40 +- internal/game/action_clean.go | 11 +- internal/game/action_default_target.go | 14 +- internal/game/action_farm.go | 68 +-- internal/game/action_finishing_blow.go | 78 +++ internal/game/action_fletch.go | 22 +- internal/game/action_gather.go | 114 ++-- internal/game/action_identify.go | 11 +- internal/game/action_production.go | 798 ---------------------------- internal/game/action_room.go | 8 +- internal/game/action_search.go | 14 +- internal/game/action_state.go | 11 - internal/game/action_steal.go | 67 ++- internal/game/action_talk.go | 127 +---- internal/game/action_toggle.go | 48 -- internal/game/action_use.go | 25 +- internal/game/aggro.go | 3 +- internal/game/assassin.go | 112 +--- internal/game/buff.go | 2 +- internal/game/cmd_agility.go | 6 +- internal/game/cmd_attack.go | 394 ++++++-------- internal/game/cmd_autocast.go | 54 -- internal/game/cmd_autotrigger.go | 54 ++ internal/game/cmd_bank.go | 14 +- internal/game/cmd_clean.go | 4 +- internal/game/cmd_color.go | 3 +- internal/game/cmd_colortable.go | 3 +- internal/game/cmd_construct.go | 157 ------ internal/game/cmd_cook.go | 12 +- internal/game/cmd_craft.go | 78 +-- internal/game/cmd_description.go | 5 +- internal/game/cmd_drink.go | 4 +- internal/game/cmd_drop.go | 19 +- internal/game/cmd_eat.go | 4 +- internal/game/cmd_equipment.go | 3 +- internal/game/cmd_estate_directory.go | 27 +- internal/game/cmd_farm.go | 14 +- internal/game/cmd_fletch.go | 5 +- internal/game/cmd_get.go | 12 +- internal/game/cmd_handlers.go | 370 +++++++++++++ internal/game/cmd_id.go | 4 +- internal/game/cmd_inventory.go | 3 +- internal/game/cmd_jack.go | 11 +- internal/game/cmd_look.go | 43 +- internal/game/cmd_map.go | 3 +- internal/game/cmd_mix.go | 15 +- internal/game/cmd_mods.go | 109 ++-- internal/game/cmd_move.go | 24 +- internal/game/cmd_option.go | 2 +- internal/game/cmd_prompt.go | 3 +- internal/game/cmd_queued.go | 3 +- internal/game/cmd_quit.go | 5 +- internal/game/cmd_registry.go | 170 ++++++ internal/game/cmd_remove.go | 8 +- internal/game/cmd_say.go | 3 +- internal/game/cmd_score.go | 2 +- internal/game/cmd_search.go | 5 +- internal/game/cmd_shop.go | 276 +++++++--- internal/game/cmd_smelt.go | 12 +- internal/game/cmd_smith.go | 4 +- internal/game/cmd_sneak.go | 7 +- internal/game/cmd_stats.go | 2 +- internal/game/cmd_style.go | 2 +- internal/game/cmd_task.go | 3 +- internal/game/cmd_tech.go | 4 +- internal/game/cmd_trigger.go | 121 ++--- internal/game/cmd_use.go | 151 +++++- internal/game/cmd_walk.go | 6 +- internal/game/cmd_wear.go | 8 +- internal/game/color.go | 3 +- internal/game/construction.go | 74 +++ internal/game/death.go | 66 +++ internal/game/equip_stats.go | 10 + internal/game/game.go | 468 ++-------------- internal/game/hacking.go | 88 +-- internal/game/hacking/hacking.go | 63 +++ internal/game/hacking/hacking_liars_dice.go | 395 ++++++++++++++ internal/game/hacking/hacking_mastermind.go | 177 ++++++ internal/game/hacking/hacking_wumpus.go | 286 ++++++++++ internal/game/hacking_liars_dice.go | 395 -------------- internal/game/hacking_mastermind.go | 177 ------ internal/game/hacking_wumpus.go | 286 ---------- internal/game/help.go | 25 +- internal/game/login_account.go | 8 +- internal/game/messages.go | 13 + internal/game/player_flags.go | 55 ++ internal/game/production_core.go | 606 +++++++++++++++++++++ internal/game/production_menu.go | 209 ++++++++ internal/game/prompt.go | 6 +- internal/game/science.go | 194 ++----- internal/game/tick.go | 59 +- internal/game/utils.go | 7 + 94 files changed, 3867 insertions(+), 3701 deletions(-) create mode 100644 internal/game/action_finishing_blow.go delete mode 100644 internal/game/action_production.go delete mode 100644 internal/game/action_toggle.go delete mode 100644 internal/game/cmd_autocast.go create mode 100644 internal/game/cmd_autotrigger.go delete mode 100644 internal/game/cmd_construct.go create mode 100644 internal/game/cmd_handlers.go create mode 100644 internal/game/cmd_registry.go create mode 100644 internal/game/construction.go create mode 100644 internal/game/death.go create mode 100644 internal/game/hacking/hacking.go create mode 100644 internal/game/hacking/hacking_liars_dice.go create mode 100644 internal/game/hacking/hacking_mastermind.go create mode 100644 internal/game/hacking/hacking_wumpus.go delete mode 100644 internal/game/hacking_liars_dice.go delete mode 100644 internal/game/hacking_mastermind.go delete mode 100644 internal/game/hacking_wumpus.go create mode 100644 internal/game/messages.go create mode 100644 internal/game/player_flags.go create mode 100644 internal/game/production_core.go create mode 100644 internal/game/production_menu.go (limited to 'internal/game') diff --git a/internal/game/action.go b/internal/game/action.go index dc21927..f25cabc 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -23,8 +23,6 @@ var verbAliases = map[string]string{ "talk": "talk", "speak": "talk", "ask": "talk", - "pull": "toggle", - "push": "toggle", "steal": "steal", "thieve": "steal", } @@ -46,8 +44,8 @@ func normalizeVerb(v string) string { return v } -func (g *Game) StartAction(sess *net.Session, verb, target string) { - p := sess.Player.(*player.Player) +func (g *Game) startAction(sess *net.Session, verb, target string) { + p := sess.Player verb = normalizeVerb(verb) @@ -57,9 +55,9 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) lower := strings.ToLower(target) instanceIdx := -1 @@ -77,7 +75,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { var obj *object.ObjectDef var mob *world.MobInstance - var behaviorID string if len(instances) > 0 { uniqueDefs := make(map[string]bool) @@ -109,16 +106,15 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } var err error obj, err = g.ObjectStore.Load(chosen.DefID) - if err != nil || obj.BehaviorID == "" { + if err != nil || !obj.IsInteractable() { sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) return } - behaviorID = obj.BehaviorID } else { mobs := g.MobStore.MobsInRoom(p.RoomID) var candidates []*world.MobInstance for _, m := range mobs { - if m.BehaviorID == "" { + if !m.IsTalkable() { continue } if q := m.MatchQuality(lower); q >= world.MatchPrefix { @@ -140,17 +136,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { sess.WriteLine("Which one?") return } - behaviorID = mob.BehaviorID - } - - bh, err := g.BehaviorStore.Load(behaviorID) - if err != nil { - if obj != nil { - sess.WriteLine(fmt.Sprintf("Something is wrong with the %s.", obj.Name)) - } else { - sess.WriteLine("Something is wrong with that.") - } - return } if obj != nil && obj.ID == "estate_directory" { @@ -158,7 +143,16 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { return } - if bh.Type != verb { + bhType := "" + if obj != nil { + bhType = obj.BehaviorType() + } else if mob != nil { + if mob.IsTalkable() { + bhType = "talk" + } + } + + if bhType != verb { if obj != nil { sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) } else { @@ -167,7 +161,7 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { return } - switch bh.Type { + switch bhType { case "gather": if obj == nil { sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) @@ -200,21 +194,19 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } case "use": g.startUse(sess, p, obj) - case "toggle": - g.startToggle(sess, p, obj) default: sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) } } -func (g *Game) CancelAction(p *player.Player) { +func (g *Game) cancelAction(p *player.Player) { p.Action = nil p.ActionState = nil p.WalkSequence = nil p.ClearMoveState() } -func (g *Game) CancelBackgroundAction(p *player.Player) { +func (g *Game) cancelBackgroundAction(p *player.Player) { p.BackgroundAction = nil p.BackgroundActionState = nil } @@ -224,8 +216,8 @@ func (g *Game) AdvanceActions() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil { + p := sess.Player + if p == nil || p.Action == nil { continue } if !p.Action.Advance() { @@ -269,8 +261,8 @@ func (g *Game) AdvanceActions() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p.BackgroundAction == nil { + p := sess.Player + if p == nil || p.BackgroundAction == nil { continue } if !p.BackgroundAction.Advance() { @@ -289,7 +281,7 @@ func (g *Game) AdvanceActions() { } func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { - p, _ := sess.Player.(*player.Player) + p := sess.Player if c.AllOf != nil { for _, sub := range c.AllOf { diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go index 089f026..37fbab9 100644 --- a/internal/game/action_agility.go +++ b/internal/game/action_agility.go @@ -68,7 +68,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { ticksPerPhase := data["ticks_per_phase"].(float64) if phase >= len(messages) { - g.CancelAction(p) + g.cancelAction(p) return } @@ -95,10 +95,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { courseID := data["course_id"].(string) if obstacleXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Agility, obstacleXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) @@ -109,10 +106,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { if isLastObstacle { if completionXP > 0 { - if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Agility, completionXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) @@ -127,10 +121,10 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, startRoom) } else { - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, nextRoom) } @@ -162,7 +156,7 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, startRoom) } diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 0230f24..4d8b65e 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -13,9 +13,9 @@ import ( func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) if ambiguous { @@ -32,9 +32,9 @@ func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) if !g.hasFireObject(p.RoomID) { sess.WriteLine("There's no fire here to stoke.") @@ -183,14 +183,14 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { logDef, err := g.ItemStore.Load(itemID) if err != nil { - g.CancelAction(p) + g.cancelAction(p) return } if phase == 0 { if !p.HasItem(itemID) { sess.WriteLine(fmt.Sprintf("You're out of %s.", g.itemColorize(sess, logDef, logDef.Name))) - g.CancelAction(p) + g.cancelAction(p) return } p.RemoveItem(itemID, 1) @@ -208,7 +208,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { ground := g.World.GroundItems(p.RoomID) if ground[itemID] <= 0 { sess.WriteLine("The thing you were trying to burn is gone.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -235,13 +235,11 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { xp := logDef.FireXP if xp > 0 { - if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Firemaking, xp) } g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) msg := g.colorize(sess, "fire", "You manage to get a fire going!") if xp > 0 && p.OptionBool("xp_drops") { @@ -273,7 +271,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { st := g.World.GetObjStateByKey(fireKey) if st == nil || st.Quality <= 0 { sess.WriteLine("The fire went out!") - g.CancelAction(p) + g.cancelAction(p) return } @@ -285,7 +283,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { if !p.HasItem(itemID) { sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.CancelAction(p) + g.cancelAction(p) return } @@ -293,9 +291,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { st.Quality += burnTicks / 2 if xp > 0 { - if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Firemaking, xp) } g.AccountStore.SaveCharacter(p) @@ -308,7 +304,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { if !p.HasItem(itemID) { sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.CancelAction(p) + g.cancelAction(p) return } @@ -416,8 +412,8 @@ func (g *Game) hasFireObject(roomID int) bool { func (g *Game) broadcastDrop(p *player.Player, itemName string) { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - op, ok := other.Player.(*player.Player) - if ok && op.Name != p.Name { + op := other.Player + if op != nil && op.Name != p.Name { other.WriteLine(fmt.Sprintf("\n%s drops a %s.", p.Name, itemName)) } } @@ -438,10 +434,10 @@ func (g *Game) FireTick() { func (g *Game) cancelStokers(roomID int) { for _, other := range g.Hub.PlayersInRoom(roomID) { - op, ok := other.Player.(*player.Player) - if ok && op.Action != nil && op.Action.Type == "stoke" { + op := other.Player + if op != nil && op.Action != nil && op.Action.Type == "stoke" { other.WriteLine("The fire went out!") - g.CancelAction(op) + g.cancelAction(op) g.writePrompt(other) } } diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go index dab3619..104dcb0 100644 --- a/internal/game/action_clean.go +++ b/internal/game/action_clean.go @@ -21,7 +21,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { allRecipes, err := g.RecipeStore.LoadAll() if err != nil { - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -51,7 +51,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if recipe == nil { sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -64,10 +64,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { } if recipe.xp > 0 { - if newLevel := p.AddSkillXP(player.Pharmacy, recipe.xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d pharmacy! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Pharmacy, recipe.xp) } g.AccountStore.SaveCharacter(p) @@ -108,7 +105,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if !hasMore { sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } diff --git a/internal/game/action_default_target.go b/internal/game/action_default_target.go index b3825ca..2ca0bd3 100644 --- a/internal/game/action_default_target.go +++ b/internal/game/action_default_target.go @@ -30,21 +30,11 @@ func (g *Game) resolveDefaultTarget(roomID int, verb string) string { seen[st.DefID] = true objDef, err := g.ObjectStore.Load(st.DefID) - if err != nil || objDef.BehaviorID == "" { + if err != nil || !objDef.IsGatherable() { continue } - bh, err := g.BehaviorStore.Load(objDef.BehaviorID) - if err != nil || bh.Type != "gather" { - continue - } - - cfg, err := g.BehaviorStore.LoadGather(objDef.BehaviorID) - if err != nil { - continue - } - - if strings.EqualFold(cfg.Skill, skill) { + if strings.EqualFold(objDef.Gather.Skill, skill) { candidates = append(candidates, candidate{defID: st.DefID, name: objDef.Name}) } } diff --git a/internal/game/action_farm.go b/internal/game/action_farm.go index 12b99c0..d79150f 100644 --- a/internal/game/action_farm.go +++ b/internal/game/action_farm.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" @@ -38,9 +39,7 @@ func farmFlagPrefix(defID string, index int) string { } func (g *Game) ensureFarmState(p *player.Player, prefix string) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } + p.EnsureFlags() if _, exists := p.Flags[prefix+"_seed"]; !exists { p.Flags[prefix+"_weeds"] = true p.Flags[prefix+"_seed"] = "" @@ -86,8 +85,8 @@ func (g *Game) FarmTick() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Flags == nil { + p := sess.Player + if p == nil || p.Flags == nil { continue } g.advanceFarmGrowth(sess, p) @@ -120,7 +119,7 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { continue } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") watered, _ := p.Flags[prefix+"_watered"].(bool) seedDef, err := g.ItemStore.Load(seedID) @@ -188,10 +187,7 @@ func (g *Game) advancePlant(sess *net.Session, p *player.Player) { p.Flags[prefix+"_ready"] = false if intXP := int(xp); intXP > 0 { - if newLevel := p.AddSkillXP(player.Farming, intXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d farming! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Farming, intXP) } g.AccountStore.SaveCharacter(p) @@ -275,10 +271,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { } if intXP := int(xp); intXP > 0 { - if newLevel := p.AddSkillXP(player.Farming, intXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d farming! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Farming, intXP) } g.ensureFarmState(p, prefix) @@ -416,7 +409,7 @@ func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) st return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name) } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") maxStages := 4 if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { maxStages = def.FarmStages @@ -450,20 +443,6 @@ func (g *Game) toolShedLookSuffix(p *player.Player) string { return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", ")) } -func intFlag(flags map[string]any, key string) int { - v, ok := flags[key] - if !ok { - return 0 - } - switch n := v.(type) { - case int: - return n - case float64: - return int(n) - } - return 0 -} - func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, defID string, index int) { objInstances := g.World.AllObjInstances(p.RoomID) if len(objInstances) == 0 { @@ -482,24 +461,17 @@ func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, d if !farmPatchDefIDs[obj.DefID] { continue } - for i, inst := range objInstances { - if inst.DefID != obj.DefID { - continue - } - pref := farmFlagPrefix(obj.DefID, i) - if pref == "" { - break - } - var name string - if def, err := g.ObjectStore.Load(obj.DefID); err == nil { - name = def.Name - } else { - name = obj.DefID - } - farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: i, name: name}) - break + pref := farmFlagPrefix(obj.DefID, obj.Index) + if pref == "" { + continue + } + var name string + if def, err := g.ObjectStore.Load(obj.DefID); err == nil { + name = def.Name + } else { + name = obj.DefID } - break + farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: obj.Index, name: name}) } if len(farms) == 0 { @@ -512,7 +484,7 @@ func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, d lower := strings.ToLower(input) for _, f := range farms { - if strings.Contains(lower, strings.ToLower(f.name)) || world.WordPrefixMatch(lower, f.name) { + if strings.Contains(lower, strings.ToLower(f.name)) || action.WordPrefixMatch(lower, f.name) { return f.prefix, f.defID, f.index } } @@ -620,7 +592,7 @@ func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) { continue } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") maxStages := 4 if sd, err := g.ItemStore.Load(seedID); err == nil && sd.FarmStages > 0 { maxStages = sd.FarmStages diff --git a/internal/game/action_finishing_blow.go b/internal/game/action_finishing_blow.go new file mode 100644 index 0000000..1a31d86 --- /dev/null +++ b/internal/game/action_finishing_blow.go @@ -0,0 +1,78 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { + p := sess.Player + cs := combat.GetCombat(p.Name) + if cs == nil { + return false + } + mob := g.MobStore.GetInstance(cs.MobID) + if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { + return false + } + lower := strings.ToLower(input) + var itemPart, targetPart string + for _, sep := range []string{" on ", " with "} { + if idx := strings.Index(lower, sep); idx > 0 { + itemPart = strings.TrimSpace(input[:idx]) + targetPart = strings.TrimSpace(input[idx+len(sep):]) + break + } + } + if targetPart == "" { + itemPart = strings.TrimSpace(input) + if mob.MatchQuality(itemPart) != world.MatchNone { + targetPart = itemPart + itemPart = strings.TrimSpace(mob.FinishingBlow) + } + } + if targetPart == "" { + return false + } + if mob.MatchQuality(targetPart) == world.MatchNone { + return false + } + g.doFinishingBlow(sess, p, mob, itemPart) + return true +} + +func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { + fbDef, err := g.ItemStore.Load(mob.FinishingBlow) + if err != nil { + sess.WriteLine("Something went wrong.") + return + } + if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { + sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) + return + } + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) + return + } + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + consumed := true + if p.Flags != nil { + if val, ok := p.Flags[autoKey]; ok { + if b, ok := val.(bool); ok && b { + consumed = false + } + } + } + if consumed { + p.RemoveItem(mob.FinishingBlow, 1) + } + sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) + mob.HP = 0 + g.endCombat(sess, p, mob) +} diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go index ddb4d2c..aa5dfc7 100644 --- a/internal/game/action_fletch.go +++ b/internal/game/action_fletch.go @@ -10,9 +10,7 @@ import ( ) 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.EnsureFlags() p.Flags["last_fletch"] = recipe.ID g.AccountStore.SaveCharacter(p) @@ -61,9 +59,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, recipe *acti } if recipe.XP > 0 { - if newLevel := p.AddSkillXP(skill, recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, recipe.EffectiveSkill()))) - } + g.awardSkillXP(sess, p, skill, recipe.XP) } batches++ @@ -138,7 +134,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { recipe := g.loadProductionRecipe(recipeID) if recipe == nil { - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -150,7 +146,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { if !g.canDoFletchRecipe(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -179,16 +175,14 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("\nYour inventory is too full!") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } if recipe.XP > 0 { - if newLevel := p.AddSkillXP(skill, recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, recipe.EffectiveSkill()))) - } + g.awardSkillXP(sess, p, skill, recipe.XP) } g.AccountStore.SaveCharacter(p) @@ -211,14 +205,14 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { p.BackgroundAction.Data["remaining"] = remaining if remaining <= 0 { sess.WriteLine("\nYou've finished fletching.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } } if !g.canContinueProduction(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index edc8545..e169beb 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -14,9 +14,19 @@ import ( "thehouseoficarus/internal/world" ) -func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { - cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID) +const birdsNestItemID = "birds_nest" + +func loadGatherConfig(g *Game, objDefID string) *action.GatherConfig { + def, err := g.ObjectStore.Load(objDefID) if err != nil { + return nil + } + return def.Gather +} + +func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { + cfg := obj.Gather + if cfg == nil { sess.WriteLine("Something is wrong with this object.") return } @@ -34,7 +44,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer)) } sess.WriteLine(msg) - } else if cfg.DepleteTimer > 0 { + } else if cfg.DepleteTimer > 0 { sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer))) } else { sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer))) @@ -156,13 +166,13 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } data := map[string]any{ - "behavior_id": obj.BehaviorID, + "obj_def_id": obj.ID, "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), "instance_idx": st.Index + 1, "effective_wait": wait, "step": 0, } - if cfg.DepleteTimer > 0 { + if cfg.DepleteTimer > 0 { data["deplete_timer"] = true } p.Action = &action.Action{ @@ -175,10 +185,10 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } func (g *Game) advanceGather(sess *net.Session, p *player.Player) { - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadGather(behaviorID) - if err != nil { - g.CancelAction(p) + objDefID := p.Action.Data["obj_def_id"].(string) + cfg := loadGatherConfig(g, objDefID) + if cfg == nil { + g.cancelAction(p) return } @@ -190,7 +200,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if step == 0 { if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } if cfg.Bait != "" { @@ -200,7 +210,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { baitName = def.Name } sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.CancelAction(p) + g.cancelAction(p) return } } @@ -228,7 +238,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { baitName = def.Name } sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.CancelAction(p) + g.cancelAction(p) return } p.RemoveItem(cfg.Bait, 1) @@ -239,12 +249,12 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if rand.Float64() < chance { eligible := filterDropsByLevel(cfg.Drops, skillLevel) - drop := g.BehaviorStore.ResolveDrop(eligible) + drop := action.ResolveDrop(g.DataDir, eligible) if drop != nil { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } @@ -265,9 +275,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { xp = cfg.XP } if xp > 0 { - if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp) } g.AccountStore.SaveCharacter(p) @@ -284,20 +292,20 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { g.checkBirdNest(sess, p, cfg, freeSlot) - if shared { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.SharedTimer <= 0 { - g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ - instanceKey: instanceKey, - behaviorID: behaviorID, - targetName: p.Action.TargetName, - playerNames: []string{p.Name}, - }) - p.Action.Data["step"] = 2 - p.Action.WaitLeft = engine.ToTicks(1) - return + if shared { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.SharedTimer <= 0 { + g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ + instanceKey: instanceKey, + objDefID: objDefID, + targetName: p.Action.TargetName, + playerNames: []string{p.Name}, + }) + p.Action.Data["step"] = 2 + p.Action.WaitLeft = engine.ToTicks(1) + return + } } - } if !shared && drop.Depletes { delay := engine.ToTicks(cfg.RespawnTimer) @@ -310,20 +318,20 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { st.DepleteTimer = float64(delay) } - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { - continue - } - op, ok := other.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { - continue - } - if op.Action.Data["instance_key"] == instanceKey { - other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) - g.CancelAction(op) - g.writePrompt(other) + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess { + continue + } + op := other.Player + if op == nil || op.Action == nil || op.Action.Type != "gather" { + continue + } + if op.Action.Data["instance_key"] == instanceKey { + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) + g.cancelAction(op) + g.writePrompt(other) + } } - } p.Action.Data["step"] = 2 p.Action.WaitLeft = engine.ToTicks(1) @@ -332,7 +340,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } p.Action.Data["step"] = 0 @@ -344,7 +352,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { sess.WriteLine(cfg.FailMsg) if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } p.Action.Data["step"] = 0 @@ -375,8 +383,8 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, t } for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { - op, ok := sess.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { + op := sess.Player + if op == nil || op.Action == nil || op.Action.Type != "gather" { continue } if op.Action.Data["instance_key"] != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) { @@ -394,7 +402,7 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, t continue } sess.WriteLine(fmt.Sprintf("\n%s", msg)) - g.CancelAction(op) + g.cancelAction(op) g.writePrompt(sess) } } @@ -419,8 +427,8 @@ func (g *Game) flushPendingDepletions() { if st == nil || st.Depleted { continue } - cfg, err := g.BehaviorStore.LoadGather(pd.behaviorID) - if err != nil { + cfg := loadGatherConfig(g, pd.objDefID) + if cfg == nil { continue } g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames) @@ -438,7 +446,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.Ga } nestName := "bird's nest" - if def, err := g.ItemStore.Load("birds_nest"); err == nil { + if def, err := g.ItemStore.Load(birdsNestItemID); err == nil { nestName = def.Name } @@ -453,12 +461,12 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.Ga } } if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, "birds_nest", 1) + g.World.AddGroundItem(p.RoomID, birdsNestItemID, 1) sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName)) return } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: "birds_nest", Quantity: 1}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1}) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) } diff --git a/internal/game/action_identify.go b/internal/game/action_identify.go index 3f63e62..b04bf97 100644 --- a/internal/game/action_identify.go +++ b/internal/game/action_identify.go @@ -14,7 +14,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { scrapCount := p.CountItem("scrap_metal") if scrapCount == 0 { sess.WriteLine("You don't have any scrap metal.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -32,7 +32,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { } if !hasExistingStack && p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) + g.cancelAction(p) return } @@ -65,10 +65,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { msg := fmt.Sprintf("The altar hums. You identify %d scrap metal into %d %s.", scrapCount, totalJunk, junkName) - if newLevel := p.AddSkillXP(player.Scavenging, totalXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d scavenging! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Scavenging, totalXP) if p.OptionBool("xp_drops") && totalXP > 0 { msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp scv)", totalXP)) @@ -76,5 +73,5 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { sess.WriteLine(msg) g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) } diff --git a/internal/game/action_production.go b/internal/game/action_production.go deleted file mode 100644 index 788cadb..0000000 --- a/internal/game/action_production.go +++ /dev/null @@ -1,798 +0,0 @@ -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"}, - "construction": {"construct", "constructing"}, -} - -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} - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, 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 -} diff --git a/internal/game/action_room.go b/internal/game/action_room.go index 603ac7e..b997352 100644 --- a/internal/game/action_room.go +++ b/internal/game/action_room.go @@ -7,7 +7,7 @@ import ( "thehouseoficarus/internal/net" ) -func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { +func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { return @@ -26,11 +26,11 @@ func (g *Game) BroadcastRespawns() { respawns := g.World.FlushObjRespawns() for _, st := range respawns { def, err := g.ObjectStore.Load(st.DefID) - if err != nil || def.BehaviorID == "" { + if err != nil || !def.IsGatherable() { continue } - cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err != nil || cfg.RespawnBroadcast == "" { + cfg := def.Gather + if cfg.RespawnBroadcast == "" { continue } name := def.Name diff --git a/internal/game/action_search.go b/internal/game/action_search.go index 884157e..d7dabe0 100644 --- a/internal/game/action_search.go +++ b/internal/game/action_search.go @@ -53,7 +53,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { def, err := g.ItemStore.Load(itemID) if err != nil { sess.WriteLine("Something went wrong.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -70,7 +70,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { slot := p.InvSlot(slotIdx) if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { sess.WriteLine("The item is gone.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -80,18 +80,18 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { p.SetInvSlot(slotIdx, nil) } - dt, err := g.BehaviorStore.LoadDropTable(def.SearchTable) + dt, err := action.LoadDropTable(g.DataDir, def.SearchTable) if err == nil && len(dt.Drops) > 0 { - drop := g.BehaviorStore.ResolveDrop(dt.Drops) + drop := action.ResolveDrop(g.DataDir, dt.Drops) if drop != nil && drop.ItemID != "" { g.giveSearchLoot(sess, p, drop) } } if def.SearchMiscTable != "" { - dt2, err := g.BehaviorStore.LoadDropTable(def.SearchMiscTable) + dt2, err := action.LoadDropTable(g.DataDir, def.SearchMiscTable) if err == nil && len(dt2.Drops) > 0 { - drop := g.BehaviorStore.ResolveDrop(dt2.Drops) + drop := action.ResolveDrop(g.DataDir, dt2.Drops) if drop != nil && drop.ItemID != "" { g.giveSearchLoot(sess, p, drop) } @@ -99,7 +99,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { } g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) } func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 55d3392..0ba3767 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -1,7 +1,5 @@ package game -import "fmt" - type ActionType string const ( @@ -11,7 +9,6 @@ const ( ActionMoving ActionType = "moving" ActionUsing ActionType = "using" ActionTalking ActionType = "talking" - ActionToggling ActionType = "toggling" ActionBurning ActionType = "burning" ActionStoking ActionType = "stoking" ActionPickingUp ActionType = "picking_up" @@ -23,9 +20,7 @@ const ( ActionFletching ActionType = "fletching" ActionEating ActionType = "eating" ActionCleaning ActionType = "cleaning" - ActionMixing ActionType = "mixing" ActionIdentifying ActionType = "identifying" - ActionTriggering ActionType = "triggering" ActionStealing ActionType = "stealing" ActionPlanting ActionType = "planting" ActionHarvesting ActionType = "harvesting_crop" @@ -63,8 +58,6 @@ func (a *ActionState) Description() string { return "using a " + a.TargetName case ActionTalking: return "talking to " + a.TargetName - case ActionToggling: - return fmt.Sprintf("pulling a %s", a.TargetName) case ActionBurning: return "trying to start a fire" case ActionStoking: @@ -87,12 +80,8 @@ func (a *ActionState) Description() string { return "eating " + a.TargetName case ActionCleaning: return "cleaning herbs" - case ActionMixing: - return "mixing " + a.TargetName case ActionIdentifying: return "identifying scrap at " + a.TargetName - case ActionTriggering: - return "triggering " + a.TargetName case ActionStealing: return "stealing from " + a.TargetName case ActionPlanting: diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go index 5347c2a..ee5456c 100644 --- a/internal/game/action_steal.go +++ b/internal/game/action_steal.go @@ -16,6 +16,34 @@ import ( "thehouseoficarus/internal/world" ) +var stallGuardTalk = &action.TalkConfig{ + Nodes: map[string]action.TalkNode{ + "start": { + Message: "Caught you red-handed! You have three options, thief.", + Options: []action.TalkOption{ + {Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &action.Condition{MinCredits: 500}}, + {Text: "\"Take me to jail.\"", Goto: "jail"}, + {Text: "\"You'll have to catch me first!\"", Goto: "fight"}, + }, + }, + "bribe": { + Message: "Smart choice. Hand over 500 credits and we'll forget this happened.", + Action: &action.NodeAction{Cost: 500}, + Options: []action.TalkOption{{Text: "\"Fine, take it.\"", End: true}}, + }, + "jail": { + Message: "Off to the detention cell with you!", + Action: &action.NodeAction{Teleport: 162}, + Options: []action.TalkOption{{Text: "(You are dragged away)", End: true}}, + }, + "fight": { + Message: "Then defend yourself!", + Action: &action.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, + Options: []action.TalkOption{{Text: "(The guard attacks!)", End: true}}, + }, + }, +} + var stealSuccess = action.SuccessFormula{ Base: 0.5, PerLevel: 0.03, @@ -23,14 +51,14 @@ var stealSuccess = action.SuccessFormula{ } func (g *Game) doSteal(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } - g.CancelAction(p) + g.cancelAction(p) targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input) if errMsg != "" { @@ -131,7 +159,7 @@ func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input str var matchingObjs []*object.ObjectDef for _, o := range stealObjs { - if world.WordPrefixMatch(searchName, o.Name) { + if action.WordPrefixMatch(searchName, o.Name) { matchingObjs = append(matchingObjs, o) } } @@ -265,12 +293,12 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { mob := g.MobStore.GetInstance(mobInstanceID) if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID { sess.WriteLine("Your target is gone.") - g.CancelAction(p) + g.cancelAction(p) return } if combat.IsMobInCombat(mob.InstanceID) { sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) - g.CancelAction(p) + g.cancelAction(p) return } data["steal_level"] = mob.StealLevel @@ -290,7 +318,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if !found { sess.WriteLine("Your target is gone.") - g.CancelAction(p) + g.cancelAction(p) return } objDef, err := g.ObjectStore.Load(objDefID) @@ -328,11 +356,11 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if rand.Float64() < chance { - dt, err := g.BehaviorStore.LoadDropTable(stealTable) + dt, err := action.LoadDropTable(g.DataDir, stealTable) if err != nil || len(dt.Drops) == 0 { sess.WriteLine("You steal nothing of value.") } else { - drop := g.BehaviorStore.ResolveDrop(dt.Drops) + drop := action.ResolveDrop(g.DataDir, dt.Drops) if drop == nil || drop.ItemID == "" { sess.WriteLine("You steal nothing of value.") } else { @@ -341,19 +369,14 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if stealXP > 0 { - prevLevel := p.Level(player.Thieving) - p.AddSkillXP(player.Thieving, stealXP) - newLevel := p.Level(player.Thieving) - if newLevel > prevLevel { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d thieving! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Thieving, stealXP) } g.AccountStore.SaveCharacter(p) if p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -363,7 +386,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { mobInstanceID := data["mob_instance_id"].(string) mob := g.MobStore.GetInstance(mobInstanceID) sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName)) - g.CancelAction(p) + g.cancelAction(p) if mob != nil && mob.HP > 0 { g.startCombat(sess, p, mob) } @@ -372,7 +395,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { guard := g.findGuardInRoom(p.RoomID, guardMob) if guard != nil { sess.WriteLine("You fumble and the Guard spots you!") - g.CancelAction(p) + g.cancelAction(p) g.startStealGuardTalk(sess, p, guard) return } @@ -433,14 +456,10 @@ func (g *Game) isGuardWatching(roomID int, objDefID string) bool { } func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk("stall_guard_talk") - if err != nil { - sess.WriteLine("The Guard glares at you but says nothing.") - return - } - + cfg := stallGuardTalk node, ok := cfg.Nodes["start"] if !ok { + sess.WriteLine("The Guard glares at you but says nothing.") return } @@ -450,7 +469,7 @@ func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob Type: "talk", TargetID: guardMob.DefID, TargetName: guardMob.Name, - Data: map[string]any{"node": "start", "behavior_id": "stall_guard_talk", "steal_guard": true}, + Data: map[string]any{"node": "start", "talk_cfg": cfg, "steal_guard": true}, } g.showTalkNode(sess, node) diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index c18e254..2f463c7 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -12,8 +12,8 @@ import ( ) func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) - if err != nil { + cfg := mob.TalkConfig + if cfg == nil { sess.WriteLine("This person has nothing to say.") return } @@ -24,7 +24,7 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI Type: "talk", TargetID: mob.DefID, TargetName: mob.Name, - Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, + Data: map[string]any{"node": "start", "talk_cfg": cfg}, } g.showTalkNode(sess, node) } else { @@ -33,8 +33,8 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI } func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) - if err != nil { + cfg := obj.Talk + if cfg == nil { sess.WriteLine("This person has nothing to say.") return } @@ -45,7 +45,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object Type: "talk", TargetID: obj.ID, TargetName: obj.Name, - Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, + Data: map[string]any{"node": "start", "talk_cfg": cfg}, } g.showTalkNode(sess, node) } else { @@ -66,12 +66,12 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { if g.WorldFlags["guard_hostile"] != nil { delete(g.WorldFlags, "guard_hostile") - p, _ := sess.Player.(*player.Player) + p := sess.Player if p != nil && p.Action != nil && p.Action.Data["steal_guard"] == true { guardMob := g.findGuardInRoom(p.RoomID, "guard") if guardMob != nil { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) guardMob.Protected = false g.startCombat(sess, p, guardMob) } @@ -99,33 +99,30 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { } func (g *Game) handleTalkInput(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil || p.Action.Type != "talk" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "talk" { sess.State = net.StateGame g.writePrompt(sess) return } - if p.Action.Data != nil { - if bid, ok := p.Action.Data["behavior_id"].(string); ok && bid == "estate_directory_talk" { - g.handleEstateDirectoryTalk(sess, input) - return - } - } - input = strings.TrimSpace(input) lower := strings.ToLower(input) if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return } - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadTalk(behaviorID) - if err != nil { - g.CancelAction(p) + if _, ok := p.Action.Data["estate_directory"].(bool); ok { + g.handleEstateDirectoryTalk(sess, input) + return + } + + cfg, ok := p.Action.Data["talk_cfg"].(*action.TalkConfig) + if !ok || cfg == nil { + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -134,7 +131,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { nodeKey := p.Action.Data["node"].(string) node, ok := cfg.Nodes[nodeKey] if !ok { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -142,7 +139,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { idx, err := parseChoiceIndex(input) if err != nil || idx <= 0 { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -163,14 +160,14 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } if chosen == nil { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return } if chosen.End { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -184,7 +181,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } } - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) } @@ -197,8 +194,8 @@ func (g *Game) enterShop(sess *net.Session, cfg *action.ShopConfig) { } func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { return } @@ -225,9 +222,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.WorldFlags[k] = v } for k, v := range na.SetPlayerFlags { - if p.Flags == nil { - p.Flags = make(map[string]any) - } + p.EnsureFlags() p.Flags[k] = v } if na.TakeItem != "" { @@ -254,7 +249,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) - g.RunEnterSteps(sess, p.RoomID) + g.runEnterSteps(sess, p.RoomID) } if na.Heal > 0 { p.HP += na.Heal @@ -274,69 +269,3 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.processSawmill(sess, p) } } - -func (g *Game) processSawmill(sess *net.Session, p *player.Player) { - logTypes := map[string]struct { - plankID string - cost int - name string - }{ - "logs": {"planks", 5, "regular planks"}, - "oak_logs": {"oak_planks", 10, "oak planks"}, - "teak_logs": {"teak_planks", 20, "teak planks"}, - "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"}, - } - - totalCost := 0 - totalPlanks := 0 - type plankResult struct { - plankID string - qty int - } - - var results []plankResult - - for logID, info := range logTypes { - qty := p.CountItem(logID) - if qty == 0 { - continue - } - cost := qty * info.cost - totalCost += cost - totalPlanks += qty - results = append(results, plankResult{info.plankID, qty}) - p.RemoveItem(logID, qty) - } - - if totalPlanks == 0 { - sess.WriteLine("You don't have any logs to process.") - return - } - - if p.Credits < totalCost { - sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits)) - return - } - - p.Credits -= totalCost - - processed := 0 - for _, r := range results { - for i := 0; i < r.qty; i++ { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i) - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1}) - processed++ - } - } - - g.AccountStore.SaveCharacter(p) - - sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost)) - if processed < totalPlanks { - sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed)) - } -} diff --git a/internal/game/action_toggle.go b/internal/game/action_toggle.go deleted file mode 100644 index db7a199..0000000 --- a/internal/game/action_toggle.go +++ /dev/null @@ -1,48 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadToggle(obj.BehaviorID) - if err != nil { - sess.WriteLine("Nothing happens.") - return - } - - if cfg.Check != nil { - val, ok := g.WorldFlags[cfg.Check.Flag] - if cfg.Check.Not { - if ok && val == cfg.Check.Value { - sess.WriteLine("Nothing happens.") - return - } - } else { - if !ok || val != cfg.Check.Value { - sess.WriteLine("Nothing happens.") - return - } - } - } - - for flag, val := range cfg.SetFlags { - g.WorldFlags[flag] = val - } - - p.ActionState = &ActionState{Type: ActionToggling, TargetName: obj.Name} - - sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) - - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name))) - } - } - } -} diff --git a/internal/game/action_use.go b/internal/game/action_use.go index 78bf028..368162b 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -12,8 +12,8 @@ import ( ) func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) - if err != nil { + cfg := obj.Use + if cfg == nil { sess.WriteLine("You can't use this.") return } @@ -49,17 +49,16 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD TargetID: obj.ID, TargetName: obj.Name, WaitLeft: engine.ToTicks(1), - Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, + Data: map[string]any{"step": 0, "use_cfg": cfg}, } sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) } func (g *Game) advanceUse(sess *net.Session, p *player.Player) { - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadUse(behaviorID) - if err != nil { - g.CancelAction(p) + cfg, ok := p.Action.Data["use_cfg"].(*action.UseConfig) + if !ok || cfg == nil { + g.cancelAction(p) return } @@ -70,12 +69,12 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { defName = def.Name } sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) - g.CancelAction(p) + g.cancelAction(p) return } if !p.RemoveItem(itemID, qty) { sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) - g.CancelAction(p) + g.cancelAction(p) return } } @@ -95,7 +94,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -106,9 +105,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) if cfg.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), cfg.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), cfg.XP) } g.AccountStore.SaveCharacter(p) @@ -125,7 +122,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { if p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } diff --git a/internal/game/aggro.go b/internal/game/aggro.go index 8f4da42..2a9fff4 100644 --- a/internal/game/aggro.go +++ b/internal/game/aggro.go @@ -6,11 +6,10 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) checkAggro(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { return diff --git a/internal/game/assassin.go b/internal/game/assassin.go index d7af1a4..1927399 100644 --- a/internal/game/assassin.go +++ b/internal/game/assassin.go @@ -3,52 +3,12 @@ package game import ( "fmt" "math/rand" - "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) -func getPlayerFlagInt(p *player.Player, key string) int { - if p.Flags == nil { - return 0 - } - val, ok := p.Flags[key] - if !ok { - return 0 - } - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - } - return 0 -} - -func getPlayerFlagString(p *player.Player, key string) string { - if p.Flags == nil { - return "" - } - val, ok := p.Flags[key] - if !ok { - return "" - } - s, _ := val.(string) - return s -} - -func setPlayerFlag(p *player.Player, key string, val any) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[key] = val -} - type assassinTaskEntry struct { MobID string MinLevel int @@ -114,10 +74,7 @@ func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.Mo return } xp := mob.MaxHP * 2 - newLevel := p.AddSkillXP(player.Assassin, xp) - if newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Assassin, xp) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) } @@ -215,70 +172,3 @@ func streakBonus(streak int) int { } return 0 } - -func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { - p := sess.Player.(*player.Player) - cs := combat.GetCombat(p.Name) - if cs == nil { - return false - } - mob := g.MobStore.GetInstance(cs.MobID) - if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { - return false - } - lower := strings.ToLower(input) - var itemPart, targetPart string - for _, sep := range []string{" on ", " with "} { - if idx := strings.Index(lower, sep); idx > 0 { - itemPart = strings.TrimSpace(input[:idx]) - targetPart = strings.TrimSpace(input[idx+len(sep):]) - break - } - } - if targetPart == "" { - itemPart = strings.TrimSpace(input) - if mob.MatchQuality(itemPart) != world.MatchNone { - targetPart = itemPart - itemPart = strings.TrimSpace(mob.FinishingBlow) - } - } - if targetPart == "" { - return false - } - if mob.MatchQuality(targetPart) == world.MatchNone { - return false - } - g.doFinishingBlow(sess, p, mob, itemPart) - return true -} - -func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { - fbDef, err := g.ItemStore.Load(mob.FinishingBlow) - if err != nil { - sess.WriteLine("Something went wrong.") - return - } - if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { - sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) - return - } - if !p.HasItem(mob.FinishingBlow) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) - return - } - autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow - consumed := true - if p.Flags != nil { - if val, ok := p.Flags[autoKey]; ok { - if b, ok := val.(bool); ok && b { - consumed = false - } - } - } - if consumed { - p.RemoveItem(mob.FinishingBlow, 1) - } - sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) - mob.HP = 0 - g.endCombat(sess, p, mob) -} diff --git a/internal/game/buff.go b/internal/game/buff.go index 39abd8a..9449731 100644 --- a/internal/game/buff.go +++ b/internal/game/buff.go @@ -15,7 +15,7 @@ func (g *Game) BuffTick() { if sess.Player == nil { continue } - p := sess.Player.(*player.Player) + p := sess.Player remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs)) dirty := false for _, buff := range p.ActiveBuffs { diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go index 3bd777f..084c5cf 100644 --- a/internal/game/cmd_agility.go +++ b/internal/game/cmd_agility.go @@ -9,7 +9,7 @@ import ( ) func (g *Game) doObstacle(sess *net.Session, verb string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -29,9 +29,9 @@ func (g *Game) doObstacle(sess *net.Session, verb string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) g.startObstacle(sess, p, info) } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 77cd569..b0ce214 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" @@ -15,7 +16,7 @@ import ( ) func (g *Game) doAttack(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You are already in combat!") @@ -23,7 +24,7 @@ func (g *Game) doAttack(sess *net.Session, input string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } mob := g.findMob(sess, input, p.RoomID) @@ -125,17 +126,17 @@ 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) + g.cancelBackgroundAction(p) combat.EnterCombat(p.Name, mob.InstanceID) - autocastActive := p.AutocastMod != "" + autotriggerActive := p.AutotriggerMod != "" playerSpeed := g.playerWeaponSpeed(p) - if autocastActive { + if autotriggerActive { playerSpeed = 5.0 } - if !autocastActive { + if !autotriggerActive { attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) var styleParts []string if attBonus > 0 { @@ -153,8 +154,8 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn } sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr)) } else { - mod := GetMod(p.AutocastMod) - modName := p.AutocastMod + mod := GetMod(p.AutotriggerMod) + modName := p.AutotriggerMod if mod != nil { modName = mod.Name } @@ -182,16 +183,16 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn g.endCombat(sess, p, currentMob) return false } - if p.AutocastMod != "" { - mod := GetMod(p.AutocastMod) + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) if mod != nil { if !g.scienceAttack(sess, p, currentMob, mod) { - p.AutocastMod = "" + p.AutotriggerMod = "" sess.WriteLine("You've run out of junk. Switching to melee.") g.playerAttack(sess, p, currentMob) } } else { - p.AutocastMod = "" + p.AutotriggerMod = "" g.playerAttack(sess, p, currentMob) } } else { @@ -228,8 +229,17 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI return } - attackType := "crush" - var weaponType object.WeaponType + attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit) + } else { + g.applyPlayerMiss(sess, p, mob, weaponType) + } +} + +func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { + attackType = "crush" if itemID, ok := p.Equipment[object.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil { if def.AttackType != "" { @@ -246,8 +256,6 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI totals := g.playerEquipBonuses(p) isRanged := weaponType == object.WeaponRanged - var attRoll, defRoll, maxHit int - if isRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack @@ -273,90 +281,99 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) } + return +} - if combat.HitCheck(attRoll, defRoll) { - dmg := combat.RollDamage(maxHit) +func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType object.WeaponType, maxHit int) { + dmg := combat.RollDamage(maxHit) - mob.HP -= dmg - if mob.HP < 0 { - mob.HP = 0 - } - if mob.FinishingBlow != "" && mob.HP <= 0 { - mob.HP = 1 - } - if mob.HP < mob.MaxHP && mob.HP > 0 { - mob.StartRegen() - } + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 + } + if mob.HP < mob.MaxHP && mob.HP > 0 { + mob.StartRegen() + } - if mob.FinishingBlow != "" && mob.HP == 1 { - fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) - fbName := mob.FinishingBlow - if fbDef != nil { - fbName = fbDef.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", - mobDisplayName(mob, false), fbName))) + if mob.FinishingBlow != "" && mob.HP == 1 { + fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) + fbName := mob.FinishingBlow + if fbDef != nil { + fbName = fbDef.Name } - gains, leveled := g.awardCombatXP(p, dmg, isRanged) + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", + mobDisplayName(mob, false), fbName))) + } - if isRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - } - g.AccountStore.SaveCharacter(p) - } + isRanged := weaponType == object.WeaponRanged + gains := g.awardCombatXP(sess, p, dmg, isRanged) - for _, skill := range leveled { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) + if isRanged { + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 } + g.AccountStore.SaveCharacter(p) + } - mobName := mobDisplayName(mob, true) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)) - if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w { - w = w2 - } - g.combatPadWidth = w - prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) - line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + mobName := mobDisplayName(mob, true) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)) + if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) + line := fmt.Sprintf("%-*s %s", w, prefix, hpPart) + if p.OptionBool("xp_drops") && len(gains) > 0 { + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) } - sess.WriteLine(line) + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + } + sess.WriteLine(line) - if isRanged && p.AmmoQty <= 0 { + if isRanged && p.AmmoQty <= 0 { + sess.WriteLine("You've run out of ammo!") + combat.LeaveCombat(p.Name) + } +} + +func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType object.WeaponType) { + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) + + if weaponType == object.WeaponRanged { + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + g.AccountStore.SaveCharacter(p) sess.WriteLine("You've run out of ammo!") combat.LeaveCombat(p.Name) } - } else { - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) - - if isRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - g.AccountStore.SaveCharacter(p) - sess.WriteLine("You've run out of ammo!") - combat.LeaveCombat(p.Name) - } - } } } func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cs := combat.GetCombat(p.Name) + attRoll, defRoll := g.calculateMobAttack(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyMobHit(sess, p, mob) + } else { + g.applyMobMiss(sess, mob) + } +} + +func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) mobAttackType := mob.AttackType @@ -364,87 +381,89 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst mobAttackType = "crush" } - attRoll := combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) + attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) totals := g.playerEquipBonuses(p) equipDef := combat.SelectDefenseBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll := combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + defRoll = combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + return +} - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) - dmg := combat.RollDamage(maxHit) - dmg = g.applyTechProtection(p, mob, dmg) - - if mob.DamageWithout != "" { - hasProtection := false - for _, itemID := range p.Equipment { - if itemID == mob.DamageWithout { - hasProtection = true - break - } +func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) { + maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) + dmg := combat.RollDamage(maxHit) + dmg = g.applyTechProtection(p, mob, dmg) + + if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 + if dmg < 1 { + dmg = 1 } - if !hasProtection { - dmg = dmg * 3 / 2 - if dmg < 1 { - dmg = 1 + cs := combat.GetCombat(p.Name) + if cs != nil && !cs.DamageWarningShown { + cs.DamageWarningShown = true + fbDef, _ := g.ItemStore.Load(mob.DamageWithout) + fbName := mob.DamageWithout + if fbDef != nil { + fbName = fbDef.Name } - if cs != nil && !cs.DamageWarningShown { - cs.DamageWarningShown = true - fbDef, _ := g.ItemStore.Load(mob.DamageWithout) - fbName := mob.DamageWithout - if fbDef != nil { - fbName = fbDef.Name - } - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", - attacker, fbName))) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", + attacker, fbName))) } } + } - p.HP -= dmg - if p.HP < 0 { - p.HP = 0 - } - p.StartRegen() - g.AccountStore.SaveCharacter(p) + p.HP -= dmg + if p.HP < 0 { + p.HP = 0 + } + p.StartRegen() + g.AccountStore.SaveCharacter(p) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - mobName := mobDisplayName(mob, true) - w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)) - if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w { - w = w2 - } - g.combatPadWidth = w - prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) - sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + mobName := mobDisplayName(mob, true) + w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)) + if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) + sess.WriteLine(fmt.Sprintf("%-*s %s", w, prefix, hpPart)) - if p.MoveTicks > 0 { - p.ClearMoveState() - p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} - sess.WriteLine("Can't escape!") - } - } else { - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) + if p.MoveTicks > 0 { + p.ClearMoveState() + p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} + sess.WriteLine("Can't escape!") + } +} + +func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name } + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) } func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - g.combatPadWidth = 0 combat.LeaveCombat(p.Name) p.ActionState = nil @@ -486,7 +505,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { - op := other.Player.(*player.Player) + op := other.Player mobLvl := mobCombatLevel(mob) levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) @@ -510,7 +529,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if len(mob.Drops.Loot) > 0 { - entry := g.BehaviorStore.ResolveDrop(mob.Drops.Loot) + entry := action.ResolveDrop(g.DataDir, mob.Drops.Loot) if entry != nil && entry.ItemID != "" { qty := entry.Quantity if qty <= 0 { @@ -548,10 +567,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } -func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain, []player.SkillName) { +func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { baseXP := dmg * 4 var gains []xpGain - var leveledUp []player.SkillName if isRanged { gains = []xpGain{ @@ -578,71 +596,10 @@ func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain } for _, gain := range gains { - if newLevel := p.AddSkillXP(player.SkillName(gain.Skill), gain.XP); newLevel > 0 { - leveledUp = append(leveledUp, player.SkillName(gain.Skill)) - } + g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) } g.AccountStore.SaveCharacter(p) - return gains, leveledUp -} - -func (g *Game) dropItemsOnDeath(p *player.Player) { - roomID := p.RoomID - - if p.Credits > 0 { - g.World.AddGroundItem(roomID, "credits", p.Credits) - p.Credits = 0 - } - - var items []deathDrop - - for slot, inv := range p.Inventory { - if inv == nil || inv.Quantity <= 0 { - continue - } - val := 0 - if def, err := g.ItemStore.Load(inv.ItemID); err == nil { - val = def.Value * inv.Quantity - } - items = append(items, deathDrop{ - itemID: inv.ItemID, - quantity: inv.Quantity, - totalVal: val, - invSlot: slot, - }) - } - - for eqSlot, itemID := range p.Equipment { - val := 0 - if def, err := g.ItemStore.Load(itemID); err == nil { - val = def.Value - } - items = append(items, deathDrop{ - itemID: itemID, - quantity: 1, - totalVal: val, - isEquip: true, - equipSlot: eqSlot, - }) - } - - if len(items) <= 3 { - return - } - - sort.Slice(items, func(i, j int) bool { - return items[i].totalVal > items[j].totalVal - }) - - for i := 3; i < len(items); i++ { - it := items[i] - if it.isEquip { - delete(p.Equipment, it.equipSlot) - } else { - p.SetInvSlot(it.invSlot, nil) - } - g.World.AddGroundItem(roomID, it.itemID, it.quantity) - } + return gains } func (g *Game) stopCombat(playerName string) { @@ -663,7 +620,7 @@ func (g *Game) respawnMob(instanceID string) { if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(homeRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") { + if p := sess.Player; p != nil && p.OptionBool("mob_spawn") { mobLvl := mobCombatLevel(inst) levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob_name", mobDisplayName(inst, false)), levelStr)) @@ -672,12 +629,3 @@ func (g *Game) respawnMob(instanceID string) { } } -func (g *Game) playerWeaponSpeed(p *player.Player) float64 { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil && def.Speed > 0 { - return def.Speed - } - } - return 5.0 -} diff --git a/internal/game/cmd_autocast.go b/internal/game/cmd_autocast.go deleted file mode 100644 index 205fd8d..0000000 --- a/internal/game/cmd_autocast.go +++ /dev/null @@ -1,54 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doAutocast(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - input = strings.TrimSpace(input) - - if input == "" { - if p.AutocastMod == "" { - sess.WriteLine("No autocast mod set. Use 'autocast ' to set one.") - } else { - mod := GetMod(p.AutocastMod) - if mod == nil { - sess.WriteLine("Autocast: none (invalid mod)") - p.AutocastMod = "" - } else { - sess.WriteLine(fmt.Sprintf("Autocast: %s (Lv%d)", mod.Name, mod.Level)) - } - } - return - } - - if strings.ToLower(input) == "off" { - p.AutocastMod = "" - sess.WriteLine("Autocast disabled.") - return - } - - mod := FindMod(strings.ToLower(input)) - if mod == nil { - sess.WriteLine("Unknown mod.") - return - } - - if mod.Category != ModCombat { - sess.WriteLine("You can only autocast combat mods.") - return - } - - if p.Level(player.Science) < mod.Level { - sess.WriteLine(fmt.Sprintf("You need level %d science to autocast %s.", mod.Level, mod.Name)) - return - } - - p.AutocastMod = mod.ID - sess.WriteLine(fmt.Sprintf("Autocast set to: %s", mod.Name)) -} diff --git a/internal/game/cmd_autotrigger.go b/internal/game/cmd_autotrigger.go new file mode 100644 index 0000000..3c41735 --- /dev/null +++ b/internal/game/cmd_autotrigger.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doAutotrigger(sess *net.Session, input string) { + p := sess.Player + input = strings.TrimSpace(input) + + if input == "" { + if p.AutotriggerMod == "" { + sess.WriteLine("No autotrigger mod set. Use 'autotrigger ' to set one.") + } else { + mod := GetMod(p.AutotriggerMod) + if mod == nil { + sess.WriteLine("Autotrigger: none (invalid mod)") + p.AutotriggerMod = "" + } else { + sess.WriteLine(fmt.Sprintf("Autotrigger: %s (Lv%d)", mod.Name, mod.Level)) + } + } + return + } + + if strings.ToLower(input) == "off" { + p.AutotriggerMod = "" + sess.WriteLine("Autotrigger disabled.") + return + } + + mod := FindMod(strings.ToLower(input)) + if mod == nil { + sess.WriteLine("Unknown mod.") + return + } + + if mod.Category != ModCombat { + sess.WriteLine("You can only autotrigger combat mods.") + return + } + + if p.Level(player.Science) < mod.Level { + sess.WriteLine(fmt.Sprintf("You need level %d science to autotrigger %s.", mod.Level, mod.Name)) + return + } + + p.AutotriggerMod = mod.ID + sess.WriteLine(fmt.Sprintf("Autotrigger set to: %s", mod.Name)) +} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index ff3a3ac..0785b81 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -5,9 +5,9 @@ import ( "sort" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) handleBankInput(sess *net.Session, input string) { @@ -57,7 +57,7 @@ func (g *Game) writeBankPrompt(sess *net.Session) { } func (g *Game) showBankBrowse(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player unicode := p.OptionBool("unicode") @@ -110,7 +110,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { } func (g *Game) doBankDeposit(sess *net.Session, input string) { - p, _ := sess.Player.(*player.Player) + p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -158,7 +158,7 @@ func (g *Game) doBankDeposit(sess *net.Session, input string) { } func (g *Game) doBankDepositAll(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player var deposited int for i := 0; i < 28; i++ { @@ -187,7 +187,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) { } func (g *Game) doBankWithdraw(sess *net.Session, input string) { - p, _ := sess.Player.(*player.Player) + p := sess.Player freeSlot := p.FirstFreeSlot() if freeSlot == -1 { @@ -235,7 +235,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { } lower := strings.ToLower(input) for _, e := range entries { - if world.WordPrefixMatch(e.name, lower) { + if action.WordPrefixMatch(e.name, lower) { matches = append(matches, e) } } @@ -326,7 +326,7 @@ func (g *Game) canUseBank(p *player.Player) bool { } func (g *Game) doBank(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player if !g.canUseBank(p) { sess.WriteLine("There is no bank here.") diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index 70dedb9..1f00d75 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -12,7 +12,7 @@ import ( ) func (g *Game) doClean(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -50,7 +50,7 @@ func (g *Game) doClean(sess *net.Session, input string) { return } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) p.BackgroundAction = &action.Action{ Type: "clean", diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 1dd14fa..3fb7716 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -7,7 +7,6 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doColor(sess *net.Session, input string) { @@ -160,7 +159,7 @@ var colorCategoryOrder = []string{ } func showColorTable(g *Game, sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) table := &Table{ Columns: []string{ diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go index 90ae8c4..5aa36d9 100644 --- a/internal/game/cmd_colortable.go +++ b/internal/game/cmd_colortable.go @@ -6,7 +6,6 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doColortable(sess *net.Session) { @@ -16,7 +15,7 @@ func (g *Game) doColortable(sess *net.Session) { return } - p := sess.Player.(*player.Player) + p := sess.Player unicode := p.OptionBool("unicode") sess.WriteLine(fmt.Sprintf("\nColor mode: %s", mode)) diff --git a/internal/game/cmd_construct.go b/internal/game/cmd_construct.go deleted file mode 100644 index 4ab6409..0000000 --- a/internal/game/cmd_construct.go +++ /dev/null @@ -1,157 +0,0 @@ -package game - -import ( - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func (g *Game) doConstruct(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - sess.WriteLine("Error loading recipes.") - return - } - - var constructRecipes []action.RecipeDef - for _, r := range allRecipes { - if r.Type != "construction" { - continue - } - if !g.constructRecipeVisible(p, &r) { - continue - } - constructRecipes = append(constructRecipes, r) - } - - if input != "" { - g.doConstructWithInput(sess, p, constructRecipes, input) - return - } - - lastRecipeID, _ := p.Flags["last_construct"].(string) - if lastRecipeID != "" { - for i := range constructRecipes { - if constructRecipes[i].ID == lastRecipeID { - r := &constructRecipes[i] - if g.canDoConstructRecipe(p, r) { - g.startConstructRecipe(sess, p, r, 0) - return - } - break - } - } - } - - available := g.availableConstructRecipes(p, constructRecipes) - if len(available) == 0 { - sess.WriteLine("You don't have any materials to construct.") - return - } - - if p.OptionBool("construct_all") && len(available) == 1 { - g.startConstructRecipe(sess, p, &available[0], 0) - return - } - - g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false) -} - -func (g *Game) doConstructWithInput(sess *net.Session, p *player.Player, constructRecipes []action.RecipeDef, input string) { - qty, productName := parseQty(input) - productName = strings.TrimSpace(productName) - - var matched []action.RecipeDef - for _, r := range constructRecipes { - 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 construct that.") - return - } - - var available []action.RecipeDef - for _, r := range matched { - if g.canDoConstructRecipe(p, &r) { - available = append(available, r) - } - } - - if len(available) == 0 { - sess.WriteLine("You don't have the materials or level for that.") - return - } - - if len(available) == 1 { - g.startConstructRecipe(sess, p, &available[0], qty) - return - } - - g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false) -} - -func (g *Game) constructRecipeVisible(p *player.Player, r *action.RecipeDef) bool { - if len(r.Station) > 0 { - stID, _ := g.findStation(p.RoomID, r.Station) - if stID == "" { - return false - } - } - if r.Tool != "" && !g.hasToolType(p, r.Tool) { - return false - } - return true -} - -func (g *Game) canDoConstructRecipe(p *player.Player, r *action.RecipeDef) bool { - if p.Level(player.Construction) < r.Level { - return false - } - if !r.HasAllItemsQty(p.CountItem) { - return false - } - if len(r.Station) > 0 { - stID, _ := g.findStation(p.RoomID, r.Station) - if stID == "" { - return false - } - } - if r.Tool != "" && !g.hasToolType(p, r.Tool) { - return false - } - return true -} - -func (g *Game) availableConstructRecipes(p *player.Player, allConstruct []action.RecipeDef) []action.RecipeDef { - var result []action.RecipeDef - for _, r := range allConstruct { - if r.HasAllItemsQty(p.CountItem) { - result = append(result, r) - } - } - return result -} - -func (g *Game) startConstructRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags["last_construct"] = recipe.ID - g.AccountStore.SaveCharacter(p) - - g.startProductionFromRecipe(sess, p, recipe, count) -} diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go index 9b76a84..5b78745 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -9,8 +9,8 @@ import ( ) func (g *Game) doCook(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, stationName := g.findStation(p.RoomID, []string{"fire", "cooking_range"}) if stationDefID == "" { @@ -66,7 +66,7 @@ func (g *Game) doCook(sess *net.Session, input string) { return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -116,7 +116,11 @@ func (g *Game) showCookMenu(sess *net.Session, p *player.Player, allRecipes []ac names = append(names, e.ItemName) } g.showMenuTable(sess, fmt.Sprintf("What would you like to cook on the %s?", stationName), names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } func (g *Game) recipeName(sess *net.Session, r *action.RecipeDef) string { diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 50a99e0..7f8808b 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -6,12 +6,19 @@ import ( "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) doCraft(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + g.doGenericStationSkill(sess, input, "crafting", player.Crafting, "last_craft", "Crafting", "craft", "Craft") +} + +func (g *Game) doConstruct(sess *net.Session, input string) { + g.doGenericStationSkill(sess, input, "construction", player.Construction, "last_construct", "Construction", "construct", "Construct") +} + +func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType string, skill player.SkillName, flagKey, label, verbPast, verbCap string) { + p := sess.Player + g.cancelAction(p) allRecipes, err := g.RecipeStore.LoadAll() if err != nil { @@ -19,29 +26,29 @@ func (g *Game) doCraft(sess *net.Session, input string) { return } - var craftRecipes []action.RecipeDef + var recipes []action.RecipeDef for _, r := range allRecipes { - if r.Type != "crafting" { + if r.Type != recipeType { continue } - if !g.craftRecipeVisible(p, &r) { + if !g.genericRecipeVisible(p, &r) { continue } - craftRecipes = append(craftRecipes, r) + recipes = append(recipes, r) } if input != "" { - g.doCraftWithInput(sess, p, craftRecipes, input) + g.doGenericWithInput(sess, p, recipes, input, skill, flagKey, label, verbCap) return } - lastRecipeID, _ := p.Flags["last_craft"].(string) + lastRecipeID, _ := p.Flags[flagKey].(string) if lastRecipeID != "" { - for i := range craftRecipes { - if craftRecipes[i].ID == lastRecipeID { - r := &craftRecipes[i] - if g.canDoCraftRecipe(p, r) { - g.startCraftRecipe(sess, p, r, 0) + for i := range recipes { + if recipes[i].ID == lastRecipeID { + r := &recipes[i] + if g.canDoGenericRecipe(p, r, skill) { + g.startGenericRecipe(sess, p, r, 0, flagKey) return } break @@ -49,44 +56,45 @@ func (g *Game) doCraft(sess *net.Session, input string) { } } - available := g.availableCraftRecipes(p, craftRecipes) + available := g.availableGenericRecipes(p, recipes) if len(available) == 0 { - sess.WriteLine("You don't have any materials to craft.") + sess.WriteLine("You don't have any materials to " + verbPast + ".") return } - if p.OptionBool("craft_all") && len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], 0) + optionKey := verbPast + "_all" + if p.OptionBool(optionKey) && len(available) == 1 { + g.startGenericRecipe(sess, p, &available[0], 0, flagKey) return } - g.showProductionTable(sess, p, available, "Crafting", "crafting", "last_craft", "Craft", false) + g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false) } -func (g *Game) doCraftWithInput(sess *net.Session, p *player.Player, craftRecipes []action.RecipeDef, input string) { +func (g *Game) doGenericWithInput(sess *net.Session, p *player.Player, recipes []action.RecipeDef, input string, skill player.SkillName, flagKey, label, verbCap string) { qty, productName := parseQty(input) productName = strings.TrimSpace(productName) var matched []action.RecipeDef - for _, r := range craftRecipes { + for _, r := range recipes { outDef, _ := g.ItemStore.Load(r.Output) name := r.Output if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(productName, name) { + if action.WordPrefixMatch(productName, name) { matched = append(matched, r) } } if len(matched) == 0 { - sess.WriteLine("You can't craft that.") + sess.WriteLine("You can't " + strings.ToLower(verbCap) + " that.") return } var available []action.RecipeDef for _, r := range matched { - if g.canDoCraftRecipe(p, &r) { + if g.canDoGenericRecipe(p, &r, skill) { available = append(available, r) } } @@ -97,14 +105,14 @@ func (g *Game) doCraftWithInput(sess *net.Session, p *player.Player, craftRecipe } if len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], qty) + g.startGenericRecipe(sess, p, &available[0], qty, flagKey) return } - g.showProductionTable(sess, p, available, "Crafting", "crafting", "last_craft", "Craft", false) + g.showProductionTable(sess, p, available, label, label, flagKey, verbCap, false) } -func (g *Game) craftRecipeVisible(p *player.Player, r *action.RecipeDef) bool { +func (g *Game) genericRecipeVisible(p *player.Player, r *action.RecipeDef) bool { if len(r.Station) > 0 { stID, _ := g.findStation(p.RoomID, r.Station) if stID == "" { @@ -117,8 +125,8 @@ func (g *Game) craftRecipeVisible(p *player.Player, r *action.RecipeDef) bool { return true } -func (g *Game) canDoCraftRecipe(p *player.Player, r *action.RecipeDef) bool { - if p.Level(player.Crafting) < r.Level { +func (g *Game) canDoGenericRecipe(p *player.Player, r *action.RecipeDef, skill player.SkillName) bool { + if p.Level(skill) < r.Level { return false } if !r.HasAllItemsQty(p.CountItem) { @@ -136,9 +144,9 @@ func (g *Game) canDoCraftRecipe(p *player.Player, r *action.RecipeDef) bool { return true } -func (g *Game) availableCraftRecipes(p *player.Player, allCraft []action.RecipeDef) []action.RecipeDef { +func (g *Game) availableGenericRecipes(p *player.Player, allRecipes []action.RecipeDef) []action.RecipeDef { var result []action.RecipeDef - for _, r := range allCraft { + for _, r := range allRecipes { if r.HasAllItemsQty(p.CountItem) { result = append(result, r) } @@ -146,11 +154,9 @@ func (g *Game) availableCraftRecipes(p *player.Player, allCraft []action.RecipeD return result } -func (g *Game) startCraftRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags["last_craft"] = recipe.ID +func (g *Game) startGenericRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int, flagKey string) { + p.EnsureFlags() + p.Flags[flagKey] = recipe.ID g.AccountStore.SaveCharacter(p) g.startProductionFromRecipe(sess, p, recipe, count) diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go index 20ada37..ced5dcc 100644 --- a/internal/game/cmd_description.go +++ b/internal/game/cmd_description.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doDescription(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") if p.Description != "" { sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description)) @@ -22,7 +21,7 @@ func (g *Game) doDescription(sess *net.Session) { func (g *Game) handleDescriptionChange(sess *net.Session, input string) { if input != "" { - p := sess.Player.(*player.Player) + p := sess.Player p.Description = input g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("Description set to: %s", input)) diff --git a/internal/game/cmd_drink.go b/internal/game/cmd_drink.go index c7f9b34..44ef991 100644 --- a/internal/game/cmd_drink.go +++ b/internal/game/cmd_drink.go @@ -12,7 +12,7 @@ import ( ) func (g *Game) doDrink(sess *net.Session, args []string) { - p := sess.Player.(*player.Player) + p := sess.Player if len(args) == 0 { sess.WriteLine("Drink what?") @@ -57,7 +57,7 @@ func (g *Game) doDrink(sess *net.Session, args []string) { return } - g.CancelAction(p) + g.cancelAction(p) g.consumeQueue[p.Name] = &QueuedCommand{ Session: sess, diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 1fd99b4..d3141e3 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -5,12 +5,11 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doDropAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) p.ActionState = &ActionState{Type: ActionDropping, TargetName: "inventory"} count := 0 @@ -28,8 +27,8 @@ func (g *Game) doDropAll(sess *net.Session) { } func (g *Game) doDropAllNamed(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -86,8 +85,8 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { } func (g *Game) doDrop(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) qty, itemName := parseQty(input) @@ -182,14 +181,14 @@ func (g *Game) doDrop(sess *net.Session, input string) { } func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { sess.State = net.StateGame g.writePrompt(sess) return } - g.CancelAction(p) + g.cancelAction(p) input = strings.TrimSpace(strings.ToLower(input)) if input != "y" && input != "yes" { sess.State = net.StateGame diff --git a/internal/game/cmd_eat.go b/internal/game/cmd_eat.go index e6a0386..f94ade3 100644 --- a/internal/game/cmd_eat.go +++ b/internal/game/cmd_eat.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doEat(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { sess.WriteLine("Eat what?") @@ -52,7 +52,7 @@ func (g *Game) doEat(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) g.consumeQueue[p.Name] = &QueuedCommand{ Session: sess, diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go index b256bc3..f5a9a11 100644 --- a/internal/game/cmd_equipment.go +++ b/internal/game/cmd_equipment.go @@ -5,11 +5,10 @@ import ( "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" ) func (g *Game) doEquipment(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") sess.WriteLine("Equipment:") diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index 6c66ca4..c79135f 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -15,12 +15,7 @@ import ( ) func (g *Game) startEstateDirectory(sess *net.Session, verb string) { - p := sess.Player.(*player.Player) - - if verb == "toggle" { - sess.WriteLine("You can't do that with the directory.") - return - } + p := sess.Player homeowners := g.scanHomeowners("owns_house_local_neighborhood") ownedByMe := false @@ -40,7 +35,7 @@ func (g *Game) startEstateDirectory(sess *net.Session, verb string) { if ownedByMe { houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") if houseRoom > 0 { - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, houseRoom) return } @@ -82,7 +77,7 @@ func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, home Type: "talk", TargetID: "estate_directory", TargetName: "Estate Directory", - Data: map[string]any{"node": "start", "behavior_id": "estate_directory_talk"}, + Data: map[string]any{"node": "start", "estate_directory": true}, } sess.Write("\nChoice: ") return @@ -101,8 +96,8 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { switch input { case "enter": - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { return } houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") @@ -118,8 +113,8 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { p.ActionState = nil g.writePrompt(sess) case "leave", "bye", "exit", "quit": - p, ok := sess.Player.(*player.Player) - if ok { + p := sess.Player + if p != nil { p.Action = nil p.ActionState = nil } @@ -134,7 +129,7 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { func (g *Game) scanHomeowners(flagKey string) []string { var homeowners []string - charsDir := filepath.Join(g.dataDir, "players", "characters") + charsDir := filepath.Join(g.DataDir, "players", "characters") entries, err := os.ReadDir(charsDir) if err != nil { return homeowners @@ -179,9 +174,7 @@ func (g *Game) scanHomeowners(flagKey string) []string { } func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { - if p.Flags == nil { - return 0 - } + p.EnsureFlags() val, ok := p.Flags[flagKey] if !ok || val == nil { return 0 @@ -218,6 +211,6 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) { g.seedRoomObjects(p.RoomID) g.AccountStore.SaveCharacter(p) g.doLook(sess) - g.RunEnterSteps(sess, p.RoomID) + g.runEnterSteps(sess, p.RoomID) g.writePrompt(sess) } diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go index 0a374f0..5ef36d9 100644 --- a/internal/game/cmd_farm.go +++ b/internal/game/cmd_farm.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doPlant(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { sess.WriteLine("Plant what?") @@ -93,7 +93,7 @@ func (g *Game) doPlant(sess *net.Session, input string) { } func (g *Game) doHarvest(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player prefix, _, _ := g.findFarmPatch(sess, p, input, func(flags map[string]any, prefix string) bool { ready, _ := flags[prefix+"_ready"].(bool) @@ -179,7 +179,7 @@ func (g *Game) doHarvest(sess *net.Session, input string) { } func (g *Game) doRake(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !g.hasToolType(p, "rake") { sess.WriteLine("You need a rake to do that.") @@ -225,7 +225,7 @@ func (g *Game) doRake(sess *net.Session, input string) { } func (g *Game) doWater(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !g.hasToolType(p, "watering_can") { sess.WriteLine("You need a watering can to do that.") @@ -285,7 +285,7 @@ func (g *Game) doWater(sess *net.Session, input string) { } func (g *Game) doCure(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !p.HasItem("plant_cure") { sess.WriteLine("You don't have any plant cure.") @@ -329,7 +329,7 @@ func (g *Game) doCure(sess *net.Session, input string) { } func (g *Game) doInspect(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player objInstances := g.World.AllObjInstances(p.RoomID) var farmPatches []struct { @@ -437,7 +437,7 @@ func (g *Game) doInspect(sess *net.Session, input string) { continue } - stage := intFlag(p.Flags, fp.prefix+"_stage") + stage := intFromFlag(p.Flags, fp.prefix+"_stage") maxStages := 4 if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { maxStages = def.FarmStages diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go index 60acb92..1f6a075 100644 --- a/internal/game/cmd_fletch.go +++ b/internal/game/cmd_fletch.go @@ -7,7 +7,6 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) var fletchLogItems = map[string]bool{ @@ -51,7 +50,7 @@ func fletchNeedsChisel(r *action.RecipeDef) bool { } func (g *Game) doFletch(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -121,7 +120,7 @@ func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, fletchReci if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(productName, name) { + if action.WordPrefixMatch(productName, name) { matched = append(matched, r) } } diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 6f08892..bae721b 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -8,8 +8,8 @@ import ( ) func (g *Game) doGet(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") @@ -129,8 +129,8 @@ func (g *Game) doGet(sess *net.Session, input string) { } func (g *Game) doGetAllNamed(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") @@ -251,8 +251,8 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { } func (g *Game) doGetAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") diff --git a/internal/game/cmd_handlers.go b/internal/game/cmd_handlers.go new file mode 100644 index 0000000..b8db359 --- /dev/null +++ b/internal/game/cmd_handlers.go @@ -0,0 +1,370 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeGet(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Get what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doGetAll(sess) + } else { + g.doGetAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doGet(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Drop what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doDropAll(sess) + } else { + g.doDropAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doDrop(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + p := sess.Player + target := g.resolveDefaultMob(p.RoomID) + if target == "" { + sess.WriteLine("Attack what?") + return + } + g.doAttack(sess, target) + } else { + g.doAttack(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeStyle(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doStyle(sess, "") + } else { + g.doStyle(sess, args[0]) + } +} + +func (g *Game) executeLook(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doLook(sess) + } else { + g.doLookTarget(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { + g.doMove(sess, strings.TrimSpace(rawInput), 1.0) +} + +func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Say what?") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 + if msgStart >= 4 && msgStart < len(rawInput) { + g.doSay(sess, rawInput[msgStart:]) + } else { + g.doSay(sess, strings.Join(args, " ")) + } + } +} + +func (g *Game) executeScore(sess *net.Session, args []string, rawInput string) { + g.doScore(sess) +} + +func (g *Game) executeTask(sess *net.Session, args []string, rawInput string) { + g.doTask(sess) +} + +func (g *Game) executeStats(sess *net.Session, args []string, rawInput string) { + g.doStats(sess) +} + +func (g *Game) executeTech(sess *net.Session, args []string, rawInput string) { + g.doTech(sess, strings.Join(args, " ")) +} + +func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { + g.doInventory(sess) +} + +func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { + g.doQuit(sess) +} + +func (g *Game) executeDescription(sess *net.Session, args []string, rawInput string) { + g.doDescription(sess) +} + +func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) { + g.doOption(sess, strings.Join(args, " ")) +} + +func (g *Game) executeColor(sess *net.Session, args []string, rawInput string) { + g.doColor(sess, strings.Join(args, " ")) +} + +func (g *Game) executeColortable(sess *net.Session, args []string, rawInput string) { + g.doColortable(sess) +} + +func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doPrompt(sess, "") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 + if msgStart >= 7 && msgStart < len(rawInput) { + g.doPrompt(sess, rawInput[msgStart:]) + } else { + g.doPrompt(sess, strings.Join(args, " ")) + } + } +} + +func (g *Game) executeExits(sess *net.Session, args []string, rawInput string) { + g.doExits(sess) +} + +func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) { + g.doMap(sess) +} + +func (g *Game) executeQueued(sess *net.Session, args []string, rawInput string) { + g.doQueued(sess) +} + +func (g *Game) executeHelp(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doHelp(sess, "") + } else { + g.doHelp(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeGather(sess *net.Session, args []string, rawInput string) { + verb := strings.TrimSpace(rawInput) + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, verb) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", verb)) + return + } + g.startAction(sess, verb, target) + } else { + g.startAction(sess, verb, strings.Join(args, " ")) + } +} + +func (g *Game) executeUse(sess *net.Session, args []string, rawInput string) { + if g.tryFinishingBlow(sess, strings.Join(args, " ")) { + return + } + g.doUse(sess, strings.Join(args, " ")) +} + +func (g *Game) executeCook(sess *net.Session, args []string, rawInput string) { + g.doCook(sess, strings.Join(args, " ")) +} + +func (g *Game) executeSmelt(sess *net.Session, args []string, rawInput string) { + g.doSmelt(sess, strings.Join(args, " ")) +} + +func (g *Game) executeSmith(sess *net.Session, args []string, rawInput string) { + g.doSmith(sess, strings.Join(args, " ")) +} + +func (g *Game) executeCraft(sess *net.Session, args []string, rawInput string) { + g.doCraft(sess, strings.Join(args, " ")) +} + +func (g *Game) executeEat(sess *net.Session, args []string, rawInput string) { + g.doEat(sess, strings.Join(args, " ")) +} + +func (g *Game) executeDrink(sess *net.Session, args []string, rawInput string) { + g.doDrink(sess, args) +} + +func (g *Game) executeFletch(sess *net.Session, args []string, rawInput string) { + g.doFletch(sess, strings.Join(args, " ")) +} + +func (g *Game) executeClean(sess *net.Session, args []string, rawInput string) { + g.doClean(sess, strings.Join(args, " ")) +} + +func (g *Game) executeMix(sess *net.Session, args []string, rawInput string) { + g.doMix(sess, strings.Join(args, " ")) +} + +func (g *Game) executeIdentify(sess *net.Session, args []string, rawInput string) { + g.doIdentify(sess, strings.Join(args, " ")) +} + +func (g *Game) executeAutotrigger(sess *net.Session, args []string, rawInput string) { + g.doAutotrigger(sess, strings.Join(args, " ")) +} + +func (g *Game) executeMods(sess *net.Session, args []string, rawInput string) { + showAll := len(args) > 0 && args[0] == "all" + g.doMods(sess, showAll) +} + +func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { + g.doSneak(sess) +} + +func (g *Game) executeTrigger(sess *net.Session, args []string, rawInput string) { + g.doTrigger(sess, strings.Join(args, " ")) +} + +func (g *Game) executeTalk(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.startAction(sess, "talk", strings.Join(args, " ")) + } +} + +func (g *Game) executeBurn(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doBurn(sess, p, strings.Join(args, " ")) +} + +func (g *Game) executeStoke(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doStoke(sess, p, strings.Join(args, " ")) +} + +func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) { + g.doAlias(sess, args) +} + +func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) { + g.doUnalias(sess, args) +} + +func (g *Game) executeSearch(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Search what?") + } else { + g.doSearch(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doSteal(sess, "") + } else { + g.doSteal(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executePlant(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Plant what?") + } else { + g.doPlant(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeHarvest(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doHarvest(sess, "") + } else { + g.doHarvest(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeRake(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doRake(sess, "") + } else { + g.doRake(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeWater(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doWater(sess, "") + } else { + g.doWater(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doCure(sess, "") + } else { + g.doCure(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doInspect(sess, "") + } else { + g.doInspect(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeWalk(sess *net.Session, args []string, rawInput string) { + g.doWalk(sess, args) +} + +func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { + g.doBank(sess) +} + +func (g *Game) executeConstruct(sess *net.Session, args []string, rawInput string) { + g.doConstruct(sess, strings.Join(args, " ")) +} + +func (g *Game) executeJack(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doJack(sess, strings.Join(args, " ")) +} + +func (g *Game) executeWear(sess *net.Session, args []string, rawInput string) { + g.doWear(sess, strings.Join(args, " ")) +} + +func (g *Game) executeRemove(sess *net.Session, args []string, rawInput string) { + g.doRemove(sess, strings.Join(args, " ")) +} diff --git a/internal/game/cmd_id.go b/internal/game/cmd_id.go index 5ea146d..f7158e9 100644 --- a/internal/game/cmd_id.go +++ b/internal/game/cmd_id.go @@ -82,8 +82,8 @@ func junkMultiplier(junkID string, level int) int { } func (g *Game) doIdentify(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) var found altarConfig foundAny := false diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index 8684fcd..ae7b328 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doInventory(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots())) diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go index db157f3..8cb5062 100644 --- a/internal/game/cmd_jack.go +++ b/internal/game/cmd_jack.go @@ -5,12 +5,13 @@ import ( "strings" "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) doJack(sess *net.Session, target string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -32,7 +33,7 @@ func (g *Game) doJack(sess *net.Session, target string) { } defID := instances[0].DefID - tDef, ok := terminalDefs[defID] + tDef, ok := hacking.Terminals[defID] if !ok { sess.WriteLine("You can't jack into that.") return @@ -44,13 +45,13 @@ func (g *Game) doJack(sess *net.Session, target string) { return } - g.CancelAction(p) - g.CancelBackgroundAction(p) + g.cancelAction(p) + g.cancelBackgroundAction(p) minigame := tDef.Minigame() display := minigame.Init(hackLevel) - g.hackingStates[p.Name] = &HackingSession{ + g.hackingStates[p.Name] = &hacking.Session{ Minigame: minigame, TerminalID: defID, Level: hackLevel, diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 6c83130..5fa2cf4 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -15,18 +15,30 @@ import ( ) func (g *Game) doLook(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player room, err := g.World.LoadRoom(p.RoomID) if err != nil { sess.WriteLine("You are in a void.") return } + g.showRoomName(sess, p, room) + g.showRoomDescription(sess, p, room) + g.showRoomMobs(sess, p, room) + g.showRoomObjects(sess, p, room) + g.showGroundItems(sess, p, room) + g.showRoomExits(sess, p, room) + g.showRoomPlayers(sess, p, room) +} + +func (g *Game) showRoomName(sess *net.Session, p *player.Player, room *world.Room) { sess.WriteLines( "", fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))), ) +} +func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *world.Room) { descWidth := p.OptionInt("room_desc_width") if descWidth <= 0 { descWidth = 70 @@ -51,8 +63,10 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine(line) } } +} - mobs := g.MobStore.MobsInRoom(p.RoomID) +func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) { + mobs := g.MobStore.MobsInRoom(p.RoomID) if len(mobs) > 0 { sort.Slice(mobs, func(i, j int) bool { iDamaged := mobs[i].HP < mobs[i].MaxHP @@ -93,7 +107,9 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, npcColor, displayName), levelStr, hp, desc)) } } +} +func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.Room) { objs := g.World.AllObjInstances(p.RoomID) if len(objs) > 0 { sess.WriteLine("") @@ -155,6 +171,9 @@ func (g *Game) doLook(sess *net.Session) { } roomDesc := def.InRoomDescription + if roomDesc != "" { + roomDesc = color.ExpandTags(g.colorMode(sess), roomDesc) + } coloredName := g.objColorize(sess, def, def.Name) coloredPlural := g.objColorize(sess, def, def.Name+"s") @@ -217,7 +236,9 @@ func (g *Game) doLook(sess *net.Session) { } g.showFarmPatches(sess, p) +} +func (g *Game) showGroundItems(sess *net.Session, p *player.Player, room *world.Room) { ground := g.World.GroundItemsDetailed(p.RoomID) if len(ground) > 0 { showDespawn := p.OptionBool("despawn") @@ -334,13 +355,15 @@ func (g *Game) doLook(sess *net.Session) { } } } +} +func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) { if len(room.Exits) > 0 { sess.WriteLine("") - if p.OptionBool("exits") { + if p.OptionBool("exits") { sess.WriteLine("Exits:") type exitLine struct { - dir string + dir string targetName string } var lines []exitLine @@ -383,11 +406,13 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine("") } } +} +func (g *Game) showRoomPlayers(sess *net.Session, p *player.Player, room *world.Room) { others := g.Hub.PlayersInRoom(p.RoomID) for _, other := range others { if other != sess && other.Player != nil { - op := other.Player.(*player.Player) + op := other.Player line := fmt.Sprintf("\n%s is here", g.colorize(sess, "player_name", op.Name)) if cs := combat.GetCombat(op.Name); cs != nil { if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { @@ -408,7 +433,7 @@ func (g *Game) doLook(sess *net.Session) { } func (g *Game) doLookTarget(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(input) if exitDir := g.World.ResolveExit(lower); exitDir != "" { @@ -572,7 +597,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { if other == sess || other.Player == nil { continue } - op := other.Player.(*player.Player) + op := other.Player if strings.ToLower(op.Name) != lower { continue } @@ -584,7 +609,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { - myP := sess.Player.(*player.Player) + myP := sess.Player theirLevel := p.CombatLevel() levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel)) sess.WriteLines( @@ -623,7 +648,7 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { } func (g *Game) doExits(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player room, err := g.World.LoadRoom(p.RoomID) if err != nil || len(room.Exits) == 0 { sess.WriteLine("There are no exits here.") diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go index 1c78be3..7cad008 100644 --- a/internal/game/cmd_map.go +++ b/internal/game/cmd_map.go @@ -2,11 +2,10 @@ package game import ( "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doMap(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mapWidth := p.OptionInt("mapwidth") mapHeight := p.OptionInt("mapheight") if mapWidth < 5 { diff --git a/internal/game/cmd_mix.go b/internal/game/cmd_mix.go index 3082870..8fc600b 100644 --- a/internal/game/cmd_mix.go +++ b/internal/game/cmd_mix.go @@ -4,12 +4,11 @@ import ( "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) doMix(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) allRecipes, err := g.RecipeStore.LoadAll() if err != nil { @@ -35,7 +34,7 @@ func (g *Game) doMix(sess *net.Session, input string) { if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(itemName, name) { + if action.WordPrefixMatch(itemName, name) { matched = append(matched, r) } } @@ -68,7 +67,7 @@ func (g *Game) doMix(sess *net.Session, input string) { names = append(names, g.recipeName(sess, &r)) } g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = recipeMenuData(available) + sess.PendingMenu = recipeMenuData(recipeDefIDs(available)) } func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef) { @@ -109,5 +108,9 @@ func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []act names = append(names, e.ItemName) } g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go index b9161e3..65d7ad7 100644 --- a/internal/game/cmd_mods.go +++ b/internal/game/cmd_mods.go @@ -3,109 +3,96 @@ package game import ( "fmt" "sort" - "strings" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) -func (g *Game) doMods(sess *net.Session) { - p := sess.Player.(*player.Player) - mode := g.colorMode(sess) +func (g *Game) doMods(sess *net.Session, showAll bool) { + p := sess.Player sciLevel := p.Level(player.Science) + mode := g.colorMode(sess) categories := []struct { Name string Cat ModCategory }{ {"Combat", ModCombat}, - {"Processing", ModProcessing}, {"Utility", ModUtility}, {"Transport", ModTransport}, {"Enchantment", ModEnchant}, } sess.WriteLine("") - anyMods := false for _, cat := range categories { - var mods []*ModDef + var catMods []*ModDef for _, m := range AllMods { - if m.Category == cat.Cat && m.Level <= sciLevel { - mods = append(mods, m) + if m.Category != cat.Cat { + continue + } + if !showAll && m.Level > sciLevel { + continue } + catMods = append(catMods, m) } - if len(mods) == 0 { + if len(catMods) == 0 { continue } + anyMods = true - sort.Slice(mods, func(i, j int) bool { - return mods[i].Level < mods[j].Level + sort.Slice(catMods, func(i, j int) bool { + return catMods[i].Level < catMods[j].Level }) - t := &Table{Title: cat.Name + " Mods", Columns: []string{ - color.Render(mode, color.Parse("75"), "Mod"), - color.Render(mode, color.Parse("230"), "Lv"), - color.Render(mode, color.Parse("245"), "Cost"), - color.Render(mode, color.Parse("222"), "XP"), - }} + t := &Table{ + Title: cat.Name, + Columns: []string{ + color.Render(mode, color.Parse("75"), "Module"), + color.Render(mode, color.Parse("245"), "Level"), + color.Render(mode, color.Parse("222"), "Cost"), + color.Render(mode, color.Parse("179"), "XP"), + }, + } + + for _, m := range catMods { + unlocked := m.Level <= sciLevel + levelStr := fmt.Sprintf("Lv %d", m.Level) + costStr := g.junkCostString(p, m) + xpStr := fmt.Sprintf("%d XP", m.BaseXP) - for _, m := range mods { - costStr := g.modCostDisplay(p, m) - xpStr := fmt.Sprintf("%.1f", m.BaseXP) - if m.MaxHit > 0 { - xpStr += fmt.Sprintf(" (max %d)", m.MaxHit) + if unlocked { + t.Rows = append(t.Rows, []string{ + color.Render(mode, color.Parse("75"), m.Name), + color.Render(mode, color.Parse("245"), levelStr), + color.Render(mode, color.Parse("222"), costStr), + color.Render(mode, color.Parse("179"), xpStr), + }) + } else { + t.Rows = append(t.Rows, []string{ + g.colorize(sess, "dim", m.Name), + g.colorize(sess, "dim", levelStr), + g.colorize(sess, "dim", costStr), + g.colorize(sess, "dim", xpStr), + }) } - t.Rows = append(t.Rows, []string{ - color.Render(mode, color.Parse("75"), m.Name), - color.Render(mode, color.Parse("230"), fmt.Sprint(m.Level)), - color.Render(mode, color.Parse("245"), costStr), - color.Render(mode, color.Parse("222"), xpStr), - }) } for _, line := range t.Render(p.OptionBool("unicode")) { sess.WriteLine(line) } - anyMods = true + sess.WriteLine("") } if !anyMods { - sess.WriteLine("You don't know any mods yet. Train Science to unlock mods.") + sess.WriteLine("No modules found.") } - if p.AutocastMod != "" { - mod := GetMod(p.AutocastMod) + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) if mod != nil { - sess.WriteLine(fmt.Sprintf("\nAutocast: %s", g.colorize(sess, "science_mod", mod.Name))) - } - } -} - -func (g *Game) modCostDisplay(p *player.Player, mod *ModDef) string { - cost := g.effectiveJunkCost(p, mod) - if len(cost) == 0 { - return "free" - } - var parts []string - keys := make([]string, 0, len(cost)) - for k := range cost { - keys = append(keys, k) - } - sort.Strings(keys) - for _, itemID := range keys { - qty := cost[itemID] - def, _ := g.ItemStore.Load(itemID) - name := itemID - if def != nil { - name = def.Name - } - if qty > 1 { - parts = append(parts, fmt.Sprintf("%d %s", qty, name)) - } else { - parts = append(parts, name) + sess.WriteLine(fmt.Sprintf("Autotrigger: %s", g.colorize(sess, "science_mod", mod.Name))) } } - return strings.Join(parts, ", ") } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index b3fe254..5d6ea66 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -10,8 +10,8 @@ import ( "thehouseoficarus/internal/player" ) -func (g *Game) doMove(sess *net.Session, dir string) { - p := sess.Player.(*player.Player) +func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { + p := sess.Player exitDir := g.World.ResolveExit(dir) if exitDir == "" { sess.WriteLine("Go where?") @@ -50,10 +50,10 @@ func (g *Game) doMove(sess *net.Session, dir string) { inCombat := combat.GetCombat(p.Name) != nil if !inCombat && p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - ticks := g.moveTicks(p) + ticks := g.moveTicks(p, multiplier) p.MoveDirection = string(exitDir) p.MoveTarget = targetID @@ -83,7 +83,7 @@ var gracefulItems = map[string]bool{ "graceful_cape": true, } -func (g *Game) moveTicks(p *player.Player) int { +func (g *Game) moveTicks(p *player.Player, multiplier float64) int { if id, ok := p.Equipment[object.SlotBack]; ok && id == "cape_of_agility" { return 0 } @@ -95,11 +95,12 @@ func (g *Game) moveTicks(p *player.Player) int { } } - base := 4.0 - float64(count)*0.25 + base := 2.0 - float64(count)*0.15 if count == 6 { - base -= 0.5 + base -= 0.1 } + base *= multiplier return engine.ToTicks(base) - 1 } @@ -152,7 +153,7 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { g.doMap(sess) } - g.RunEnterSteps(sess, targetID) + g.runEnterSteps(sess, targetID) g.checkAggro(sess) } @@ -183,11 +184,8 @@ func (g *Game) seedRoomObjects(roomID int) { if len(obj.WanderRooms) > 0 { g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval) } - if def.BehaviorID != "" { - cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err == nil && cfg.DepleteTimer > 0 { - g.World.SetObjDepleteTimer(roomID, obj.ID, cfg.DepleteTimer) - } + if def.IsGatherable() && def.Gather.DepleteTimer > 0 { + g.World.SetObjDepleteTimer(roomID, obj.ID, def.Gather.DepleteTimer) } } } diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index 3d4d420..fdf5371 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doOption(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) if input == "" { diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go index 6cc45f9..8850ea1 100644 --- a/internal/game/cmd_prompt.go +++ b/internal/game/cmd_prompt.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doPrompt(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { prompt := p.Prompt diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go index d9e92d5..fdb1643 100644 --- a/internal/game/cmd_queued.go +++ b/internal/game/cmd_queued.go @@ -5,11 +5,10 @@ import ( "sort" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doQueued(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player freeCmds := g.freeQueue[p.Name] activeCmd := g.activeQueue[p.Name] diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 2ec0221..d52c9ae 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -6,18 +6,17 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doQuit(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't rest during combat!") return } - g.CancelAction(p) + g.cancelAction(p) p.ActionState = &ActionState{Type: ActionResting} g.cancelRest(p.Name) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go new file mode 100644 index 0000000..e5bf30e --- /dev/null +++ b/internal/game/cmd_registry.go @@ -0,0 +1,170 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +type commandHandler func(g *Game, sess *net.Session, args []string, rawInput string) + +type commandDef struct { + handler commandHandler + class CommandClass +} + +var commandRegistry = map[string]commandDef{ + "get": {(*Game).executeGet, ClassActive}, + "take": {(*Game).executeGet, ClassActive}, + "grab": {(*Game).executeGet, ClassActive}, + "pick": {(*Game).executeGet, ClassActive}, + "drop": {(*Game).executeDrop, ClassActive}, + "attack": {(*Game).executeAttack, ClassActive}, + "kill": {(*Game).executeAttack, ClassActive}, + "style": {(*Game).executeStyle, ClassInstant}, + "look": {(*Game).executeLook, ClassInstant}, + "l": {(*Game).executeLook, ClassInstant}, + "north": {(*Game).executeMove, ClassActive}, + "n": {(*Game).executeMove, ClassActive}, + "south": {(*Game).executeMove, ClassActive}, + "s": {(*Game).executeMove, ClassActive}, + "east": {(*Game).executeMove, ClassActive}, + "e": {(*Game).executeMove, ClassActive}, + "west": {(*Game).executeMove, ClassActive}, + "w": {(*Game).executeMove, ClassActive}, + "up": {(*Game).executeMove, ClassActive}, + "u": {(*Game).executeMove, ClassActive}, + "down": {(*Game).executeMove, ClassActive}, + "d": {(*Game).executeMove, ClassActive}, + "say": {(*Game).executeSay, ClassInstant}, + "sc": {(*Game).executeScore, ClassInstant}, + "score": {(*Game).executeScore, ClassInstant}, + "task": {(*Game).executeTask, ClassInstant}, + "stats": {(*Game).executeStats, ClassInstant}, + "tech": {(*Game).executeTech, ClassInstant}, + "t": {(*Game).executeTech, ClassInstant}, + "i": {(*Game).executeInventory, ClassInstant}, + "inv": {(*Game).executeInventory, ClassInstant}, + "inventory": {(*Game).executeInventory, ClassInstant}, + "quit": {(*Game).executeQuit, ClassActive}, + "description": {(*Game).executeDescription, ClassInstant}, + "desc": {(*Game).executeDescription, ClassInstant}, + "option": {(*Game).executeOption, ClassInstant}, + "options": {(*Game).executeOption, ClassInstant}, + "color": {(*Game).executeColor, ClassInstant}, + "colors": {(*Game).executeColor, ClassInstant}, + "colortable": {(*Game).executeColortable, ClassInstant}, + "prompt": {(*Game).executePrompt, ClassInstant}, + "exits": {(*Game).executeExits, ClassInstant}, + "map": {(*Game).executeMap, ClassInstant}, + "queued": {(*Game).executeQueued, ClassInstant}, + "help": {(*Game).executeHelp, ClassInstant}, + "mine": {(*Game).executeGather, ClassActive}, + "chop": {(*Game).executeGather, ClassActive}, + "fish": {(*Game).executeGather, ClassActive}, + "cut": {(*Game).executeGather, ClassActive}, + "pull": {(*Game).executeUse, ClassActive}, + "push": {(*Game).executeUse, ClassActive}, + "use": {(*Game).executeUse, ClassActive}, + "cook": {(*Game).executeCook, ClassActive}, + "smelt": {(*Game).executeSmelt, ClassActive}, + "smith": {(*Game).executeSmith, ClassActive}, + "craft": {(*Game).executeCraft, ClassActive}, + "eat": {(*Game).executeEat, ClassFree}, + "drink": {(*Game).executeDrink, ClassFree}, + "fletch": {(*Game).executeFletch, ClassFree}, + "clean": {(*Game).executeClean, ClassFree}, + "mix": {(*Game).executeMix, ClassActive}, + "id": {(*Game).executeIdentify, ClassActive}, + "identify": {(*Game).executeIdentify, ClassActive}, + "autotrigger": {(*Game).executeAutotrigger, ClassInstant}, + "auto": {(*Game).executeAutotrigger, ClassInstant}, + "mods": {(*Game).executeMods, ClassInstant}, + "modlist": {(*Game).executeMods, ClassInstant}, + "mod": {(*Game).executeMods, ClassInstant}, + "sneak": {(*Game).executeSneak, ClassInstant}, + "trigger": {(*Game).executeTrigger, ClassActive}, + "talk": {(*Game).executeTalk, ClassActive}, + "speak": {(*Game).executeTalk, ClassActive}, + "ask": {(*Game).executeTalk, ClassActive}, + "burn": {(*Game).executeBurn, ClassActive}, + "stoke": {(*Game).executeStoke, ClassActive}, + "alias": {(*Game).executeAlias, ClassInstant}, + "unalias": {(*Game).executeUnalias, ClassInstant}, + "search": {(*Game).executeSearch, ClassActive}, + "steal": {(*Game).executeSteal, ClassActive}, + "thieve": {(*Game).executeSteal, ClassActive}, + "plant": {(*Game).executePlant, ClassActive}, + "harvest": {(*Game).executeHarvest, ClassActive}, + "rake": {(*Game).executeRake, ClassActive}, + "water": {(*Game).executeWater, ClassActive}, + "cure": {(*Game).executeCure, ClassActive}, + "inspect": {(*Game).executeInspect, ClassInstant}, + "walk": {(*Game).executeWalk, ClassActive}, + "bank": {(*Game).executeBank, ClassActive}, + "construct": {(*Game).executeConstruct, ClassActive}, + "make": {(*Game).executeConstruct, ClassActive}, + "jack": {(*Game).executeJack, ClassActive}, + "jackin": {(*Game).executeJack, ClassActive}, + "eq": {(*Game).executeWear, ClassFree}, + "equipment": {(*Game).executeWear, ClassFree}, + "equip": {(*Game).executeWear, ClassFree}, + "wear": {(*Game).executeWear, ClassFree}, + "wield": {(*Game).executeWear, ClassFree}, + "remove": {(*Game).executeRemove, ClassFree}, + "unwear": {(*Game).executeRemove, ClassFree}, + "unwield": {(*Game).executeRemove, ClassFree}, +} + +func classifyCommand(cmd string) CommandClass { + if def, ok := commandRegistry[cmd]; ok { + return def.class + } + if _, ok := verbAliases[cmd]; ok { + return ClassActive + } + if obstacleVerbs[cmd] { + return ClassActive + } + return ClassUnknown +} + +func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { + if def, ok := commandRegistry[cmd]; ok { + def.handler(g, sess, args, rawInput) + return + } + + if obstacleVerbs[cmd] { + g.doObstacle(sess, cmd) + return + } + + p := sess.Player + if a, ok := verbAliases[cmd]; ok { + g.cancelAction(p) + switch a { + case "gather": + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, cmd) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", cmd)) + return + } + g.startAction(sess, cmd, target) + } else { + g.startAction(sess, cmd, strings.Join(args, " ")) + } + case "talk": + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.startAction(sess, "talk", strings.Join(args, " ")) + } + } + return + } + + sess.WriteLine("Unknown command.") +} diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index d36a8fd..e168fd9 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doRemove(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(strings.TrimSpace(input)) if lower == "all" { @@ -22,7 +22,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) qty, itemName := parseQty(input) @@ -136,7 +136,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { } func (g *Game) doRemoveAll(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if len(p.Equipment) == 0 { sess.WriteLine("You aren't wearing anything.") @@ -153,7 +153,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { return } - g.CancelAction(p) + g.cancelAction(p) for _, slot := range EquipSlots { itemID, ok := p.Equipment[slot] diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go index 67d8165..3d619a0 100644 --- a/internal/game/cmd_say.go +++ b/internal/game/cmd_say.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSay(sess *net.Session, msg string) { - p := sess.Player.(*player.Player) + p := sess.Player roomID := p.RoomID for _, other := range g.Hub.PlayersInRoom(roomID) { diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 82c0803..0b0caf2 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doScore(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) sess.WriteLines( "", diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go index cf37c90..cc22d49 100644 --- a/internal/game/cmd_search.go +++ b/internal/game/cmd_search.go @@ -5,12 +5,11 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSearch(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) lower := strings.ToLower(strings.TrimSpace(input)) if lower == "" { diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index a3f7613..2742151 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -2,11 +2,12 @@ package game import ( "fmt" + "strconv" "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" + "thehouseoficarus/internal/object" ) func (g *Game) handleShopInput(sess *net.Session, input string) { @@ -27,17 +28,9 @@ func (g *Game) handleShopInput(sess *net.Session, input string) { switch cmd { case "buy": - if len(parts) < 2 { - sess.WriteLine("Buy what? Use 'browse' to see available items.") - } else { - g.doShopBuy(sess, strings.Join(parts[1:], " ")) - } + g.cmdShopBuy(sess, parts[1:]) case "sell": - if len(parts) < 2 { - sess.WriteLine("Sell what?") - } else { - g.doShopSell(sess, strings.Join(parts[1:], " ")) - } + g.cmdShopSell(sess, parts[1:]) case "browse", "list": g.showShopBrowse(sess) case "leave", "bye", "exit", "quit": @@ -49,15 +42,65 @@ func (g *Game) handleShopInput(sess *net.Session, input string) { g.writeShopPrompt(sess) } +func parseShopQty(args []string) (int, string, bool) { + if len(args) == 0 { + return 1, "", false + } + if strings.EqualFold(args[0], "all") { + rest := strings.Join(args[1:], " ") + return 0, rest, true + } + if n, err := strconv.Atoi(args[0]); err == nil && n > 0 { + return n, strings.Join(args[1:], " "), false + } + return 1, strings.Join(args, " "), false +} + +func (g *Game) cmdShopBuy(sess *net.Session, args []string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + qty, itemName, all := parseShopQty(args) + if itemName == "" { + sess.WriteLine("Buy what? Use 'browse' to see available items.") + return + } + + g.doShopBuy(sess, itemName, qty, all) +} + +func (g *Game) cmdShopSell(sess *net.Session, args []string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + qty, itemName, all := parseShopQty(args) + if itemName == "" { + sess.WriteLine("Sell what?") + return + } + + g.doShopSell(sess, itemName, qty, all) +} + func (g *Game) writeShopPrompt(sess *net.Session) { sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> "))) } func (g *Game) shopConfig(sess *net.Session) *action.ShopConfig { - cfg, _ := sess.Shop.(*action.ShopConfig) + cfg := sess.Shop return cfg } +type shopMatch struct { + si *action.ShopItem + name string + def *object.ItemDef +} + func (g *Game) showShopBrowse(sess *net.Session) { cfg := g.shopConfig(sess) if cfg == nil { @@ -74,16 +117,16 @@ func (g *Game) showShopBrowse(sess *net.Session) { } unicode := true - if p, ok := sess.Player.(*player.Player); ok { + if p := sess.Player; p != nil { unicode = p.OptionBool("unicode") } t := Table{ Title: "Shop Inventory", - Columns: []string{"#", "Item", "Buy Price", "Sell Price"}, + Columns: []string{"Item", "Buy Price", "Sell Price"}, } - for i, si := range cfg.Items { + for _, si := range cfg.Items { def, _ := g.ItemStore.Load(si.ItemID) itemName := si.ItemID if def != nil { @@ -95,7 +138,6 @@ func (g *Game) showShopBrowse(sess *net.Session) { sellStr = fmt.Sprintf("%d cr", si.SellPrice) } t.Rows = append(t.Rows, []string{ - fmt.Sprintf("%d", i+1), g.colorize(sess, "item", itemName), g.colorize(sess, "credits_pickup", buyStr), g.colorize(sess, "credits_pickup", sellStr), @@ -107,55 +149,84 @@ func (g *Game) showShopBrowse(sess *net.Session) { } } -func (g *Game) doShopBuy(sess *net.Session, input string) { +func (g *Game) findShopMatches(input string, cfg *action.ShopConfig) []shopMatch { + var matches []shopMatch + for i := range cfg.Items { + si := &cfg.Items[i] + itemName := si.ItemID + def, _ := g.ItemStore.Load(si.ItemID) + if def != nil { + itemName = def.Name + } + if action.WordPrefixMatch(input, itemName) { + matches = append(matches, shopMatch{si: si, name: itemName, def: def}) + } + } + return matches +} + +func (g *Game) doShopBuy(sess *net.Session, input string, qty int, all bool) { cfg := g.shopConfig(sess) if cfg == nil { return } - idx, err := parseChoiceIndex(input) - if err == nil && idx > 0 && idx <= len(cfg.Items) { - si := cfg.Items[idx-1] - g.buyShopItem(sess, si) + p := sess.Player + + matches := g.findShopMatches(input, cfg) + if len(matches) == 0 { + sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") return } - var match *action.ShopItem - for i := range cfg.Items { - si := &cfg.Items[i] - def, _ := g.ItemStore.Load(si.ItemID) - itemName := si.ItemID - if def != nil { - itemName = def.Name + if len(matches) > 1 { + sess.WriteLine("That's ambiguous, which one?") + for _, m := range matches { + sess.WriteLine(fmt.Sprintf(" - %s", g.colorize(sess, "item", m.name))) } + return + } - invMatches := g.collectMatches(input, func(yield func(string, int) bool) { - yield(itemName, -1) - }) + si := matches[0].si + def := matches[0].def - if len(invMatches) > 0 { - if match != nil { - sess.WriteLine(fmt.Sprintf("That's ambiguous, which one? (use #, e.g. buy %s 1)", si.ItemID)) - return + actualQty := qty + if all || qty == 0 { + affordable := p.Credits / si.BuyPrice + if def != nil && def.Stackable { + actualQty = affordable + } else { + freeSlots := p.FreeSlots() + actualQty = affordable + if actualQty > freeSlots { + actualQty = freeSlots } - match = si } } - if match != nil { - g.buyShopItem(sess, *match) + if actualQty <= 0 { + if p.Credits < si.BuyPrice { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + } else { + sess.WriteLine("Your inventory is full.") + } return } - sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") + g.buyShopItem(sess, *si, actualQty) } -func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) { - p, _ := sess.Player.(*player.Player) +func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem, qty int) { + p := sess.Player - if p.Credits < si.BuyPrice { - sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) - return + totalCost := si.BuyPrice * qty + if p.Credits < totalCost { + qty = p.Credits / si.BuyPrice + if qty <= 0 { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + return + } + totalCost = si.BuyPrice * qty } def, _ := g.ItemStore.Load(si.ItemID) @@ -168,37 +239,83 @@ func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == si.ItemID { - p.Credits -= si.BuyPrice - slot.Quantity++ + p.Credits -= totalCost + slot.Quantity += qty g.AccountStore.SaveCharacter(p) itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } return } } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Credits -= totalCost + p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, qty)) + g.AccountStore.SaveCharacter(p) + + itemColor := g.itemColorize(sess, def, displayName) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } + return + } + + freeSlots := p.FreeSlots() + if qty > freeSlots { + if freeSlots == 0 { + sess.WriteLine("Your inventory is full.") + return + } + qty = freeSlots + totalCost = si.BuyPrice * qty + if p.Credits < totalCost { + qty = p.Credits / si.BuyPrice + totalCost = si.BuyPrice * qty + } } - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") + if qty <= 0 { + if p.Credits < si.BuyPrice { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + } else { + sess.WriteLine("Your inventory is full.") + } return } - p.Credits -= si.BuyPrice - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: si.ItemID, Quantity: 1}) + p.Credits -= totalCost + for j := 0; j < qty; j++ { + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, 1)) + } g.AccountStore.SaveCharacter(p) itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } } -func (g *Game) doShopSell(sess *net.Session, input string) { +func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { cfg := g.shopConfig(sess) if cfg == nil { return } - p, _ := sess.Player.(*player.Player) + p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -208,6 +325,9 @@ func (g *Game) doShopSell(sess *net.Session, input string) { if len(matches) > 1 { sess.WriteLine("That's ambiguous, which one?") + for _, m := range uniqueItemNames(matches) { + sess.WriteLine(fmt.Sprintf(" - %s", g.colorize(sess, "item", m))) + } return } @@ -232,38 +352,52 @@ func (g *Game) doShopSell(sess *net.Session, input string) { return } - p.RemoveItem(match.ID, 1) - p.Credits += shopItem.SellPrice + totalInInv := p.CountItem(match.ID) + actualQty := qty + if all || qty == 0 { + actualQty = totalInInv + } else if actualQty > totalInInv { + actualQty = totalInInv + } + + if actualQty <= 0 { + sess.WriteLine("You don't have that item.") + return + } + + p.RemoveItem(match.ID, actualQty) + p.Credits += shopItem.SellPrice * actualQty g.AccountStore.SaveCharacter(p) def, _ := g.ItemStore.Load(match.ID) - displayName := match.ID - if def != nil { - displayName = def.Name - } + displayName := match.Name itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) + if actualQty == 1 { + sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) + } else { + totalCredits := shopItem.SellPrice * actualQty + sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCredits)))) + } } func (g *Game) leaveShop(sess *net.Session) { sess.Shop = nil - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Action == nil || p.Action.Type != "talk" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "talk" { sess.State = net.StateGame if p != nil { - g.CancelAction(p) + g.cancelAction(p) } g.writePrompt(sess) return } - behaviorID, _ := p.Action.Data["behavior_id"].(string) nodeKey, _ := p.Action.Data["node"].(string) - cfg, err := g.BehaviorStore.LoadTalk(behaviorID) - if err != nil { + cfg, ok := p.Action.Data["talk_cfg"].(*action.TalkConfig) + if !ok || cfg == nil { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } @@ -271,7 +405,7 @@ func (g *Game) leaveShop(sess *net.Session) { node, ok := cfg.Nodes[nodeKey] if !ok { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } @@ -287,7 +421,7 @@ func (g *Game) leaveShop(sess *net.Session) { } if visible == 0 { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 46f2607..53ff426 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -9,8 +9,8 @@ import ( ) func (g *Game) doSmelt(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, stationName := g.findStation(p.RoomID, []string{"furnace"}) if stationDefID == "" { @@ -64,7 +64,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) { return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -108,5 +108,9 @@ func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, allRecipes []a names = append(names, e.ItemName) } g.showMenuTable(sess, "What would you like to smelt?", names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index b539640..067d251 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -11,8 +11,8 @@ import ( ) func (g *Game) doSmith(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, _ := g.findStation(p.RoomID, []string{"anvil"}) if stationDefID == "" { diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go index 97aab8a..b37862d 100644 --- a/internal/game/cmd_sneak.go +++ b/internal/game/cmd_sneak.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSneak(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player p.Sneaking = !p.Sneaking if p.Sneaking { p.SneakNotified = make(map[string]bool) @@ -29,8 +28,8 @@ func (g *Game) SneakTick() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || !p.Sneaking { + p := sess.Player + if p == nil || !p.Sneaking { continue } diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 0dc963e..51bacf1 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doStats(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player totals := g.playerEquipBonuses(p) attackType := "crush" diff --git a/internal/game/cmd_style.go b/internal/game/cmd_style.go index 901d02d..269fa3f 100644 --- a/internal/game/cmd_style.go +++ b/internal/game/cmd_style.go @@ -9,7 +9,7 @@ import ( ) func (g *Game) doStyle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player styles := []string{"accurate", "aggressive", "defensive", "balanced"} if input == "" { diff --git a/internal/game/cmd_task.go b/internal/game/cmd_task.go index 2dcf3f4..5b67421 100644 --- a/internal/game/cmd_task.go +++ b/internal/game/cmd_task.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doTask(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mobID := getPlayerFlagString(p, "assassin_task_mob") remaining := getPlayerFlagInt(p, "assassin_task_remaining") diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go index f51ccd9..89d5c2a 100644 --- a/internal/game/cmd_tech.go +++ b/internal/game/cmd_tech.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doTech(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player techLevel := p.Level(player.Technology) if techLevel < 1 { @@ -109,7 +109,7 @@ func (g *Game) deactivateGroup(p *player.Player, group string) []string { } func (g *Game) doTechList(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) techLevel := p.Level(player.Technology) diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 7ef4913..89ed397 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doTrigger(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player input = strings.TrimSpace(input) if input == "" { @@ -40,8 +40,6 @@ func (g *Game) doTrigger(sess *net.Session, input string) { g.triggerCombatMod(sess, p, mod, targetArg) case ModTransport: g.triggerTransport(sess, p, mod) - case ModProcessing: - g.triggerProcessing(sess, p, mod, targetArg) case ModUtility: g.triggerUtility(sess, p, mod, targetArg) case ModEnchant: @@ -65,13 +63,13 @@ func (g *Game) parseTriggerArgs(input string) (*ModDef, string) { func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { if cs := combat.GetCombat(p.Name); cs != nil { - p.AutocastMod = mod.ID + p.AutotriggerMod = mod.ID sess.WriteLine(fmt.Sprintf("You switch to triggering %s.", mod.Name)) return } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } var mobTarget string @@ -105,7 +103,7 @@ func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef return } - p.AutocastMod = mod.ID + p.AutotriggerMod = mod.ID g.startCombat(sess, p, mob) } @@ -116,15 +114,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) } @@ -180,7 +176,7 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -201,17 +197,10 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe p.Credits += creditValue - sciXP := int(mod.BaseXP) - var leveledUp []player.SkillName - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Science) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) - for _, skill := range leveledUp { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) - } - sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.", itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue)))) @@ -222,6 +211,8 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { switch mod.ID { + case "low_process", "high_process": + g.triggerProcessing(sess, p, mod, targetArg) case "bones_to_nutrients": g.triggerBonesToNutrients(sess, p, mod) case "em_grab": @@ -246,7 +237,7 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -258,10 +249,8 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod } } - sciXP := int(mod.BaseXP) * boneCount - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP * boneCount + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount)) @@ -301,7 +290,7 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -318,10 +307,8 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t freeSlot := p.FirstFreeSlot() p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: qty}) - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name)) @@ -377,7 +364,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -410,14 +397,10 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) if recipe.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(recipe.Skill), recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(player.SkillName(recipe.Skill)), recipe.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(recipe.Skill), recipe.XP) } g.AccountStore.SaveCharacter(p) @@ -477,7 +460,7 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -492,13 +475,9 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } - if newLevel := p.AddSkillXP(player.Construction, 10); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.awardSkillXP(sess, p, player.Construction, 10) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name)) @@ -521,7 +500,7 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -540,14 +519,10 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef processed++ } - sciXP := int(mod.BaseXP) * qty - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP * qty + g.awardSkillXP(sess, p, player.Science, sciXP) conXP := 10 * qty - if newLevel := p.AddSkillXP(player.Construction, conXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction)))) - } + g.awardSkillXP(sess, p, player.Construction, conXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost)) @@ -594,17 +569,15 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) inv.ItemID = outputID - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) outputDef, _ := g.ItemStore.Load(outputID) @@ -637,7 +610,7 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -657,10 +630,8 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: chip.Output, Quantity: chip.Qty}) } - sciXP := int(mod.BaseXP) - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) inputDef, _ := g.ItemStore.Load(chip.Input) @@ -714,27 +685,21 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob mob.StartRegen() } - sciXP := int(mod.BaseXP) - hpXP := int(mod.BaseXP * 0.33) + sciXP := mod.BaseXP + hpXP := mod.BaseXP / 3 + if hpXP < 1 { + hpXP = 1 + } var gains []xpGain - var leveledUp []player.SkillName - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Science) - } + g.awardSkillXP(sess, p, player.Science, sciXP) gains = append(gains, xpGain{string(player.Science), sciXP}) - if newLevel := p.AddSkillXP(player.Hitpoints, hpXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Hitpoints) - } + g.awardSkillXP(sess, p, player.Hitpoints, hpXP) gains = append(gains, xpGain{string(player.Hitpoints), hpXP}) g.AccountStore.SaveCharacter(p) - for _, skill := range leveledUp { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) - } - mobName := mobDisplayName(mob, true) prefix := fmt.Sprintf(" %s hits %s for %s damage.", g.colorize(sess, "science_mod", mod.Name), diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 95a7b5b..fbe5945 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -16,8 +16,8 @@ func (g *Game) doUse(sess *net.Session, input string) { return } - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) if g.tryRecharge(sess, p, input) { return @@ -50,11 +50,144 @@ func (g *Game) doUse(sess *net.Session, input string) { } if len(strings.Fields(input)) >= 2 { + if g.useRoomObject(sess, p, input) { + return + } g.doUseItemOnTarget(sess, p, strings.Fields(input)[0], strings.Join(strings.Fields(input)[1:], " ")) return } - g.StartAction(sess, "use", input) + if g.useRoomObject(sess, p, input) { + return + } + + g.startAction(sess, "use", input) +} + +func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) bool { + objInstances := g.World.FindObjInstances(p.RoomID, input) + if len(objInstances) == 0 { + return false + } + objSt := &objInstances[0] + def, _ := g.ObjectStore.Load(objSt.DefID) + if def == nil { + return false + } + recipes := g.stationRecipes(p, objSt.DefID) + if len(recipes) > 0 { + g.showStationRecipeMenu(sess, p, recipes, def.Name) + return true + } + bt := def.BehaviorType() + if bt != "" { + g.startAction(sess, bt, def.Name) + return true + } + for _, ui := range def.UseInteractions { + if ui.Item != "" { + continue + } + if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { + continue + } + if ui.Message != "" { + sess.WriteLine(ui.Message) + } + if ui.Action != nil { + g.applyNodeAction(sess, ui.Action) + } + p.ActionState = &ActionState{Type: ActionUsing, TargetName: def.Name} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s uses the %s.", p.Name, def.Name))) + } + } + } + return true + } + if len(def.UseInteractions) > 0 { + sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name)) + return true + } + sess.WriteLine(fmt.Sprintf("You can't use the %s.", def.Name)) + return true +} + +func (g *Game) stationRecipes(p *player.Player, stationDefID string) []action.RecipeDef { + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + return nil + } + var results []action.RecipeDef + for _, r := range allRecipes { + if len(r.Station) == 0 { + continue + } + stationMatch := false + for _, sid := range r.Station { + if sid == stationDefID { + stationMatch = true + break + } + } + if !stationMatch { + continue + } + if !r.HasAllItemsQty(p.CountItem) { + continue + } + skillName := player.SkillName(r.EffectiveSkill()) + if p.Level(skillName) < r.Level { + continue + } + if r.Tool != "" && !g.hasToolType(p, r.Tool) { + continue + } + if stationDefID == "anvil" && !g.hasToolType(p, "hammer") { + continue + } + results = append(results, r) + } + return results +} + +func (g *Game) showStationRecipeMenu(sess *net.Session, p *player.Player, recipes []action.RecipeDef, stationName string) { + if len(recipes) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have anything you can make at the %s.", stationName)) + return + } + if len(recipes) == 1 { + g.promptHowMany(sess, recipes[0].ID) + return + } + needTypes := false + for i := range recipes { + for j := i + 1; j < len(recipes); j++ { + if recipes[i].Type != recipes[j].Type { + needTypes = true + break + } + } + if needTypes { + break + } + } + menu := recipeMenuData(recipeDefIDs(recipes)) + sess.PendingMenu = menu + sess.State = net.StateRecipeChoice + var names []string + for _, r := range recipes { + name := g.recipeName(sess, &r) + if needTypes { + typeLabel := strings.ToUpper(r.Type[:1]) + r.Type[1:] + names = append(names, fmt.Sprintf("%s (%s)", name, typeLabel)) + } else { + names = append(names, name) + } + } + g.showMenuTable(sess, fmt.Sprintf("What would you like to make at the %s?", stationName), names) } func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, itemBName string) { @@ -79,7 +212,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, objSt := &objInstances[0] def, _ := g.ObjectStore.Load(objSt.DefID) if def == nil { - g.StartAction(sess, "use", itemBName) + g.startAction(sess, "use", itemBName) return } @@ -109,7 +242,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, filtered[0].ID) return } - sess.PendingMenu = recipeMenuData(filtered) + sess.PendingMenu = recipeMenuData(recipeDefIDs(filtered)) sess.State = net.StateRecipeChoice var names []string for _, r := range filtered { @@ -131,7 +264,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, recipes[0].ID) return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -157,7 +290,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } - g.StartAction(sess, "use", itemBName) + g.startAction(sess, "use", itemBName) return } @@ -213,12 +346,12 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, if len(craftRecipes) > 0 { var available []action.RecipeDef for _, r := range craftRecipes { - if g.canDoCraftRecipe(p, &r) { + if g.canDoGenericRecipe(p, &r, player.Crafting) { available = append(available, r) } } if len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], 0) + g.startGenericRecipe(sess, p, &available[0], 0, "last_craft") return } if len(available) > 1 { diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index 2104782..eca608f 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doWalk(sess *net.Session, args []string) { - p := sess.Player.(*player.Player) + p := sess.Player if len(args) == 0 { sess.WriteLine("Walk where?") return @@ -87,7 +87,7 @@ func (g *Game) startWalk(sess *net.Session, p *player.Player, dirs []string) { return } - g.CancelAction(p) + g.cancelAction(p) p.WalkSequence = dirs } @@ -104,7 +104,7 @@ func (g *Game) advanceWalk(sess *net.Session, p *player.Player) { p.WalkSequence = p.WalkSequence[1:] oldRoom := p.RoomID - g.doMove(sess, dir) + g.doMove(sess, dir, 2.0) if p.MoveTicks == 0 && p.RoomID == oldRoom { p.WalkSequence = nil diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 692a3e4..36765e8 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -26,7 +26,7 @@ func (g *Game) checkRequirements(p *player.Player, def *object.ItemDef) string { } func (g *Game) doWear(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(strings.TrimSpace(input)) if lower == "all" { @@ -39,7 +39,7 @@ func (g *Game) doWear(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) qty, itemName := parseQty(input) @@ -168,8 +168,8 @@ func (g *Game) unequipAmmo(p *player.Player) { } func (g *Game) doWearAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) bestPerSlot := make(map[object.EquipSlot]struct { itemID string diff --git a/internal/game/color.go b/internal/game/color.go index c3b3d83..962728a 100644 --- a/internal/game/color.go +++ b/internal/game/color.go @@ -5,11 +5,10 @@ import ( "thehouseoficarus/internal/config" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" ) func (g *Game) colorMode(sess *net.Session) string { - if p, ok := sess.Player.(*player.Player); ok && p != nil { + if p := sess.Player; p != nil { return p.OptionString("color") } return "none" diff --git a/internal/game/construction.go b/internal/game/construction.go new file mode 100644 index 0000000..06713cd --- /dev/null +++ b/internal/game/construction.go @@ -0,0 +1,74 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) processSawmill(sess *net.Session, p *player.Player) { + logTypes := map[string]struct { + plankID string + cost int + name string + }{ + "logs": {"planks", 5, "regular planks"}, + "oak_logs": {"oak_planks", 10, "oak planks"}, + "teak_logs": {"teak_planks", 20, "teak planks"}, + "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"}, + } + + totalCost := 0 + totalPlanks := 0 + type plankResult struct { + plankID string + qty int + } + + var results []plankResult + + for logID, info := range logTypes { + qty := p.CountItem(logID) + if qty == 0 { + continue + } + cost := qty * info.cost + totalCost += cost + totalPlanks += qty + results = append(results, plankResult{info.plankID, qty}) + p.RemoveItem(logID, qty) + } + + if totalPlanks == 0 { + sess.WriteLine("You don't have any logs to process.") + return + } + + if p.Credits < totalCost { + sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits)) + return + } + + p.Credits -= totalCost + + processed := 0 + for _, r := range results { + for i := 0; i < r.qty; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i) + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1}) + processed++ + } + } + + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost)) + if processed < totalPlanks { + sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed)) + } +} diff --git a/internal/game/death.go b/internal/game/death.go new file mode 100644 index 0000000..d2c1b73 --- /dev/null +++ b/internal/game/death.go @@ -0,0 +1,66 @@ +package game + +import ( + "sort" + + "thehouseoficarus/internal/player" +) + +func (g *Game) dropItemsOnDeath(p *player.Player) { + roomID := p.RoomID + + if p.Credits > 0 { + g.World.AddGroundItem(roomID, "credits", p.Credits) + p.Credits = 0 + } + + var items []deathDrop + + for slot, inv := range p.Inventory { + if inv == nil || inv.Quantity <= 0 { + continue + } + val := 0 + if def, err := g.ItemStore.Load(inv.ItemID); err == nil { + val = def.Value * inv.Quantity + } + items = append(items, deathDrop{ + itemID: inv.ItemID, + quantity: inv.Quantity, + totalVal: val, + invSlot: slot, + }) + } + + for eqSlot, itemID := range p.Equipment { + val := 0 + if def, err := g.ItemStore.Load(itemID); err == nil { + val = def.Value + } + items = append(items, deathDrop{ + itemID: itemID, + quantity: 1, + totalVal: val, + isEquip: true, + equipSlot: eqSlot, + }) + } + + if len(items) <= 3 { + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].totalVal > items[j].totalVal + }) + + for i := 3; i < len(items); i++ { + it := items[i] + if it.isEquip { + delete(p.Equipment, it.equipSlot) + } else { + p.SetInvSlot(it.invSlot, nil) + } + g.World.AddGroundItem(roomID, it.itemID, it.quantity) + } +} diff --git a/internal/game/equip_stats.go b/internal/game/equip_stats.go index 559671f..02b8348 100644 --- a/internal/game/equip_stats.go +++ b/internal/game/equip_stats.go @@ -29,3 +29,13 @@ func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { } return totals } + +func (g *Game) playerWeaponSpeed(p *player.Player) float64 { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.Speed > 0 { + return def.Speed + } + } + return 5.0 +} diff --git a/internal/game/game.go b/internal/game/game.go index 03300a4..b8eadce 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -11,6 +11,7 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/config" "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" @@ -39,25 +40,23 @@ type Game struct { ItemStore *object.ItemStore AccountStore *player.AccountStore MobStore *world.MobStore - BehaviorStore *action.Store RecipeStore *action.RecipeStore CourseStore *CourseStore Hub *net.Hub Ticks *engine.Engine WorldFlags map[string]any ColorConfig *config.ColorsConfig - dataDir string + DataDir string restTimers map[string]uint64 charsMu sync.Mutex loggedInChars map[string]*net.Session - combatPadWidth int freeQueue map[string][]QueuedCommand activeQueue map[string]*QueuedCommand consumeQueue map[string]*QueuedCommand pendingDepletions []pendingDepletion guardWatchTimers map[string]int farmTickCounter int - hackingStates map[string]*HackingSession + hackingStates map[string]*hacking.Session } func New(dataDir string, colorConfig *config.ColorsConfig) *Game { @@ -67,13 +66,12 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { ItemStore: object.NewItemStore(dataDir), AccountStore: player.NewAccountStore(dataDir), MobStore: world.NewMobStore(dataDir), - BehaviorStore: action.NewStore(dataDir), RecipeStore: action.NewRecipeStore(dataDir), CourseStore: NewCourseStore(dataDir), Ticks: engine.New(), WorldFlags: make(map[string]any), ColorConfig: colorConfig, - dataDir: dataDir, + DataDir: dataDir, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), freeQueue: make(map[string][]QueuedCommand), @@ -81,16 +79,17 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { consumeQueue: make(map[string]*QueuedCommand), pendingDepletions: nil, guardWatchTimers: make(map[string]int), - hackingStates: make(map[string]*HackingSession), + hackingStates: make(map[string]*hacking.Session), } g.CourseStore.LoadAll() + g.LoadMods() return g } func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { - if p, ok := sess.Player.(*player.Player); ok { + if p := sess.Player; p != nil { p.DeactivateAllTechs() g.charsMu.Lock() delete(g.loggedInChars, p.Name) @@ -150,44 +149,6 @@ func (g *Game) HandleSession(sess *net.Session, input string) { } } -func classifyCommand(cmd string) CommandClass { - switch cmd { - case "say", "score", "sc", "inventory", "i", "inv", - "look", "l", "exits", "help", - "map", "option", "options", "alias", "unalias", - "description", "desc", "queued", "color", "colors", - "colortable", "prompt", "style", "stats", - "tech", "t", "sneak", - "autocast", "auto", "mods", "modlist", - "task", "inspect": - return ClassInstant - case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield": - return ClassFree - case "get", "take", "grab", "pick", "drop", - "attack", "kill", - "north", "n", "south", "s", "east", "e", - "west", "w", "up", "u", "down", "d", - "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "mix", - "id", "identify", - "steal", "thieve", - "trigger", "cast", - "plant", "harvest", "rake", "water", "cure", - "bank", "construct", "make": - return ClassActive - case "jack", "jackin": - return ClassActive - case "eat", "fletch", "clean", "drink": - return ClassFree - } - if _, ok := verbAliases[cmd]; ok { - return ClassActive - } - if obstacleVerbs[cmd] { - return ClassActive - } - return ClassUnknown -} - func (g *Game) writePrompt(sess *net.Session) { sess.Write("\r\n" + g.promptStr(sess)) } @@ -240,7 +201,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } - p, _ := sess.Player.(*player.Player) + p := sess.Player if p == nil { return } @@ -276,363 +237,60 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } } -func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { - p, _ := sess.Player.(*player.Player) - - switch cmd { - case "get", "take", "grab", "pick": - if len(args) == 0 { - sess.WriteLine("Get what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doGetAll(sess) - } else { - g.doGetAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doGet(sess, strings.Join(args, " ")) - } - case "drop": - if len(args) == 0 { - sess.WriteLine("Drop what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doDropAll(sess) - } else { - g.doDropAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doDrop(sess, strings.Join(args, " ")) - } - case "attack", "kill": - if len(args) == 0 { - target := g.resolveDefaultMob(p.RoomID) - if target == "" { - sess.WriteLine("Attack what?") - break - } - g.doAttack(sess, target) - return - } else { - g.doAttack(sess, strings.Join(args, " ")) - return - } - case "style": - if len(args) == 0 { - g.doStyle(sess, "") - } else { - g.doStyle(sess, args[0]) - } - case "look", "l": - if len(args) == 0 { - g.doLook(sess) - } else { - g.doLookTarget(sess, strings.Join(args, " ")) - } - case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d": - g.doMove(sess, cmd) - case "say": - if len(args) == 0 { - sess.WriteLine("Say what?") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 - if msgStart >= 4 && msgStart < len(rawInput) { - g.doSay(sess, rawInput[msgStart:]) - } else { - g.doSay(sess, strings.Join(args, " ")) - } - } - case "sc", "score": - g.doScore(sess) - case "task": - g.doTask(sess) - case "stats": - g.doStats(sess) - case "tech", "t": - g.doTech(sess, strings.Join(args, " ")) - case "i", "inv", "inventory": - g.doInventory(sess) - case "quit": - g.doQuit(sess) - return - case "description", "desc": - g.doDescription(sess) - case "option", "options": - g.doOption(sess, strings.Join(args, " ")) - case "color", "colors": - g.doColor(sess, strings.Join(args, " ")) - case "colortable": - g.doColortable(sess) - case "prompt": - if len(args) == 0 { - g.doPrompt(sess, "") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 - if msgStart >= 7 && msgStart < len(rawInput) { - g.doPrompt(sess, rawInput[msgStart:]) - } else { - g.doPrompt(sess, strings.Join(args, " ")) - } - } - case "exits": - g.doExits(sess) - case "map": - g.doMap(sess) - case "queued": - g.doQueued(sess) - case "help": - if len(args) == 0 { - g.doHelp(sess, "") - } else { - g.doHelp(sess, strings.Join(args, " ")) - } - case "mine", "chop", "fish", "cut", "pull", "push": - g.CancelAction(p) - if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, cmd) - if target == "" { - sess.WriteLine(fmt.Sprintf("%s what?", cmd)) - break - } - g.StartAction(sess, cmd, target) - return - } else { - g.StartAction(sess, cmd, strings.Join(args, " ")) - return - } - case "use": - if g.tryFinishingBlow(sess, strings.Join(args, " ")) { - return - } - g.doUse(sess, strings.Join(args, " ")) - return - case "cook": - g.doCook(sess, strings.Join(args, " ")) - return - case "smelt": - g.doSmelt(sess, strings.Join(args, " ")) - return - case "smith": - g.doSmith(sess, strings.Join(args, " ")) - return - case "craft": - g.doCraft(sess, strings.Join(args, " ")) - return - case "eat": - g.doEat(sess, strings.Join(args, " ")) - return - case "drink": - g.doDrink(sess, args) - return - case "fletch": - g.doFletch(sess, strings.Join(args, " ")) - return - case "clean": - g.doClean(sess, strings.Join(args, " ")) - return - case "mix": - g.doMix(sess, strings.Join(args, " ")) - return - case "id", "identify": - g.doIdentify(sess, strings.Join(args, " ")) - return - case "autocast", "auto": - g.doAutocast(sess, strings.Join(args, " ")) - case "mods", "modlist": - g.doMods(sess) - case "sneak": - g.doSneak(sess) - case "trigger", "cast": - g.doTrigger(sess, strings.Join(args, " ")) - return - case "talk", "speak", "ask": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Talk to whom?") - } else { - g.StartAction(sess, "talk", strings.Join(args, " ")) - return - } - case "burn": - g.CancelAction(p) - g.doBurn(sess, p, strings.Join(args, " ")) - return - case "stoke": - g.CancelAction(p) - g.doStoke(sess, p, strings.Join(args, " ")) - return - case "alias": - g.doAlias(sess, args) - return - case "unalias": - g.doUnalias(sess, args) - return - case "search": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Search what?") - } else { - g.doSearch(sess, strings.Join(args, " ")) - } - return - case "steal", "thieve": - g.CancelAction(p) - if len(args) == 0 { - g.doSteal(sess, "") - } else { - g.doSteal(sess, strings.Join(args, " ")) - } - return - case "plant": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Plant what?") - } else { - g.doPlant(sess, strings.Join(args, " ")) - } - return - case "harvest": - g.CancelAction(p) - if len(args) == 0 { - g.doHarvest(sess, "") - } else { - g.doHarvest(sess, strings.Join(args, " ")) - } - return - case "rake": - g.CancelAction(p) - if len(args) == 0 { - g.doRake(sess, "") - } else { - g.doRake(sess, strings.Join(args, " ")) - } - return - case "water": - g.CancelAction(p) - if len(args) == 0 { - g.doWater(sess, "") - } else { - g.doWater(sess, strings.Join(args, " ")) - } - return - case "cure": - g.CancelAction(p) - if len(args) == 0 { - g.doCure(sess, "") - } else { - g.doCure(sess, strings.Join(args, " ")) - } - return - case "inspect": - if len(args) == 0 { - g.doInspect(sess, "") - } else { - g.doInspect(sess, strings.Join(args, " ")) - } - case "walk": - g.doWalk(sess, args) - return - case "bank": - g.doBank(sess) - return - case "construct", "make": - g.doConstruct(sess, strings.Join(args, " ")) - return - case "jack", "jackin": - g.CancelAction(p) - g.doJack(sess, strings.Join(args, " ")) - return - case "eq", "equipment", "equip", "wear", "wield": - g.doWear(sess, strings.Join(args, " ")) - return - case "remove", "unwear", "unwield": - g.doRemove(sess, strings.Join(args, " ")) - return - default: - if obstacleVerbs[cmd] { - g.doObstacle(sess, cmd) - return - } - if a, ok := verbAliases[cmd]; ok { - g.CancelAction(p) - switch a { - case "gather", "toggle": - if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, cmd) - if target == "" { - sess.WriteLine(fmt.Sprintf("%s what?", cmd)) - break - } - g.StartAction(sess, cmd, target) - return - } - g.StartAction(sess, cmd, strings.Join(args, " ")) - return - case "talk": - if len(args) == 0 { - sess.WriteLine("Talk to whom?") - } else { - g.StartAction(sess, "talk", strings.Join(args, " ")) - return - } - } - } else { - sess.WriteLine("Unknown command.") - } - } -} - func (g *Game) ProcessQueuedCommands() { if g.Hub == nil { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.ActionState == nil { - continue - } - as, ok := p.ActionState.(*ActionState) - if !ok { + p := sess.Player + if p == nil { continue } - switch as.Type { - case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, - ActionTraversing: - default: - if as.Type != ActionMoving || p.MoveTicks <= 0 { + + if p.ActionState != nil { + as, ok := p.ActionState.(*ActionState) + if !ok { p.ActionState = nil + } else { + switch as.Type { + case ActionGathering, ActionCombating, ActionUsing, ActionTalking, + ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, + ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, + ActionTraversing: + default: + if as.Type != ActionMoving || p.MoveTicks <= 0 { + p.ActionState = nil + } + } } } - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - 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) - parts := strings.Fields(qc.Command + " " + qc.Args) + raw := qc.Command + if qc.Args != "" { + raw += " " + qc.Args + } + parts := strings.Fields(raw) if len(parts) > 0 { - g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + g.executeCommand(qc.Session, parts[0], parts[1:], raw) } g.writePrompt(qc.Session) } delete(g.freeQueue, p.Name) + + if len(p.WalkSequence) > 0 { + g.advanceWalk(sess, p) + if len(p.WalkSequence) == 0 && p.MoveTicks == 0 { + g.writePrompt(sess) + } + } } var actives []QueuedCommand @@ -644,16 +302,20 @@ func (g *Game) ProcessQueuedCommands() { }) for _, qc := range actives { - parts := strings.Fields(qc.Command + " " + qc.Args) - p, ok := qc.Session.Player.(*player.Player) - if !ok || p == nil { + raw := qc.Command + if qc.Args != "" { + raw += " " + qc.Args + } + parts := strings.Fields(raw) + p := qc.Session.Player + if p == nil { continue } if p.MoveTicks > 0 { p.ClearMoveState() } if len(parts) > 0 { - g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + g.executeCommand(qc.Session, parts[0], parts[1:], raw) } isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil || p.MoveTicks > 0 _, isResting := g.restTimers[p.Name] @@ -663,48 +325,12 @@ func (g *Game) ProcessQueuedCommands() { } g.activeQueue = make(map[string]*QueuedCommand) - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.WalkSequence) == 0 { - continue - } - g.advanceWalk(sess, p) - if len(p.WalkSequence) == 0 && p.MoveTicks == 0 { - g.writePrompt(sess) - } - } - g.flushPendingDepletions() } type pendingDepletion struct { instanceKey string - behaviorID string + objDefID string targetName string playerNames []string } - -func (g *Game) MoveTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.MoveTicks <= 0 { - continue - } - p.MoveTicks-- - if p.MoveTicks > 0 { - if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") { - if p.MoveTicks > 1 { - sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) - } else { - sess.WriteLine("Running next tick!") - } - } - } else { - g.completeMove(sess, p) - g.writePrompt(sess) - } - } -} diff --git a/internal/game/hacking.go b/internal/game/hacking.go index dfefd5c..3457728 100644 --- a/internal/game/hacking.go +++ b/internal/game/hacking.go @@ -4,67 +4,14 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) -type HackingMinigame interface { - Init(level int) string - HandleInput(input string) (output string, done bool, won bool) - Name() string -} - -type XPBonuser interface { - BonusXP() int -} - -type HackingSession struct { - Minigame HackingMinigame - TerminalID string - Level int - ReqLevel int - Started bool -} - -type TerminalDef struct { - ObjectID string - Level int - Minigame func() HackingMinigame - BaseXPWin int - BaseXPLose int - Name string -} - -var terminalDefs = map[string]TerminalDef{ - "terminal_basic": { - ObjectID: "terminal_basic", - Level: 1, - Minigame: func() HackingMinigame { return NewWumpusGame() }, - BaseXPWin: 50, - BaseXPLose: 10, - Name: "Hunt the Wumpus", - }, - "terminal_advanced": { - ObjectID: "terminal_advanced", - Level: 20, - Minigame: func() HackingMinigame { return NewMastermindGame() }, - BaseXPWin: 150, - BaseXPLose: 30, - Name: "Code Breaker", - }, - "terminal_secure": { - ObjectID: "terminal_secure", - Level: 40, - Minigame: func() HackingMinigame { return NewLiarsDiceGame() }, - BaseXPWin: 300, - BaseXPLose: 50, - Name: "Signal Bluff", - }, -} - func (g *Game) handleHackingInput(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { sess.State = net.StateGame return } @@ -105,36 +52,33 @@ func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, w return } - tDef := terminalDefs[hs.TerminalID] + tDef := hacking.Terminals[hs.TerminalID] var xp int if completed && won { - xp = hackingXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection terminated. Contract complete.")) + xp = hacking.CalcXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection terminated. Contract complete.") } else if completed { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection lost.")) + xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection lost.") } else if hs.Started { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) sess.WriteLine("\nYou jack out of the terminal.") } else { sess.WriteLine("\nYou jack out of the terminal.") } if won { - if b, ok := hs.Minigame.(XPBonuser); ok { + if b, ok := hs.Minigame.(hacking.XPBonuser); ok { bonus := b.BonusXP() if bonus > 0 { - xp += hackingXP(bonus, hs.Level, hs.ReqLevel) + xp += hacking.CalcXP(bonus, hs.Level, hs.ReqLevel) } } } if xp > 0 { - if newLevel := p.AddSkillXP(player.Hacking, xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d hacking! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Hacking, xp) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking]))) @@ -147,11 +91,3 @@ func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, w sess.State = net.StateGame g.writePrompt(sess) } - -func hackingXP(baseXP int, playerLevel int, reqLevel int) int { - multiplier := 1.0 + 0.01*float64(playerLevel-reqLevel) - if multiplier < 1.0 { - multiplier = 1.0 - } - return int(float64(baseXP) * multiplier) -} diff --git a/internal/game/hacking/hacking.go b/internal/game/hacking/hacking.go new file mode 100644 index 0000000..f022ee1 --- /dev/null +++ b/internal/game/hacking/hacking.go @@ -0,0 +1,63 @@ +package hacking + +type Minigame interface { + Init(level int) string + HandleInput(input string) (output string, done bool, won bool) + Name() string +} + +type XPBonuser interface { + BonusXP() int +} + +type Session struct { + Minigame Minigame + TerminalID string + Level int + ReqLevel int + Started bool +} + +type TerminalDef struct { + ObjectID string + Level int + Minigame func() Minigame + BaseXPWin int + BaseXPLose int + Name string +} + +var Terminals = map[string]TerminalDef{ + "terminal_basic": { + ObjectID: "terminal_basic", + Level: 1, + Minigame: func() Minigame { return NewWumpusGame() }, + BaseXPWin: 50, + BaseXPLose: 10, + Name: "Hunt the Wumpus", + }, + "terminal_advanced": { + ObjectID: "terminal_advanced", + Level: 20, + Minigame: func() Minigame { return NewMastermindGame() }, + BaseXPWin: 150, + BaseXPLose: 30, + Name: "Code Breaker", + }, + "terminal_secure": { + ObjectID: "terminal_secure", + Level: 40, + Minigame: func() Minigame { return NewLiarsDiceGame() }, + BaseXPWin: 300, + BaseXPLose: 50, + Name: "Signal Bluff", + }, +} + +func CalcXP(baseXP int, playerLevel int, reqLevel int) int { + multiplier := 1.0 + 0.01*float64(playerLevel-reqLevel) + if multiplier < 1.0 { + multiplier = 1.0 + } + return int(float64(baseXP) * multiplier) +} diff --git a/internal/game/hacking/hacking_liars_dice.go b/internal/game/hacking/hacking_liars_dice.go new file mode 100644 index 0000000..bcfbd40 --- /dev/null +++ b/internal/game/hacking/hacking_liars_dice.go @@ -0,0 +1,395 @@ +package hacking + +import ( + "fmt" + "math/rand" + "strconv" + "strings" +) + +type LiarsDiceGame struct { + playerDice []int + aiDice []int + currentBid LiarsBid + hasBid bool + playerTurn bool + round int + gameOver bool + won bool + playerLost int + completed bool +} + +type LiarsBid struct { + Quantity int + Face int +} + +func NewLiarsDiceGame() *LiarsDiceGame { + return &LiarsDiceGame{ + playerDice: make([]int, 5), + aiDice: make([]int, 5), + } +} + +func (l *LiarsDiceGame) Name() string { + return "Signal Bluff" +} + +func (l *LiarsDiceGame) Init(level int) string { + l.playerDice = make([]int, 5) + l.aiDice = make([]int, 5) + l.round = 0 + l.gameOver = false + l.playerTurn = true + l.hasBid = false + l.playerLost = 0 + l.completed = false + + var sb strings.Builder + sb.WriteString("=== SIGNAL BLUFF ===\n") + sb.WriteString("\n") + sb.WriteString("You've connected to a secure channel running a bluffing protocol.\n") + sb.WriteString("You and the system each have 5 signal fragments (dice). Each round,\n") + sb.WriteString("fragments are scrambled. You see only yours. Take turns making claims\n") + sb.WriteString("about the total count of a specific signal across ALL fragments.\n") + sb.WriteString("\n") + sb.WriteString("1s are wild -- they count as any signal.\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" bid - Claim at least N fragments show face F\n") + sb.WriteString(" (e.g., 'bid 3 4' = \"at least three 4s\")\n") + sb.WriteString(" call - Challenge the last bid (reveal all)\n") + sb.WriteString(" exact - Claim the bid is exactly right\n") + sb.WriteString(" status - Show your fragments and current bid\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + sb.WriteString("\n") + sb.WriteString(l.startRound()) + return sb.String() +} + +func (l *LiarsDiceGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(strings.ToLower(input)) + parts := strings.Fields(input) + + if len(parts) == 0 { + return "Commands: bid , call, exact, status, jack out", false, false + } + + cmd := parts[0] + + switch cmd { + case "bid", "b": + if len(parts) < 3 { + return "Usage: bid (e.g., bid 3 4)", false, false + } + qty, err1 := strconv.Atoi(parts[1]) + face, err2 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil { + return "Usage: bid (e.g., bid 3 4)", false, false + } + return l.doBid(qty, face) + case "call", "c", "intercept", "liar": + return l.doCall(false) + case "exact", "e": + return l.doExact() + case "status": + return l.doStatus(), false, false + default: + if len(parts) == 2 { + qty, err1 := strconv.Atoi(parts[0]) + face, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil { + return l.doBid(qty, face) + } + } + return "Commands: bid , call, exact, status, jack out", false, false + } +} + +func (l *LiarsDiceGame) doBid(qty, face int) (string, bool, bool) { + if face < 1 || face > 6 { + return "Face must be 1-6.", false, false + } + if qty < 1 { + return "Quantity must be at least 1.", false, false + } + if !l.playerTurn { + return "It's not your turn to bid. Use 'call' or 'exact'.", false, false + } + + if l.hasBid { + if qty < l.currentBid.Quantity || (qty == l.currentBid.Quantity && face <= l.currentBid.Face) { + return "You must bid higher (higher quantity or same quantity with higher face).", false, false + } + } + + l.currentBid = LiarsBid{Quantity: qty, Face: face} + l.hasBid = true + l.playerTurn = false + + return l.aiTurn() +} + +func (l *LiarsDiceGame) doCall(player bool) (string, bool, bool) { + if !l.hasBid { + return "No bid to call yet. Make a bid first.", false, false + } + + var sb strings.Builder + if player { + sb.WriteString("System intercepts!\n") + } else { + sb.WriteString("You intercept!\n") + } + + sb.WriteString(l.showAllDice()) + sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) + + total := l.countFace(l.currentBid.Face) + sb.WriteString(fmt.Sprintf("Actual count: %d (%d %ss + %d wild 1s)\n\n", + total, + l.countFaceNonWild(l.currentBid.Face), + faceName(l.currentBid.Face), + l.countFace(1)-l.countFaceNonWild(1))) + + if total >= l.currentBid.Quantity { + sb.WriteString("The bid holds! ") + if player { + sb.WriteString("System loses a fragment.") + l.aiDice = l.aiDice[:len(l.aiDice)-1] + } else { + sb.WriteString("You lose a fragment.") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } + } else { + sb.WriteString("Bluff called! ") + if player { + sb.WriteString("You lose a fragment.") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } else { + sb.WriteString("System loses a fragment.") + l.aiDice = l.aiDice[:len(l.aiDice)-1] + } + } + + return l.checkGameOver(sb) +} + +func (l *LiarsDiceGame) doExact() (string, bool, bool) { + if !l.hasBid { + return "No bid to claim exact on. Make a bid first.", false, false + } + + var sb strings.Builder + sb.WriteString("You claim exact match!\n") + + sb.WriteString(l.showAllDice()) + total := l.countFace(l.currentBid.Face) + sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) + sb.WriteString(fmt.Sprintf("Actual count: %d\n\n", total)) + + if total == l.currentBid.Quantity { + sb.WriteString("Exact match! You recover a fragment.\n") + if len(l.playerDice) < 5 { + l.playerDice = append(l.playerDice, rand.Intn(6)+1) + } + } else { + sb.WriteString("Not an exact match. You lose a fragment.\n") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } + + return l.checkGameOver(sb) +} + +func (l *LiarsDiceGame) checkGameOver(sb strings.Builder) (string, bool, bool) { + if len(l.playerDice) == 0 { + sb.WriteString("\nYou've lost all your fragments. Connection terminated.") + l.gameOver = true + l.won = false + l.completed = true + return sb.String(), true, false + } + if len(l.aiDice) == 0 { + sb.WriteString("\nSystem has no fragments left. You win!") + l.gameOver = true + l.won = true + l.completed = true + return sb.String(), true, true + } + + sb.WriteString(l.startRound()) + return sb.String(), false, false +} + +func (l *LiarsDiceGame) showAllDice() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Your fragments: %s\n", l.diceStr(l.playerDice))) + sb.WriteString(fmt.Sprintf("System fragments: %s\n", l.diceStr(l.aiDice))) + return sb.String() +} + +func (l *LiarsDiceGame) diceStr(dice []int) string { + var parts []string + for _, d := range dice { + parts = append(parts, fmt.Sprintf("[%d]", d)) + } + return strings.Join(parts, " ") +} + +func (l *LiarsDiceGame) doStatus() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) + sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", + l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) + if l.hasBid { + who := "System" + if !l.playerTurn { + who = "System" + } else { + who = "you" + } + sb.WriteString(fmt.Sprintf("Current bid: %d %s (by %s)\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face), who)) + } + if l.playerTurn { + sb.WriteString("Your turn: make a bid.") + } else { + sb.WriteString("Your turn: bid higher, call, or exact.") + } + return sb.String() +} + +func (l *LiarsDiceGame) startRound() string { + l.round++ + for i := range l.playerDice { + l.playerDice[i] = rand.Intn(6) + 1 + } + for i := range l.aiDice { + l.aiDice[i] = rand.Intn(6) + 1 + } + l.hasBid = false + l.playerTurn = true + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) + sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", + l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) + sb.WriteString("You go first. Make a bid.\n") + return sb.String() +} + +func (l *LiarsDiceGame) aiTurn() (string, bool, bool) { + totalDice := len(l.playerDice) + len(l.aiDice) + + aiCount := l.countFace(l.currentBid.Face) + unknownDice := len(l.playerDice) + estimated := float64(aiCount) + float64(unknownDice)/3.0 + + if float64(l.currentBid.Quantity) > estimated*1.5 { + return l.doCall(true) + } + + bestFace, bestCount := l.aiBestFace() + newQty := l.currentBid.Quantity + newFace := l.currentBid.Face + + if bestFace > newFace && bestCount >= newQty { + newFace = bestFace + } else { + newQty++ + } + + if newQty > totalDice { + return l.doCall(true) + } + + l.currentBid = LiarsBid{Quantity: newQty, Face: newFace} + l.playerTurn = true + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("System broadcasts: %d %s.\n", newQty, faceNamePlural(newFace))) + sb.WriteString("Your turn: bid higher, call, or exact.\n") + return sb.String(), false, false +} + +func (l *LiarsDiceGame) aiBestFace() (face int, count int) { + counts := make(map[int]int) + for _, d := range l.aiDice { + if d == 1 { + continue + } + counts[d]++ + } + wilds := 0 + for _, d := range l.aiDice { + if d == 1 { + wilds++ + } + } + best := 2 + bestN := counts[2] + wilds + for f := 3; f <= 6; f++ { + n := counts[f] + wilds + if n > bestN || (n == bestN && f > best) { + best = f + bestN = n + } + } + return best, bestN +} + +func (l *LiarsDiceGame) countFace(face int) int { + count := 0 + for _, d := range l.playerDice { + if d == face || d == 1 { + count++ + } + } + for _, d := range l.aiDice { + if d == face || d == 1 { + count++ + } + } + return count +} + +func (l *LiarsDiceGame) countFaceNonWild(face int) int { + count := 0 + for _, d := range l.playerDice { + if d == face { + count++ + } + } + for _, d := range l.aiDice { + if d == face { + count++ + } + } + return count +} + +func faceName(f int) string { + names := []string{"", "one", "two", "three", "four", "five", "six"} + if f >= 1 && f <= 6 { + return names[f] + } + return fmt.Sprint(f) +} + +func faceNamePlural(f int) string { + names := []string{"", "ones", "twos", "threes", "fours", "fives", "sixes"} + if f >= 1 && f <= 6 { + return names[f] + } + return fmt.Sprint(f) +} + +func (l *LiarsDiceGame) BonusXP() int { + if l.completed && l.won && l.playerLost == 0 { + return 150 + } + return 0 +} diff --git a/internal/game/hacking/hacking_mastermind.go b/internal/game/hacking/hacking_mastermind.go new file mode 100644 index 0000000..8d4eb1c --- /dev/null +++ b/internal/game/hacking/hacking_mastermind.go @@ -0,0 +1,177 @@ +package hacking + +import ( + "fmt" + "math/rand" + "strings" +) + +type MastermindGame struct { + code [4]int + guesses []MastermindGuess + maxGuess int + gameOver bool + won bool +} + +type MastermindGuess struct { + Digits [4]int + Exact int + Partial int +} + +func NewMastermindGame() *MastermindGame { + return &MastermindGame{ + maxGuess: 10, + } +} + +func (m *MastermindGame) Name() string { + return "Code Breaker" +} + +func (m *MastermindGame) Init(level int) string { + pool := []int{1, 2, 3, 4, 5, 6} + rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) + copy(m.code[:], pool[:4]) + m.guesses = nil + m.gameOver = false + m.won = false + + var sb strings.Builder + sb.WriteString("=== CODE BREAKER ===\n") + sb.WriteString("\n") + sb.WriteString("The encryption module is running a 4-digit cipher using symbols 1-6.\n") + sb.WriteString("No symbol repeats. You have 10 attempts to crack the code.\n") + sb.WriteString("\n") + sb.WriteString("After each guess, you'll see:\n") + sb.WriteString(" {40}[X]{/} = correct symbol in correct position\n") + sb.WriteString(" {226}[O]{/} = correct symbol in wrong position\n") + sb.WriteString(" [ ] = symbol not in code\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" <4 digits> - Guess the code (e.g., 1234)\n") + sb.WriteString(" status - Show previous guesses\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + return sb.String() +} + +func (m *MastermindGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(input) + lower := strings.ToLower(strings.TrimSpace(input)) + + if lower == "status" { + return m.doStatus(), false, false + } + + if len(input) != 4 { + return "Enter exactly 4 digits (1-6, no repeats).", false, false + } + + var guess [4]int + seen := make(map[int]bool) + for i, ch := range input { + if ch < '1' || ch > '6' { + return "Each digit must be 1-6.", false, false + } + d := int(ch - '0') + if seen[d] { + return "No repeating digits allowed.", false, false + } + seen[d] = true + guess[i] = d + } + + exact := 0 + partial := 0 + for i := 0; i < 4; i++ { + if guess[i] == m.code[i] { + exact++ + } else { + for j := 0; j < 4; j++ { + if guess[i] == m.code[j] { + partial++ + break + } + } + } + } + + m.guesses = append(m.guesses, MastermindGuess{ + Digits: guess, + Exact: exact, + Partial: partial, + }) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Attempt %d/%d: ", len(m.guesses), m.maxGuess)) + for i, d := range guess { + match := "[ ]" + if guess[i] == m.code[i] { + match = "{40}[X]{/}" + } else { + for j := 0; j < 4; j++ { + if guess[i] == m.code[j] { + match = "{226}[O]{/}" + break + } + } + } + sb.WriteString(fmt.Sprintf("%d%s ", d, match)) + } + sb.WriteString(fmt.Sprintf(" (%d locked, %d found)", exact, partial)) + + if exact == 4 { + m.gameOver = true + m.won = true + sb.WriteString("\n\nCode cracked! The encryption module yields.\nConnection terminated.") + return sb.String(), true, true + } + + if len(m.guesses) >= m.maxGuess { + m.gameOver = true + m.won = false + sb.WriteString(fmt.Sprintf("\n\nOut of attempts. The code was: %d %d %d %d.\nConnection terminated.", + m.code[0], m.code[1], m.code[2], m.code[3])) + return sb.String(), true, false + } + + return sb.String(), false, false +} + +func (m *MastermindGame) doStatus() string { + if len(m.guesses) == 0 { + return fmt.Sprintf("No guesses yet. Remaining: %d/%d", m.maxGuess, m.maxGuess) + } + var sb strings.Builder + sb.WriteString("=== Attempts ===\n") + for i, g := range m.guesses { + sb.WriteString(fmt.Sprintf(" %2d: %d %d %d %d -> ", i+1, g.Digits[0], g.Digits[1], g.Digits[2], g.Digits[3])) + for j, d := range g.Digits { + match := "[ ]" + if g.Digits[j] == m.code[j] { + match = "{40}[X]{/}" + } else { + found := false + for k := 0; k < 4; k++ { + if g.Digits[j] == m.code[k] { + found = true + break + } + } + if found { + match = "{226}[O]{/}" + } + } + _ = d + sb.WriteString(match) + } + sb.WriteString(fmt.Sprintf(" (%d locked, %d found)\n", g.Exact, g.Partial)) + } + sb.WriteString(fmt.Sprintf("Remaining: %d/%d\n", m.maxGuess-len(m.guesses), m.maxGuess)) + return sb.String() +} + +func (m *MastermindGame) BonusXP() int { + return (m.maxGuess - len(m.guesses)) * 15 +} diff --git a/internal/game/hacking/hacking_wumpus.go b/internal/game/hacking/hacking_wumpus.go new file mode 100644 index 0000000..91b9243 --- /dev/null +++ b/internal/game/hacking/hacking_wumpus.go @@ -0,0 +1,286 @@ +package hacking + +import ( + "fmt" + "math/rand" + "strconv" + "strings" +) + +var dodecahedron = [20][3]int{ + {1, 4, 7}, + {0, 2, 9}, + {1, 3, 11}, + {2, 4, 13}, + {0, 3, 5}, + {4, 6, 14}, + {5, 7, 16}, + {0, 6, 8}, + {7, 9, 17}, + {1, 8, 10}, + {9, 11, 18}, + {2, 10, 12}, + {11, 13, 19}, + {3, 12, 14}, + {5, 13, 15}, + {14, 16, 19}, + {6, 15, 17}, + {8, 16, 18}, + {10, 17, 19}, + {12, 15, 18}, +} + +type WumpusGame struct { + player int + wumpus int + pits [2]int + ice [2]int + probes int + gameOver bool + won bool +} + +func NewWumpusGame() *WumpusGame { + return &WumpusGame{} +} + +func (w *WumpusGame) Name() string { + return "Hunt the Wumpus" +} + +func (w *WumpusGame) Init(level int) string { + taken := make(map[int]bool) + w.player = rand.Intn(20) + taken[w.player] = true + + w.wumpus = randFree(taken) + taken[w.wumpus] = true + + w.pits[0] = randFree(taken) + taken[w.pits[0]] = true + w.pits[1] = randFree(taken) + taken[w.pits[1]] = true + + w.ice[0] = randFree(taken) + taken[w.ice[0]] = true + w.ice[1] = randFree(taken) + + w.probes = 5 + w.gameOver = false + w.won = false + + var sb strings.Builder + sb.WriteString("=== HUNT THE ROGUE AI ===\n") + sb.WriteString("\n") + sb.WriteString("You've jacked into an abandoned network. Somewhere in this maze of\n") + sb.WriteString("20 nodes, a rogue AI lurks. You have 5 probes to find and neutralize it.\n") + sb.WriteString("\n") + sb.WriteString("Hazards:\n") + sb.WriteString(" - Rogue AI: Moves to an adjacent node if you enter its node. Kills you.\n") + sb.WriteString(" - Data Traps: Fall in and you're fried. 2 in the network.\n") + sb.WriteString(" - ICE: Grabs you and dumps you in a random node. 2 in the network.\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" move - Move to an adjacent node (1-20)\n") + sb.WriteString(" shoot - Launch a probe into an adjacent node (1-20)\n") + sb.WriteString(" status - Show your current status\n") + sb.WriteString(" map - Show network map\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String() +} + +func (w *WumpusGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(strings.ToLower(input)) + parts := strings.Fields(input) + + if len(parts) == 0 { + return "Unknown command.", false, false + } + + cmd := parts[0] + + switch cmd { + case "move", "m": + if len(parts) < 2 { + return "Move where? (e.g., move 3)", false, false + } + n, err := strconv.Atoi(parts[1]) + if err != nil || n < 1 || n > 20 { + return "Invalid node. Use 1-20.", false, false + } + return w.doMove(n - 1) + case "shoot", "probe", "launch", "s": + if len(parts) < 2 { + return "Shoot where? (e.g., shoot 3)", false, false + } + n, err := strconv.Atoi(parts[1]) + if err != nil || n < 1 || n > 20 { + return "Invalid node. Use 1-20.", false, false + } + return w.doShoot(n - 1) + case "status": + return w.doStatus(), false, false + case "map": + return w.doMap(), false, false + default: + n, err := strconv.Atoi(cmd) + if err == nil && n >= 1 && n <= 20 { + return w.doMove(n - 1) + } + return "Commands: move , shoot , status, map, jack out", false, false + } +} + +func (w *WumpusGame) doMove(target int) (string, bool, bool) { + if !w.hasAdjacent(w.player, target) { + adj := w.adjStrings(w.player) + return fmt.Sprintf("Can't move there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false + } + w.player = target + return w.checkRoom() +} + +func (w *WumpusGame) doShoot(target int) (string, bool, bool) { + if !w.hasAdjacent(w.player, target) { + adj := w.adjStrings(w.player) + return fmt.Sprintf("Can't shoot there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false + } + w.probes-- + if target == w.wumpus { + w.gameOver = true + w.won = true + return "Your probe hits the rogue AI! It destabilizes and crashes.\n\nConnection terminated.", true, true + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Your probe finds nothing in node %d.", target+1)) + if w.probes == 0 { + sb.WriteString("\nYou're out of probes. The rogue AI detects your presence and terminates your connection.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + if rand.Float64() < 0.75 { + w.wumpus = w.randomAdjacent(w.wumpus) + sb.WriteString("\nYou hear something shift in the network...") + } + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String(), false, false +} + +func (w *WumpusGame) checkRoom() (string, bool, bool) { + var sb strings.Builder + cur := w.player + if cur == w.wumpus { + if rand.Float64() < 0.75 { + newRoom := w.randomAdjacent(w.wumpus) + w.wumpus = newRoom + sb.WriteString("--- Node " + fmt.Sprint(cur+1) + " ---\n") + sb.WriteString("The rogue AI stirs and relocates to a nearby node...\n") + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String(), false, false + } + sb.WriteString("The rogue AI devours your connection!\n\nConnection terminated.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + for _, p := range w.pits { + if cur == p { + sb.WriteString("You've fallen into a data trap! Your connection is severed.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + } + for _, i := range w.ice { + if cur == i { + sb.WriteString("ICE detected! You're relocated to a random node...\n") + w.player = rand.Intn(20) + chained, _, _ := w.checkRoom() + sb.WriteString(chained) + return sb.String(), false, false + } + } + sb.WriteString(w.roomDesc()) + return sb.String(), false, false +} + +func (w *WumpusGame) roomDesc() string { + cur := w.player + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Node %d ---\n", cur+1)) + adj := w.adjStrings(cur) + sb.WriteString(fmt.Sprintf("Tunnels lead to: %s\n", strings.Join(adj, ", "))) + + for _, a := range dodecahedron[cur] { + if a == w.wumpus { + sb.WriteString("{196}You detect corrupted data nearby...{/}\n") + } + for _, p := range w.pits { + if a == p { + sb.WriteString("{208}You sense a void in the network...{/}\n") + } + } + for _, i := range w.ice { + if a == i { + sb.WriteString("{226}You hear static crackling...{/}\n") + } + } + } + return sb.String() +} + +func (w *WumpusGame) doStatus() string { + return fmt.Sprintf("Node: %d | Probes: %d/5", w.player+1, w.probes) +} + +func (w *WumpusGame) doMap() string { + var sb strings.Builder + sb.WriteString("=== Network Map ===\n") + for i := 0; i < 20; i++ { + marker := " " + if i == w.player { + marker = "*" + } + adj := dodecahedron[i] + sb.WriteString(fmt.Sprintf(" [%s] Node %2d -> %2d, %2d, %2d\n", + marker, i+1, adj[0]+1, adj[1]+1, adj[2]+1)) + } + sb.WriteString(fmt.Sprintf("\nYou are at Node %d.\n", w.player+1)) + return sb.String() +} + +func (w *WumpusGame) hasAdjacent(from, to int) bool { + for _, a := range dodecahedron[from] { + if a == to { + return true + } + } + return false +} + +func (w *WumpusGame) randomAdjacent(r int) int { + adj := dodecahedron[r] + return adj[rand.Intn(len(adj))] +} + +func (w *WumpusGame) adjStrings(r int) []string { + var out []string + for _, a := range dodecahedron[r] { + out = append(out, fmt.Sprint(a+1)) + } + return out +} + +func randFree(taken map[int]bool) int { + for { + r := rand.Intn(20) + if !taken[r] { + return r + } + } +} diff --git a/internal/game/hacking_liars_dice.go b/internal/game/hacking_liars_dice.go deleted file mode 100644 index 12f76c1..0000000 --- a/internal/game/hacking_liars_dice.go +++ /dev/null @@ -1,395 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strconv" - "strings" -) - -type LiarsDiceGame struct { - playerDice []int - aiDice []int - currentBid LiarsBid - hasBid bool - playerTurn bool - round int - gameOver bool - won bool - playerLost int - completed bool -} - -type LiarsBid struct { - Quantity int - Face int -} - -func NewLiarsDiceGame() *LiarsDiceGame { - return &LiarsDiceGame{ - playerDice: make([]int, 5), - aiDice: make([]int, 5), - } -} - -func (l *LiarsDiceGame) Name() string { - return "Signal Bluff" -} - -func (l *LiarsDiceGame) Init(level int) string { - l.playerDice = make([]int, 5) - l.aiDice = make([]int, 5) - l.round = 0 - l.gameOver = false - l.playerTurn = true - l.hasBid = false - l.playerLost = 0 - l.completed = false - - var sb strings.Builder - sb.WriteString("=== SIGNAL BLUFF ===\n") - sb.WriteString("\n") - sb.WriteString("You've connected to a secure channel running a bluffing protocol.\n") - sb.WriteString("You and the system each have 5 signal fragments (dice). Each round,\n") - sb.WriteString("fragments are scrambled. You see only yours. Take turns making claims\n") - sb.WriteString("about the total count of a specific signal across ALL fragments.\n") - sb.WriteString("\n") - sb.WriteString("1s are wild -- they count as any signal.\n") - sb.WriteString("\n") - sb.WriteString("Commands:\n") - sb.WriteString(" bid - Claim at least N fragments show face F\n") - sb.WriteString(" (e.g., 'bid 3 4' = \"at least three 4s\")\n") - sb.WriteString(" call - Challenge the last bid (reveal all)\n") - sb.WriteString(" exact - Claim the bid is exactly right\n") - sb.WriteString(" status - Show your fragments and current bid\n") - sb.WriteString(" jack out - Disconnect (forfeit)\n") - sb.WriteString("\n") - sb.WriteString(l.startRound()) - return sb.String() -} - -func (l *LiarsDiceGame) HandleInput(input string) (string, bool, bool) { - input = strings.TrimSpace(strings.ToLower(input)) - parts := strings.Fields(input) - - if len(parts) == 0 { - return "Commands: bid , call, exact, status, jack out", false, false - } - - cmd := parts[0] - - switch cmd { - case "bid", "b": - if len(parts) < 3 { - return "Usage: bid (e.g., bid 3 4)", false, false - } - qty, err1 := strconv.Atoi(parts[1]) - face, err2 := strconv.Atoi(parts[2]) - if err1 != nil || err2 != nil { - return "Usage: bid (e.g., bid 3 4)", false, false - } - return l.doBid(qty, face) - case "call", "c", "intercept", "liar": - return l.doCall(false) - case "exact", "e": - return l.doExact() - case "status": - return l.doStatus(), false, false - default: - if len(parts) == 2 { - qty, err1 := strconv.Atoi(parts[0]) - face, err2 := strconv.Atoi(parts[1]) - if err1 == nil && err2 == nil { - return l.doBid(qty, face) - } - } - return "Commands: bid , call, exact, status, jack out", false, false - } -} - -func (l *LiarsDiceGame) doBid(qty, face int) (string, bool, bool) { - if face < 1 || face > 6 { - return "Face must be 1-6.", false, false - } - if qty < 1 { - return "Quantity must be at least 1.", false, false - } - if !l.playerTurn { - return "It's not your turn to bid. Use 'call' or 'exact'.", false, false - } - - if l.hasBid { - if qty < l.currentBid.Quantity || (qty == l.currentBid.Quantity && face <= l.currentBid.Face) { - return "You must bid higher (higher quantity or same quantity with higher face).", false, false - } - } - - l.currentBid = LiarsBid{Quantity: qty, Face: face} - l.hasBid = true - l.playerTurn = false - - return l.aiTurn() -} - -func (l *LiarsDiceGame) doCall(player bool) (string, bool, bool) { - if !l.hasBid { - return "No bid to call yet. Make a bid first.", false, false - } - - var sb strings.Builder - if player { - sb.WriteString("System intercepts!\n") - } else { - sb.WriteString("You intercept!\n") - } - - sb.WriteString(l.showAllDice()) - sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) - - total := l.countFace(l.currentBid.Face) - sb.WriteString(fmt.Sprintf("Actual count: %d (%d %ss + %d wild 1s)\n\n", - total, - l.countFaceNonWild(l.currentBid.Face), - faceName(l.currentBid.Face), - l.countFace(1)-l.countFaceNonWild(1))) - - if total >= l.currentBid.Quantity { - sb.WriteString("The bid holds! ") - if player { - sb.WriteString("System loses a fragment.") - l.aiDice = l.aiDice[:len(l.aiDice)-1] - } else { - sb.WriteString("You lose a fragment.") - l.playerDice = l.playerDice[:len(l.playerDice)-1] - l.playerLost++ - } - } else { - sb.WriteString("Bluff called! ") - if player { - sb.WriteString("You lose a fragment.") - l.playerDice = l.playerDice[:len(l.playerDice)-1] - l.playerLost++ - } else { - sb.WriteString("System loses a fragment.") - l.aiDice = l.aiDice[:len(l.aiDice)-1] - } - } - - return l.checkGameOver(sb) -} - -func (l *LiarsDiceGame) doExact() (string, bool, bool) { - if !l.hasBid { - return "No bid to claim exact on. Make a bid first.", false, false - } - - var sb strings.Builder - sb.WriteString("You claim exact match!\n") - - sb.WriteString(l.showAllDice()) - total := l.countFace(l.currentBid.Face) - sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) - sb.WriteString(fmt.Sprintf("Actual count: %d\n\n", total)) - - if total == l.currentBid.Quantity { - sb.WriteString("Exact match! You recover a fragment.\n") - if len(l.playerDice) < 5 { - l.playerDice = append(l.playerDice, rand.Intn(6)+1) - } - } else { - sb.WriteString("Not an exact match. You lose a fragment.\n") - l.playerDice = l.playerDice[:len(l.playerDice)-1] - l.playerLost++ - } - - return l.checkGameOver(sb) -} - -func (l *LiarsDiceGame) checkGameOver(sb strings.Builder) (string, bool, bool) { - if len(l.playerDice) == 0 { - sb.WriteString("\nYou've lost all your fragments. Connection terminated.") - l.gameOver = true - l.won = false - l.completed = true - return sb.String(), true, false - } - if len(l.aiDice) == 0 { - sb.WriteString("\nSystem has no fragments left. You win!") - l.gameOver = true - l.won = true - l.completed = true - return sb.String(), true, true - } - - sb.WriteString(l.startRound()) - return sb.String(), false, false -} - -func (l *LiarsDiceGame) showAllDice() string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Your fragments: %s\n", l.diceStr(l.playerDice))) - sb.WriteString(fmt.Sprintf("System fragments: %s\n", l.diceStr(l.aiDice))) - return sb.String() -} - -func (l *LiarsDiceGame) diceStr(dice []int) string { - var parts []string - for _, d := range dice { - parts = append(parts, fmt.Sprintf("[%d]", d)) - } - return strings.Join(parts, " ") -} - -func (l *LiarsDiceGame) doStatus() string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) - sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", - l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) - if l.hasBid { - who := "System" - if !l.playerTurn { - who = "System" - } else { - who = "you" - } - sb.WriteString(fmt.Sprintf("Current bid: %d %s (by %s)\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face), who)) - } - if l.playerTurn { - sb.WriteString("Your turn: make a bid.") - } else { - sb.WriteString("Your turn: bid higher, call, or exact.") - } - return sb.String() -} - -func (l *LiarsDiceGame) startRound() string { - l.round++ - for i := range l.playerDice { - l.playerDice[i] = rand.Intn(6) + 1 - } - for i := range l.aiDice { - l.aiDice[i] = rand.Intn(6) + 1 - } - l.hasBid = false - l.playerTurn = true - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) - sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", - l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) - sb.WriteString("You go first. Make a bid.\n") - return sb.String() -} - -func (l *LiarsDiceGame) aiTurn() (string, bool, bool) { - totalDice := len(l.playerDice) + len(l.aiDice) - - aiCount := l.countFace(l.currentBid.Face) - unknownDice := len(l.playerDice) - estimated := float64(aiCount) + float64(unknownDice)/3.0 - - if float64(l.currentBid.Quantity) > estimated*1.5 { - return l.doCall(true) - } - - bestFace, bestCount := l.aiBestFace() - newQty := l.currentBid.Quantity - newFace := l.currentBid.Face - - if bestFace > newFace && bestCount >= newQty { - newFace = bestFace - } else { - newQty++ - } - - if newQty > totalDice { - return l.doCall(true) - } - - l.currentBid = LiarsBid{Quantity: newQty, Face: newFace} - l.playerTurn = true - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("System broadcasts: %d %s.\n", newQty, faceNamePlural(newFace))) - sb.WriteString("Your turn: bid higher, call, or exact.\n") - return sb.String(), false, false -} - -func (l *LiarsDiceGame) aiBestFace() (face int, count int) { - counts := make(map[int]int) - for _, d := range l.aiDice { - if d == 1 { - continue - } - counts[d]++ - } - wilds := 0 - for _, d := range l.aiDice { - if d == 1 { - wilds++ - } - } - best := 2 - bestN := counts[2] + wilds - for f := 3; f <= 6; f++ { - n := counts[f] + wilds - if n > bestN || (n == bestN && f > best) { - best = f - bestN = n - } - } - return best, bestN -} - -func (l *LiarsDiceGame) countFace(face int) int { - count := 0 - for _, d := range l.playerDice { - if d == face || d == 1 { - count++ - } - } - for _, d := range l.aiDice { - if d == face || d == 1 { - count++ - } - } - return count -} - -func (l *LiarsDiceGame) countFaceNonWild(face int) int { - count := 0 - for _, d := range l.playerDice { - if d == face { - count++ - } - } - for _, d := range l.aiDice { - if d == face { - count++ - } - } - return count -} - -func faceName(f int) string { - names := []string{"", "one", "two", "three", "four", "five", "six"} - if f >= 1 && f <= 6 { - return names[f] - } - return fmt.Sprint(f) -} - -func faceNamePlural(f int) string { - names := []string{"", "ones", "twos", "threes", "fours", "fives", "sixes"} - if f >= 1 && f <= 6 { - return names[f] - } - return fmt.Sprint(f) -} - -func (l *LiarsDiceGame) BonusXP() int { - if l.completed && l.won && l.playerLost == 0 { - return 150 - } - return 0 -} diff --git a/internal/game/hacking_mastermind.go b/internal/game/hacking_mastermind.go deleted file mode 100644 index 3ab7cca..0000000 --- a/internal/game/hacking_mastermind.go +++ /dev/null @@ -1,177 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strings" -) - -type MastermindGame struct { - code [4]int - guesses []MastermindGuess - maxGuess int - gameOver bool - won bool -} - -type MastermindGuess struct { - Digits [4]int - Exact int - Partial int -} - -func NewMastermindGame() *MastermindGame { - return &MastermindGame{ - maxGuess: 10, - } -} - -func (m *MastermindGame) Name() string { - return "Code Breaker" -} - -func (m *MastermindGame) Init(level int) string { - pool := []int{1, 2, 3, 4, 5, 6} - rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) - copy(m.code[:], pool[:4]) - m.guesses = nil - m.gameOver = false - m.won = false - - var sb strings.Builder - sb.WriteString("=== CODE BREAKER ===\n") - sb.WriteString("\n") - sb.WriteString("The encryption module is running a 4-digit cipher using symbols 1-6.\n") - sb.WriteString("No symbol repeats. You have 10 attempts to crack the code.\n") - sb.WriteString("\n") - sb.WriteString("After each guess, you'll see:\n") - sb.WriteString(" {40}[X]{/} = correct symbol in correct position\n") - sb.WriteString(" {226}[O]{/} = correct symbol in wrong position\n") - sb.WriteString(" [ ] = symbol not in code\n") - sb.WriteString("\n") - sb.WriteString("Commands:\n") - sb.WriteString(" <4 digits> - Guess the code (e.g., 1234)\n") - sb.WriteString(" status - Show previous guesses\n") - sb.WriteString(" jack out - Disconnect (forfeit)\n") - return sb.String() -} - -func (m *MastermindGame) HandleInput(input string) (string, bool, bool) { - input = strings.TrimSpace(input) - lower := strings.ToLower(strings.TrimSpace(input)) - - if lower == "status" { - return m.doStatus(), false, false - } - - if len(input) != 4 { - return "Enter exactly 4 digits (1-6, no repeats).", false, false - } - - var guess [4]int - seen := make(map[int]bool) - for i, ch := range input { - if ch < '1' || ch > '6' { - return "Each digit must be 1-6.", false, false - } - d := int(ch - '0') - if seen[d] { - return "No repeating digits allowed.", false, false - } - seen[d] = true - guess[i] = d - } - - exact := 0 - partial := 0 - for i := 0; i < 4; i++ { - if guess[i] == m.code[i] { - exact++ - } else { - for j := 0; j < 4; j++ { - if guess[i] == m.code[j] { - partial++ - break - } - } - } - } - - m.guesses = append(m.guesses, MastermindGuess{ - Digits: guess, - Exact: exact, - Partial: partial, - }) - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Attempt %d/%d: ", len(m.guesses), m.maxGuess)) - for i, d := range guess { - match := "[ ]" - if guess[i] == m.code[i] { - match = "{40}[X]{/}" - } else { - for j := 0; j < 4; j++ { - if guess[i] == m.code[j] { - match = "{226}[O]{/}" - break - } - } - } - sb.WriteString(fmt.Sprintf("%d%s ", d, match)) - } - sb.WriteString(fmt.Sprintf(" (%d locked, %d found)", exact, partial)) - - if exact == 4 { - m.gameOver = true - m.won = true - sb.WriteString("\n\nCode cracked! The encryption module yields.\nConnection terminated.") - return sb.String(), true, true - } - - if len(m.guesses) >= m.maxGuess { - m.gameOver = true - m.won = false - sb.WriteString(fmt.Sprintf("\n\nOut of attempts. The code was: %d %d %d %d.\nConnection terminated.", - m.code[0], m.code[1], m.code[2], m.code[3])) - return sb.String(), true, false - } - - return sb.String(), false, false -} - -func (m *MastermindGame) doStatus() string { - if len(m.guesses) == 0 { - return fmt.Sprintf("No guesses yet. Remaining: %d/%d", m.maxGuess, m.maxGuess) - } - var sb strings.Builder - sb.WriteString("=== Attempts ===\n") - for i, g := range m.guesses { - sb.WriteString(fmt.Sprintf(" %2d: %d %d %d %d -> ", i+1, g.Digits[0], g.Digits[1], g.Digits[2], g.Digits[3])) - for j, d := range g.Digits { - match := "[ ]" - if g.Digits[j] == m.code[j] { - match = "{40}[X]{/}" - } else { - found := false - for k := 0; k < 4; k++ { - if g.Digits[j] == m.code[k] { - found = true - break - } - } - if found { - match = "{226}[O]{/}" - } - } - _ = d - sb.WriteString(match) - } - sb.WriteString(fmt.Sprintf(" (%d locked, %d found)\n", g.Exact, g.Partial)) - } - sb.WriteString(fmt.Sprintf("Remaining: %d/%d\n", m.maxGuess-len(m.guesses), m.maxGuess)) - return sb.String() -} - -func (m *MastermindGame) BonusXP() int { - return (m.maxGuess - len(m.guesses)) * 15 -} diff --git a/internal/game/hacking_wumpus.go b/internal/game/hacking_wumpus.go deleted file mode 100644 index 600b1be..0000000 --- a/internal/game/hacking_wumpus.go +++ /dev/null @@ -1,286 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "strconv" - "strings" -) - -var dodecahedron = [20][3]int{ - {1, 4, 7}, - {0, 2, 9}, - {1, 3, 11}, - {2, 4, 13}, - {0, 3, 5}, - {4, 6, 14}, - {5, 7, 16}, - {0, 6, 8}, - {7, 9, 17}, - {1, 8, 10}, - {9, 11, 18}, - {2, 10, 12}, - {11, 13, 19}, - {3, 12, 14}, - {5, 13, 15}, - {14, 16, 19}, - {6, 15, 17}, - {8, 16, 18}, - {10, 17, 19}, - {12, 15, 18}, -} - -type WumpusGame struct { - player int - wumpus int - pits [2]int - ice [2]int - probes int - gameOver bool - won bool -} - -func NewWumpusGame() *WumpusGame { - return &WumpusGame{} -} - -func (w *WumpusGame) Name() string { - return "Hunt the Wumpus" -} - -func (w *WumpusGame) Init(level int) string { - taken := make(map[int]bool) - w.player = rand.Intn(20) - taken[w.player] = true - - w.wumpus = randFree(taken) - taken[w.wumpus] = true - - w.pits[0] = randFree(taken) - taken[w.pits[0]] = true - w.pits[1] = randFree(taken) - taken[w.pits[1]] = true - - w.ice[0] = randFree(taken) - taken[w.ice[0]] = true - w.ice[1] = randFree(taken) - - w.probes = 5 - w.gameOver = false - w.won = false - - var sb strings.Builder - sb.WriteString("=== HUNT THE ROGUE AI ===\n") - sb.WriteString("\n") - sb.WriteString("You've jacked into an abandoned network. Somewhere in this maze of\n") - sb.WriteString("20 nodes, a rogue AI lurks. You have 5 probes to find and neutralize it.\n") - sb.WriteString("\n") - sb.WriteString("Hazards:\n") - sb.WriteString(" - Rogue AI: Moves to an adjacent node if you enter its node. Kills you.\n") - sb.WriteString(" - Data Traps: Fall in and you're fried. 2 in the network.\n") - sb.WriteString(" - ICE: Grabs you and dumps you in a random node. 2 in the network.\n") - sb.WriteString("\n") - sb.WriteString("Commands:\n") - sb.WriteString(" move - Move to an adjacent node (1-20)\n") - sb.WriteString(" shoot - Launch a probe into an adjacent node (1-20)\n") - sb.WriteString(" status - Show your current status\n") - sb.WriteString(" map - Show network map\n") - sb.WriteString(" jack out - Disconnect (forfeit)\n") - sb.WriteString("\n") - sb.WriteString(w.roomDesc()) - return sb.String() -} - -func (w *WumpusGame) HandleInput(input string) (string, bool, bool) { - input = strings.TrimSpace(strings.ToLower(input)) - parts := strings.Fields(input) - - if len(parts) == 0 { - return "Unknown command.", false, false - } - - cmd := parts[0] - - switch cmd { - case "move", "m": - if len(parts) < 2 { - return "Move where? (e.g., move 3)", false, false - } - n, err := strconv.Atoi(parts[1]) - if err != nil || n < 1 || n > 20 { - return "Invalid node. Use 1-20.", false, false - } - return w.doMove(n - 1) - case "shoot", "probe", "launch", "s": - if len(parts) < 2 { - return "Shoot where? (e.g., shoot 3)", false, false - } - n, err := strconv.Atoi(parts[1]) - if err != nil || n < 1 || n > 20 { - return "Invalid node. Use 1-20.", false, false - } - return w.doShoot(n - 1) - case "status": - return w.doStatus(), false, false - case "map": - return w.doMap(), false, false - default: - n, err := strconv.Atoi(cmd) - if err == nil && n >= 1 && n <= 20 { - return w.doMove(n - 1) - } - return "Commands: move , shoot , status, map, jack out", false, false - } -} - -func (w *WumpusGame) doMove(target int) (string, bool, bool) { - if !w.hasAdjacent(w.player, target) { - adj := w.adjStrings(w.player) - return fmt.Sprintf("Can't move there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false - } - w.player = target - return w.checkRoom() -} - -func (w *WumpusGame) doShoot(target int) (string, bool, bool) { - if !w.hasAdjacent(w.player, target) { - adj := w.adjStrings(w.player) - return fmt.Sprintf("Can't shoot there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false - } - w.probes-- - if target == w.wumpus { - w.gameOver = true - w.won = true - return "Your probe hits the rogue AI! It destabilizes and crashes.\n\nConnection terminated.", true, true - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Your probe finds nothing in node %d.", target+1)) - if w.probes == 0 { - sb.WriteString("\nYou're out of probes. The rogue AI detects your presence and terminates your connection.") - w.gameOver = true - w.won = false - return sb.String(), true, false - } - if rand.Float64() < 0.75 { - w.wumpus = w.randomAdjacent(w.wumpus) - sb.WriteString("\nYou hear something shift in the network...") - } - sb.WriteString("\n") - sb.WriteString(w.roomDesc()) - return sb.String(), false, false -} - -func (w *WumpusGame) checkRoom() (string, bool, bool) { - var sb strings.Builder - cur := w.player - if cur == w.wumpus { - if rand.Float64() < 0.75 { - newRoom := w.randomAdjacent(w.wumpus) - w.wumpus = newRoom - sb.WriteString("--- Node " + fmt.Sprint(cur+1) + " ---\n") - sb.WriteString("The rogue AI stirs and relocates to a nearby node...\n") - sb.WriteString("\n") - sb.WriteString(w.roomDesc()) - return sb.String(), false, false - } - sb.WriteString("The rogue AI devours your connection!\n\nConnection terminated.") - w.gameOver = true - w.won = false - return sb.String(), true, false - } - for _, p := range w.pits { - if cur == p { - sb.WriteString("You've fallen into a data trap! Your connection is severed.") - w.gameOver = true - w.won = false - return sb.String(), true, false - } - } - for _, i := range w.ice { - if cur == i { - sb.WriteString("ICE detected! You're relocated to a random node...\n") - w.player = rand.Intn(20) - chained, _, _ := w.checkRoom() - sb.WriteString(chained) - return sb.String(), false, false - } - } - sb.WriteString(w.roomDesc()) - return sb.String(), false, false -} - -func (w *WumpusGame) roomDesc() string { - cur := w.player - var sb strings.Builder - sb.WriteString(fmt.Sprintf("--- Node %d ---\n", cur+1)) - adj := w.adjStrings(cur) - sb.WriteString(fmt.Sprintf("Tunnels lead to: %s\n", strings.Join(adj, ", "))) - - for _, a := range dodecahedron[cur] { - if a == w.wumpus { - sb.WriteString("{196}You detect corrupted data nearby...{/}\n") - } - for _, p := range w.pits { - if a == p { - sb.WriteString("{208}You sense a void in the network...{/}\n") - } - } - for _, i := range w.ice { - if a == i { - sb.WriteString("{226}You hear static crackling...{/}\n") - } - } - } - return sb.String() -} - -func (w *WumpusGame) doStatus() string { - return fmt.Sprintf("Node: %d | Probes: %d/5", w.player+1, w.probes) -} - -func (w *WumpusGame) doMap() string { - var sb strings.Builder - sb.WriteString("=== Network Map ===\n") - for i := 0; i < 20; i++ { - marker := " " - if i == w.player { - marker = "*" - } - adj := dodecahedron[i] - sb.WriteString(fmt.Sprintf(" [%s] Node %2d -> %2d, %2d, %2d\n", - marker, i+1, adj[0]+1, adj[1]+1, adj[2]+1)) - } - sb.WriteString(fmt.Sprintf("\nYou are at Node %d.\n", w.player+1)) - return sb.String() -} - -func (w *WumpusGame) hasAdjacent(from, to int) bool { - for _, a := range dodecahedron[from] { - if a == to { - return true - } - } - return false -} - -func (w *WumpusGame) randomAdjacent(r int) int { - adj := dodecahedron[r] - return adj[rand.Intn(len(adj))] -} - -func (w *WumpusGame) adjStrings(r int) []string { - var out []string - for _, a := range dodecahedron[r] { - out = append(out, fmt.Sprint(a+1)) - } - return out -} - -func randFree(taken map[int]bool) int { - for { - r := rand.Intn(20) - if !taken[r] { - return r - } - } -} diff --git a/internal/game/help.go b/internal/game/help.go index f9d822c..03e2b8c 100644 --- a/internal/game/help.go +++ b/internal/game/help.go @@ -7,7 +7,6 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" "gopkg.in/yaml.v3" ) @@ -26,36 +25,54 @@ type cmdEntry struct { var commandList = []cmdEntry{ {"alias", "Instant", "Create command shortcuts"}, {"attack / kill", "Active", "Attack a mob"}, + {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, + {"bank", "Active", "Open bank interface"}, {"burn", "Active", "Start a fire"}, {"chop / cut", "Active", "Chop trees (Woodcutting)"}, + {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, {"color / colors", "Instant", "Customize display colors"}, {"colortable", "Instant", "Display color reference chart"}, + {"construct / make", "Active", "Build furniture (Construction)"}, {"cook", "Active", "Cook raw food on a fire or range"}, + {"craft", "Active", "Craft jewelry (Crafting)"}, {"description / desc", "Instant", "Set your character description"}, + {"drink", "Free", "Drink a potion"}, {"drop", "Active", "Drop items to the ground"}, {"eat", "Free", "Eat food to restore hitpoints"}, {"equipment / eq", "Instant", "Show equipped items"}, {"exits", "Instant", "List available exits"}, + {"farm", "Active", "Farm crops (Farming)"}, {"fish", "Active", "Fish at fishing spots (Fishing)"}, + {"fletch", "Free", "Fletch logs into bows (Fletching)"}, {"get / take / pick", "Active", "Pick up items from the ground"}, {"help", "Instant", "Show help topics"}, + {"id / identify", "Active", "Identify herbs (Pharmacy)"}, {"inventory / i / inv", "Instant", "Show your inventory"}, + {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, {"map", "Instant", "Display an ASCII map of the area"}, {"mine", "Active", "Mine rocks (Mining)"}, + {"mix", "Active", "Mix potions (Pharmacy)"}, + {"mods / modlist", "Instant", "List available science modules"}, {"north / south / east / west / up / down", "Active", "Move in a direction"}, {"option / options", "Instant", "View or change settings"}, {"prompt", "Instant", "Set custom command prompt"}, - {"pull / push", "Active", "Toggle levers and switches"}, + {"pull / push", "Active", "Interact with objects"}, {"queued", "Instant", "Show pending tick actions"}, {"quit", "Active", "Rest and disconnect"}, {"remove / unwear / unwield", "Free", "Unequip items"}, {"say", "Instant", "Chat with players in your room"}, {"score / sc", "Instant", "View your stats and skills"}, {"search", "Active", "Search items for loot"}, + {"smelt", "Active", "Smelt ore into bars (Smithing)"}, + {"smith", "Active", "Smith bars into items (Smithing)"}, + {"sneak", "Instant", "Toggle sneak mode"}, + {"steal", "Active", "Steal from mobs (Thieving)"}, {"stoke", "Active", "Add logs to a fire"}, {"style", "Instant", "Change combat style"}, {"talk / speak / ask", "Active", "Talk to NPCs"}, + {"tech", "Instant", "Toggle technology abilities"}, + {"trigger", "Active", "Trigger a science module"}, {"unalias", "Instant", "Remove command shortcuts"}, {"use", "Active", "Use an object (crafting)"}, {"walk", "Active", "Pathfind to a room or multi-step walk"}, @@ -90,7 +107,7 @@ func LoadHelp(dataDir string) ([]HelpDef, error) { func (g *Game) doHelp(sess *net.Session, topic string) { if topic == "" { unicode := true - if p, _ := sess.Player.(*player.Player); p != nil { + if p := sess.Player; p != nil { unicode = p.OptionBool("unicode") } @@ -114,7 +131,7 @@ func (g *Game) doHelp(sess *net.Session, topic string) { return } - helps, err := LoadHelp(g.dataDir) + helps, err := LoadHelp(g.DataDir) if err != nil || len(helps) == 0 { sess.WriteLine("No help available.") return diff --git a/internal/game/login_account.go b/internal/game/login_account.go index 176a1ee..87f8c62 100644 --- a/internal/game/login_account.go +++ b/internal/game/login_account.go @@ -17,7 +17,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { } actualName, err := g.AccountStore.FindAccount(name) if err == nil { - sess.Account = &net.AccountEntry{Name: actualName} + sess.Account = &player.Account{Name: actualName} sess.State = net.StatePassword sess.Conn.SetEcho(false) sess.Write("Password: ") @@ -27,7 +27,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { sess.Write("Account name: ") return } - sess.Account = &net.AccountEntry{Name: name} + sess.Account = &player.Account{Name: name} sess.State = net.StateNewAccountPass sess.Conn.SetEcho(false) sess.WriteLine("\r\nOh, a new visitor to this rock!") @@ -63,7 +63,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) { return } sess.Conn.SetEcho(true) - sess.Account = &net.AccountEntry{ + sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, Characters: acc.Characters, @@ -144,7 +144,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { } sess.Conn.SetEcho(true) - sess.Account = &net.AccountEntry{ + sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, Aliases: make(map[string]string), diff --git a/internal/game/messages.go b/internal/game/messages.go new file mode 100644 index 0000000..1c9e85a --- /dev/null +++ b/internal/game/messages.go @@ -0,0 +1,13 @@ +package game + +const ( + MsgErrLoadRecipes = "Error loading recipes." + MsgNoCombat = "You can't do that during combat!" + MsgNoDontHave = "You don't have any '%s'." + MsgNeverMind = "Never mind." + MsgSomethingWrongObject = "Something is wrong with this object." + MsgSomethingWrongThat = "Something is wrong with that." + MsgInventoryFull = "Your inventory is too full!" + MsgAmbiguous = "That's ambiguous, which one?" + MsgWhichOne = "Which one?" +) diff --git a/internal/game/player_flags.go b/internal/game/player_flags.go new file mode 100644 index 0000000..6595cc4 --- /dev/null +++ b/internal/game/player_flags.go @@ -0,0 +1,55 @@ +package game + +import "thehouseoficarus/internal/player" + +func getPlayerFlagInt(p *player.Player, key string) int { + if p.Flags == nil { + return 0 + } + val, ok := p.Flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func getPlayerFlagString(p *player.Player, key string) string { + if p.Flags == nil { + return "" + } + val, ok := p.Flags[key] + if !ok { + return "" + } + s, _ := val.(string) + return s +} + +func setPlayerFlag(p *player.Player, key string, val any) { + p.EnsureFlags() + p.Flags[key] = val +} + +func intFromFlag(flags map[string]any, key string) int { + val, ok := flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} diff --git a/internal/game/production_core.go b/internal/game/production_core.go new file mode 100644 index 0000000..42f64e1 --- /dev/null +++ b/internal/game/production_core.go @@ -0,0 +1,606 @@ +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" + +) + +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} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, 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 || action.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 + allRecipes, _ := g.RecipeStore.LoadAll() + g.showSmithTable(sess, p, barID, allRecipes) + return + } + if rid, ok := entry["fletch_recipe_id"]; ok { + p := sess.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(recipeDefIDs(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 + 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 + } + + p.EnsureFlags() + 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 +} diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go new file mode 100644 index 0000000..329110b --- /dev/null +++ b/internal/game/production_menu.go @@ -0,0 +1,209 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" +) + +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"}, + "construction": {"construct", "constructing"}, +} + +var productionActionTypes map[string]bool + +func init() { + productionActionTypes = make(map[string]bool) + for _, pt := range productionTypes { + productionActionTypes[pt.ActionType] = true + } +} + +func recipeMenuData(recipeIDs []string) []map[string]string { + out := make([]map[string]string, len(recipeIDs)) + for i, id := range recipeIDs { + out[i] = map[string]string{"recipe_id": id} + } + return out +} + +func recipeDefIDs(defs []action.RecipeDef) []string { + ids := make([]string, len(defs)) + for i, d := range defs { + ids[i] = d.ID + } + return ids +} + +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 action.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 + 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 + 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), + } +} diff --git a/internal/game/prompt.go b/internal/game/prompt.go index c3768fe..2ecf367 100644 --- a/internal/game/prompt.go +++ b/internal/game/prompt.go @@ -12,7 +12,7 @@ import ( func (g *Game) promptStr(sess *net.Session) string { prompt := "> " - if p, ok := sess.Player.(*player.Player); ok && p != nil { + if p := sess.Player; p != nil { if p.Prompt != "" { prompt = p.Prompt } @@ -28,8 +28,8 @@ func (g *Game) expandPromptColors(sess *net.Session, text string) string { } func (g *Game) expandPromptVars(sess *net.Session, text string) string { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { + p := sess.Player + if p == nil { return text } diff --git a/internal/game/science.go b/internal/game/science.go index bf9a961..8c7f0af 100644 --- a/internal/game/science.go +++ b/internal/game/science.go @@ -2,9 +2,13 @@ package game import ( "fmt" + "os" + "path/filepath" "sort" "strings" + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -20,22 +24,51 @@ const ( ) type ModDef struct { - ID string - Name string - Level int - MaxHit int - BaseXP float64 - JunkCost map[string]int - Category ModCategory - Element string - TargetType string - Destination int + ID string `yaml:"id"` + Name string `yaml:"name"` + Level int `yaml:"level"` + MaxHit int `yaml:"max_hit"` + BaseXP int `yaml:"base_xp"` + JunkCost map[string]int `yaml:"junk_cost"` + Category ModCategory `yaml:"category"` + Element string `yaml:"element"` + Destination int `yaml:"destination"` } var AllMods []*ModDef var modByID map[string]*ModDef +func (g *Game) LoadMods() error { + dir := filepath.Join(g.DataDir, "modules") + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + AllMods = nil + modByID = make(map[string]*ModDef) + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + var m ModDef + if err := yaml.Unmarshal(data, &m); err != nil { + continue + } + AllMods = append(AllMods, &m) + modByID[m.ID] = AllMods[len(AllMods)-1] + } + modByID = make(map[string]*ModDef, len(AllMods)) + for _, m := range AllMods { + modByID[m.ID] = m + } + return nil +} + func GetMod(id string) *ModDef { return modByID[id] } @@ -162,6 +195,7 @@ func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool { func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { cost := g.effectiveJunkCost(p, mod) + delete(cost, "scrap_metal") if len(cost) == 0 { return "free" } @@ -172,6 +206,7 @@ func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { if def != nil { name = def.Name } + name = strings.TrimSuffix(name, "junk") if qty > 1 { parts = append(parts, fmt.Sprintf("%d %s", qty, name)) } else { @@ -193,143 +228,4 @@ func (g *Game) totalEquipScienceAttack(p *player.Player) int { return total } -func init() { - AllMods = []*ModDef{ - {ID: "bio_strike", Name: "Bio Strike", Level: 1, MaxHit: 4, BaseXP: 5.5, - JunkCost: map[string]int{"biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_bolt", Name: "Bio Bolt", Level: 17, MaxHit: 9, BaseXP: 13.5, - JunkCost: map[string]int{"biojunk": 2, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_blast", Name: "Bio Blast", Level: 41, MaxHit: 13, BaseXP: 25.5, - JunkCost: map[string]int{"biojunk": 3, "chaosjunk": 1, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_wave", Name: "Bio Wave", Level: 62, MaxHit: 17, BaseXP: 36.0, - JunkCost: map[string]int{"biojunk": 5, "deathjunk": 1, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_surge", Name: "Bio Surge", Level: 81, MaxHit: 21, BaseXP: 44.0, - JunkCost: map[string]int{"biojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "hydro_strike", Name: "Hydro Strike", Level: 5, MaxHit: 6, BaseXP: 7.5, - JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_bolt", Name: "Hydro Bolt", Level: 23, MaxHit: 10, BaseXP: 16.5, - JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_blast", Name: "Hydro Blast", Level: 47, MaxHit: 14, BaseXP: 28.5, - JunkCost: map[string]int{"hydrojunk": 5, "ecojunk": 3, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_wave", Name: "Hydro Wave", Level: 65, MaxHit: 18, BaseXP: 37.5, - JunkCost: map[string]int{"hydrojunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_surge", Name: "Hydro Surge", Level: 85, MaxHit: 22, BaseXP: 46.0, - JunkCost: map[string]int{"hydrojunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - - {ID: "eco_strike", Name: "Eco Strike", Level: 9, MaxHit: 7, BaseXP: 9.5, - JunkCost: map[string]int{"ecojunk": 2, "biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_bolt", Name: "Eco Bolt", Level: 29, MaxHit: 11, BaseXP: 19.5, - JunkCost: map[string]int{"ecojunk": 3, "biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_blast", Name: "Eco Blast", Level: 53, MaxHit: 15, BaseXP: 31.5, - JunkCost: map[string]int{"ecojunk": 4, "biojunk": 3, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_wave", Name: "Eco Wave", Level: 70, MaxHit: 19, BaseXP: 40.0, - JunkCost: map[string]int{"ecojunk": 7, "biojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_surge", Name: "Eco Surge", Level: 90, MaxHit: 23, BaseXP: 48.5, - JunkCost: map[string]int{"ecojunk": 10, "biojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - - {ID: "solar_strike", Name: "Solar Strike", Level: 13, MaxHit: 8, BaseXP: 11.5, - JunkCost: map[string]int{"solarjunk": 3, "ecojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_bolt", Name: "Solar Bolt", Level: 35, MaxHit: 12, BaseXP: 22.5, - JunkCost: map[string]int{"solarjunk": 4, "ecojunk": 3, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_blast", Name: "Solar Blast", Level: 59, MaxHit: 16, BaseXP: 34.5, - JunkCost: map[string]int{"solarjunk": 5, "ecojunk": 4, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_wave", Name: "Solar Wave", Level: 75, MaxHit: 20, BaseXP: 42.5, - JunkCost: map[string]int{"solarjunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_surge", Name: "Solar Surge", Level: 95, MaxHit: 24, BaseXP: 51.0, - JunkCost: map[string]int{"solarjunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - - {ID: "low_process", Name: "Low Level Processing", Level: 21, MaxHit: 0, BaseXP: 31.0, - JunkCost: map[string]int{"naturejunk": 3, "solarjunk": 1, "scrap_metal": 1}, - Category: ModProcessing, Element: "", TargetType: "inventory"}, - {ID: "high_process", Name: "High Level Processing", Level: 55, MaxHit: 0, BaseXP: 65.0, - JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1}, - Category: ModProcessing, Element: "", TargetType: "inventory"}, - - {ID: "bones_to_nutrients", Name: "Bones to Nutrients", Level: 15, MaxHit: 0, BaseXP: 25.0, - JunkCost: map[string]int{"naturejunk": 2, "ecojunk": 2, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "self"}, - {ID: "em_grab", Name: "Electromagnetic Grab", Level: 33, MaxHit: 0, BaseXP: 43.0, - JunkCost: map[string]int{"lawjunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "ground_item"}, - {ID: "superheat", Name: "Superheat Item", Level: 43, MaxHit: 0, BaseXP: 53.0, - JunkCost: map[string]int{"naturejunk": 4, "solarjunk": 1, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "inventory"}, - {ID: "plank_make", Name: "Plank Make", Level: 86, MaxHit: 0, BaseXP: 90.0, - JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "inventory"}, - - {ID: "transport_town", Name: "Transport: Town Square", Level: 25, MaxHit: 0, BaseXP: 27.0, - JunkCost: map[string]int{"lawjunk": 1, "solarjunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 1}, - {ID: "transport_forge", Name: "Transport: Forge", Level: 31, MaxHit: 0, BaseXP: 35.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 12}, - {ID: "transport_mine", Name: "Transport: Mining Pit", Level: 37, MaxHit: 0, BaseXP: 40.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "solarjunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 6}, - {ID: "transport_forest", Name: "Transport: Forest", Level: 45, MaxHit: 0, BaseXP: 48.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 22}, - {ID: "transport_scavenge", Name: "Transport: Scavenging Post", Level: 51, MaxHit: 0, BaseXP: 52.0, - JunkCost: map[string]int{"lawjunk": 1, "naturejunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 9}, - {ID: "transport_deep_mine", Name: "Transport: Deep Mine", Level: 61, MaxHit: 0, BaseXP: 60.0, - JunkCost: map[string]int{"lawjunk": 2, "ecojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 7}, - {ID: "transport_fishing", Name: "Transport: Fishing Dock", Level: 55, MaxHit: 0, BaseXP: 56.0, - JunkCost: map[string]int{"lawjunk": 1, "hydrojunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 10}, - - {ID: "enchant_1", Name: "Enchant Level 1", Level: 7, MaxHit: 0, BaseXP: 17.5, - JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_2", Name: "Enchant Level 2", Level: 27, MaxHit: 0, BaseXP: 37.0, - JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_3", Name: "Enchant Level 3", Level: 49, MaxHit: 0, BaseXP: 59.0, - JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_4", Name: "Enchant Level 4", Level: 57, MaxHit: 0, BaseXP: 67.0, - JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - - {ID: "chip_sapphire", Name: "Chip Sapphire Bolts", Level: 4, MaxHit: 0, BaseXP: 9.0, - JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_emerald", Name: "Chip Emerald Bolts", Level: 27, MaxHit: 0, BaseXP: 37.0, - JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_ruby", Name: "Chip Ruby Bolts", Level: 49, MaxHit: 0, BaseXP: 59.0, - JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_diamond", Name: "Chip Diamond Bolts", Level: 57, MaxHit: 0, BaseXP: 67.0, - JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - } - - modByID = make(map[string]*ModDef, len(AllMods)) - for _, m := range AllMods { - modByID[m.ID] = m - } -} diff --git a/internal/game/tick.go b/internal/game/tick.go index 4ab7e13..ec7eda9 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -17,8 +17,8 @@ func (g *Game) DisconnectTick() { if !sess.Disconnecting { continue } - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { g.Hub.Remove(sess) continue } @@ -38,8 +38,8 @@ func (g *Game) RegenTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { continue } if p.HP <= 0 || p.HP >= p.MaxHP() { @@ -65,7 +65,7 @@ func (g *Game) RegenTick() { p.HP = p.MaxHP() p.RegenerateTick = 0 } else { - p.RegenerateTick = 100 + p.RegenerateTick = player.RegenTickInterval } } } @@ -118,9 +118,9 @@ func (g *Game) WanderTick() { objMoves := g.World.FlushObjMoves() for _, om := range objMoves { for _, sess := range g.Hub.PlayersInRoom(om.FromRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.Action != nil { + if p := sess.Player; p != nil && p.Action != nil { if p.Action.TargetID == om.DefID { - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) } } @@ -135,8 +135,8 @@ func (g *Game) WanderTick() { for _, m := range moves { for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { continue } if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") { @@ -162,8 +162,8 @@ func (g *Game) WanderTick() { func (g *Game) SharedDepletionTick() { activeKeys := make(map[string]bool) for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Action == nil || p.Action.Type != "gather" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "gather" { continue } if key, ok := p.Action.Data["instance_key"].(string); ok { @@ -218,8 +218,8 @@ func (g *Game) TechTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.ActiveTechs) == 0 { + p := sess.Player + if p == nil || len(p.ActiveTechs) == 0 { continue } @@ -281,8 +281,8 @@ func (g *Game) ConsumeTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { + p := sess.Player + if p == nil { continue } if p.ConsumeCooldown > 0 { @@ -296,8 +296,8 @@ func (g *Game) VisualTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || !p.OptionBool("visual_ticks") { + p := sess.Player + if p == nil || !p.OptionBool("visual_ticks") { continue } text := p.OptionString("visual_tick_text") @@ -318,3 +318,28 @@ func (g *Game) VisualTick() { } } } + +func (g *Game) MoveTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil || p.MoveTicks <= 0 { + continue + } + p.MoveTicks-- + if p.MoveTicks > 0 { + if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") { + if p.MoveTicks > 1 { + sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) + } else { + sess.WriteLine("Running next tick!") + } + } + } else { + g.completeMove(sess, p) + g.writePrompt(sess) + } + } +} diff --git a/internal/game/utils.go b/internal/game/utils.go index 6a294c5..9148ea3 100644 --- a/internal/game/utils.go +++ b/internal/game/utils.go @@ -112,3 +112,10 @@ func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { } return slot } + +func (g *Game) awardSkillXP(sess *net.Session, p *player.Player, skill player.SkillName, xp int) { + newLevel := p.AddSkillXP(skill, xp) + if newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } +} -- cgit v1.2.3