From 1b9e2da3b3c438d8dc53d3489725dd5ba0022777 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 11 Jun 2026 04:46:27 -0400 Subject: feat: web client, get/drop updates and fixes --- internal/action/action.go | 2 + internal/action/behavior.go | 21 +- internal/config/config.go | 59 ++++ internal/game/action.go | 593 ++--------------------------------------- internal/game/action_gather.go | 355 ++++++++++++++++++++++++ internal/game/action_room.go | 47 ++++ internal/game/action_talk.go | 211 +++++++++++++++ internal/game/action_toggle.go | 46 ++++ internal/game/action_use.go | 120 +++++++++ internal/game/cmd_drop.go | 163 +++++++++-- internal/game/cmd_equipment.go | 28 ++ internal/game/cmd_get.go | 222 +++++++++++++-- internal/game/cmd_inventory.go | 36 +++ internal/game/cmd_look.go | 119 +++++++-- internal/game/cmd_misc.go | 125 --------- internal/game/cmd_move.go | 6 + internal/game/cmd_quit.go | 1 + internal/game/cmd_remove.go | 67 +++++ internal/game/cmd_say.go | 21 ++ internal/game/cmd_score.go | 27 ++ internal/game/cmd_search.go | 87 ++++++ internal/game/cmd_style.go | 45 ++++ internal/game/cmd_ticktest.go | 21 -- internal/game/cmd_toggle.go | 3 +- internal/game/cmd_wear.go | 146 ++++++++++ internal/game/game.go | 31 ++- internal/game/login.go | 550 ++++++++++++++++++++++++++++++++++++++ internal/game/session.go | 505 ----------------------------------- internal/game/tick.go | 92 +++++-- internal/game/utils.go | 12 + internal/net/conn.go | 101 +++++++ internal/net/server.go | 153 ++++++++--- internal/net/terminal.html | 164 ++++++++++++ internal/net/web.go | 72 +++++ internal/net/wsconn.go | 34 +++ internal/object/item.go | 39 ++- internal/player/player.go | 14 +- internal/player/validate.go | 37 +++ internal/world/mob.go | 46 ++-- internal/world/room.go | 21 +- internal/world/world.go | 107 ++++++-- 41 files changed, 3126 insertions(+), 1423 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/game/action_gather.go create mode 100644 internal/game/action_room.go create mode 100644 internal/game/action_talk.go create mode 100644 internal/game/action_toggle.go create mode 100644 internal/game/action_use.go create mode 100644 internal/game/cmd_equipment.go create mode 100644 internal/game/cmd_inventory.go delete mode 100644 internal/game/cmd_misc.go create mode 100644 internal/game/cmd_remove.go create mode 100644 internal/game/cmd_say.go create mode 100644 internal/game/cmd_score.go create mode 100644 internal/game/cmd_search.go create mode 100644 internal/game/cmd_style.go delete mode 100644 internal/game/cmd_ticktest.go create mode 100644 internal/game/cmd_wear.go create mode 100644 internal/game/login.go delete mode 100644 internal/game/session.go create mode 100644 internal/net/conn.go create mode 100644 internal/net/terminal.html create mode 100644 internal/net/web.go create mode 100644 internal/net/wsconn.go create mode 100644 internal/player/validate.go (limited to 'internal') diff --git a/internal/action/action.go b/internal/action/action.go index 9cd4c0f..44f27f8 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -24,6 +24,8 @@ type DropEntry struct { Quantity int `yaml:"quantity"` Depletes bool `yaml:"depletes"` Message string `yaml:"message"` + Level int `yaml:"level"` + XP int `yaml:"xp"` } type DropTableDef struct { diff --git a/internal/action/behavior.go b/internal/action/behavior.go index d8eb909..a3c8802 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -3,8 +3,10 @@ package action type GatherConfig struct { Skill string `yaml:"skill"` Level int `yaml:"level"` + XP int `yaml:"xp"` BaseWait int `yaml:"base_wait"` - Tool string `yaml:"tool"` + Tools []string `yaml:"tools"` + Bait string `yaml:"bait"` Success SuccessFormula `yaml:"success"` GatherMsg string `yaml:"gather_message"` FailMsg string `yaml:"fail_message"` @@ -12,6 +14,8 @@ type GatherConfig struct { DepleteDelay int `yaml:"deplete_delay"` RespawnMsg string `yaml:"respawn_message"` RespawnBroadcast string `yaml:"respawn_broadcast"` + SharedDeplete int `yaml:"shared_deplete"` + NestChance int `yaml:"nest_chance"` } type SuccessFormula struct { @@ -57,14 +61,15 @@ type Condition struct { } type UseConfig struct { - Message string `yaml:"message"` - Wait int `yaml:"wait"` - Consume map[string]int `yaml:"consume"` - Reward DropEntry `yaml:"reward"` - FailMsg string `yaml:"fail_message"` + Message string `yaml:"message"` + Wait int `yaml:"wait"` + Consume map[string]int `yaml:"consume"` + Reward DropEntry `yaml:"reward"` + FailMsg string `yaml:"fail_message"` Success *SuccessFormula `yaml:"success"` - Skill string `yaml:"skill"` - Level int `yaml:"level"` + Skill string `yaml:"skill"` + Level int `yaml:"level"` + XP int `yaml:"xp"` } type ToggleConfig struct { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8ff5740 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Telnet TelnetConfig `yaml:"telnet"` + HTTP HTTPConfig `yaml:"http"` + HTTPS HTTPSConfig `yaml:"https"` +} + +type TelnetConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` +} + +type HTTPConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` +} + +type HTTPSConfig struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +func Default() *Config { + return &Config{ + Telnet: TelnetConfig{ + Enabled: true, + Port: 4000, + }, + HTTP: HTTPConfig{ + Enabled: false, + Port: 8080, + }, + HTTPS: HTTPSConfig{ + Enabled: false, + Port: 8443, + }, + } +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := Default() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/internal/game/action.go b/internal/game/action.go index 62cdf62..018c4fb 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "math/rand" "sort" "strconv" "strings" @@ -48,6 +47,15 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { var behaviorID string if len(instances) > 0 { + uniqueDefs := make(map[string]bool) + for _, ist := range instances { + uniqueDefs[ist.DefID] = true + } + if len(uniqueDefs) > 1 { + sess.WriteLine("That's ambiguous, which one?") + return + } + var chosen *world.ObjState if instanceIdx >= 0 && instanceIdx < len(instances) { chosen = &instances[instanceIdx] @@ -127,7 +135,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) return } - // Find the actual ObjState for the chosen object chosenObj := g.World.FindObjInstances(p.RoomID, obj.ID) sort.Slice(chosenObj, func(i, j int) bool { return chosenObj[i].Index < chosenObj[j].Index @@ -187,389 +194,16 @@ func (g *Game) AdvanceActions() { } } -func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { - cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID) - if err != nil { - sess.WriteLine("Something is wrong with this object.") - return - } - - skillLevel := p.Level(player.SkillName(cfg.Skill)) - if cfg.Level > 0 && skillLevel < cfg.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) - return - } - - if st.Depleted { - sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) - return - } - - wait := cfg.BaseWait - - if cfg.Tool != "" { - bestSpeed := -1 - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { - bestSpeed = def.ToolSpeed - } - } - for _, itemID := range p.Toolbelt { - def, err := g.ItemStore.Load(itemID) - if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { - bestSpeed = def.ToolSpeed - } - } - if bestSpeed < 0 { - sess.WriteLine(fmt.Sprintf("You need a %s to mine the %s.", cfg.Tool, obj.Name)) - return - } - wait = cfg.BaseWait - bestSpeed - if wait < 1 { - wait = 1 - } - } - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - return - } - - p.Action = &action.Action{ - Type: "gather", - TargetID: st.DefID, - TargetName: obj.Name, - WaitLeft: 0, - Data: map[string]any{ - "behavior_id": obj.BehaviorID, - "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), - "instance_idx": st.Index + 1, - "effective_wait": wait, - "step": 0, - }, - } -} - -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) - return - } - - step := p.Action.Data["step"].(int) - wait := p.Action.Data["effective_wait"].(int) - instanceKey := p.Action.Data["instance_key"].(string) - - if step == 0 { - sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) - p.Action.Data["step"] = 1 - p.Action.WaitLeft = wait - return - } - - if step == 2 { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.Depleted { - p.Action.WaitLeft = 3 - return - } - if cfg.RespawnMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) - } - p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait - return - } - - skillLevel := p.Level(player.SkillName(cfg.Skill)) - chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level) - - if rand.Float64() < chance { - drop := g.BehaviorStore.ResolveDrop(cfg.Drops) - if drop != nil { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) - return - } - - qty := drop.Quantity - if qty <= 0 { - qty = 1 - } - - itemName := drop.ItemID - if def, err := g.ItemStore.Load(drop.ItemID); err == nil { - itemName = def.Name - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) - - msg := drop.Message - if msg == "" { - msg = fmt.Sprintf("You manage to get some %s.", itemName) - } - sess.WriteLine(msg) - - g.AccountStore.SaveCharacter(p) - - if drop.Depletes { - delay := cfg.DepleteDelay - if delay <= 0 { - delay = 10 - } - st := g.World.GetObjStateByKey(instanceKey) - if st != nil { - st.Depleted = true - st.DepleteTimer = 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!", p.Action.TargetName, p.Name)) - g.CancelAction(op) - } - } - - p.Action.Data["step"] = 2 - p.Action.WaitLeft = 1 - return - } - - // Non-depleting drop: loop back. Check inventory before looping. - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) - return - } - p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait - return - } - } - - // Failed: show fail message, loop back - sess.WriteLine(cfg.FailMsg) - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) - return - } - p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait -} - -func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) - if err != nil { - sess.WriteLine("This person has nothing to say.") - return - } - - if node, ok := cfg.Nodes["start"]; ok { - p.Action = &action.Action{ - Type: "talk", - TargetID: mob.DefID, - TargetName: mob.Name, - Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, - } - g.showTalkNode(sess, node) - } else { - sess.WriteLine("This person has nothing to say.") - } -} - -func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) - if err != nil { - sess.WriteLine("This person has nothing to say.") - return - } - - if node, ok := cfg.Nodes["start"]; ok { - p.Action = &action.Action{ - Type: "talk", - TargetID: obj.ID, - TargetName: obj.Name, - Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, - } - g.showTalkNode(sess, node) - } else { - sess.WriteLine("This person has nothing to say.") - } -} - -func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { - sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) - - if node.Action != nil { - g.applyNodeAction(sess, node.Action) - } - - if len(node.Options) == 0 { - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - visible := 0 - for _, opt := range node.Options { - if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { - continue - } - visible++ - sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text)) - } - sess.State = net.StateTalk - sess.Write("\nChoice: ") -} - -func (g *Game) handleTalkInput(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil || p.Action.Type != "talk" { - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - input = strings.TrimSpace(input) - lower := strings.ToLower(input) - if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadTalk(behaviorID) - if err != nil { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - nodeKey := p.Action.Data["node"].(string) - node, ok := cfg.Nodes[nodeKey] - if !ok { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - idx, err := parseIndex(input) - if err != nil || idx <= 0 { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - validIdx := 0 - var chosen *action.TalkOption - for i := range node.Options { - opt := &node.Options[i] - if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue - } - validIdx++ - if validIdx == idx { - chosen = opt - break - } - } - - if chosen == nil { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - if chosen.End { - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") - return - } - - if chosen.Goto != "" { - if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { - p.Action.Data["node"] = chosen.Goto - g.showTalkNode(sess, nextNode) - return - } - } - - g.CancelAction(p) - sess.State = net.StateGame - sess.Write("\r\n> ") -} - -func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { - p, ok := sess.Player.(*player.Player) - if !ok { - return - } - for k, v := range na.SetFlags { - g.WorldFlags[k] = v - } - for k, v := range na.SetPlayerFlags { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[k] = v - } - if na.TakeItem != "" { - if p.HasItem(na.TakeItem) { - p.RemoveItem(na.TakeItem, 1) - } - } - if na.GiveItem != "" { - slot := p.FirstFreeSlot() - if slot == -1 { - sess.WriteLine("Your inventory is too full to receive that.") - } else { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) - g.AccountStore.SaveCharacter(p) - } - } - if na.Teleport > 0 { - p.RoomID = na.Teleport - g.AccountStore.SaveCharacter(p) - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.ensureRoomObjects(p.RoomID) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - g.RunEnterSteps(sess, p.RoomID) - } - if na.Heal > 0 { - p.HP += na.Heal - if maxHP := p.MaxHP(); p.HP > maxHP { - p.HP = maxHP - } - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal)) +func normalizeVerb(v string) string { + switch v { + case "mine", "chop", "fish", "cut": + return "gather" + case "talk", "speak", "ask": + return "talk" + case "pull", "push": + return "toggle" } + return v } func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { @@ -619,194 +253,3 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { } return true } - -func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) - if err != nil { - sess.WriteLine("You can't use this.") - return - } - - for itemID, qty := range cfg.Consume { - if !p.HasItem(itemID) { - defName := itemID - if def, err := g.ItemStore.Load(itemID); err == nil { - defName = def.Name - } - sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) - return - } - _ = qty - } - - if cfg.Success == nil && p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is full.") - return - } - - p.Action = &action.Action{ - Type: "use", - TargetID: obj.ID, - TargetName: obj.Name, - WaitLeft: 1, - Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, - } - - 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) - return - } - - for itemID, qty := range cfg.Consume { - if !p.HasItem(itemID) { - defName := itemID - if def, err := g.ItemStore.Load(itemID); err == nil { - defName = def.Name - } - sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) - g.CancelAction(p) - return - } - if !p.RemoveItem(itemID, qty) { - sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) - g.CancelAction(p) - return - } - } - - if cfg.Success != nil { - skillLevel := p.Level(player.SkillName(cfg.Skill)) - chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level) - if rand.Float64() >= chance { - if cfg.FailMsg != "" { - sess.WriteLine(cfg.FailMsg) - } - p.Action.WaitLeft = cfg.Wait - return - } - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - g.CancelAction(p) - return - } - - qty := cfg.Reward.Quantity - if qty <= 0 { - qty = 1 - } - - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) - - g.AccountStore.SaveCharacter(p) - - itemName := cfg.Reward.ItemID - if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil { - itemName = def.Name - } - sess.WriteLine(fmt.Sprintf("You make a %s.", itemName)) - - if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is full.") - g.CancelAction(p) - return - } - - p.Action.WaitLeft = cfg.Wait -} - -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 - } - - 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(fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name)) - } - } - } -} - -func normalizeVerb(v string) string { - switch v { - case "mine", "chop", "fish": - return "gather" - case "talk", "speak", "ask": - return "talk" - case "pull", "push": - return "toggle" - } - return v -} - -func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { - room, err := g.World.LoadRoom(roomID) - if err != nil || len(room.OnEnter) == 0 { - return - } - for _, step := range room.OnEnter { - if step.Condition != nil && !g.checkCondition(sess, step.Condition) { - continue - } - if step.Message != "" { - sess.WriteLine(fmt.Sprintf("\n%s", step.Message)) - } - } -} - -func (g *Game) BroadcastRespawns() { - respawns := g.World.FlushObjRespawns() - for _, st := range respawns { - def, err := g.ObjectStore.Load(st.DefID) - if err != nil || def.BehaviorID == "" { - continue - } - cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err != nil || cfg.RespawnBroadcast == "" { - continue - } - name := def.Name - if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { - name += fmt.Sprintf(" [%d]", idx) - } - msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) - if g.Hub != nil { - for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { - sess.WriteLine(fmt.Sprintf("\n%s", msg)) - } - } - } -} diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go new file mode 100644 index 0000000..e1e724e --- /dev/null +++ b/internal/game/action_gather.go @@ -0,0 +1,355 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/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) + if err != nil { + sess.WriteLine("Something is wrong with this object.") + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + if cfg.Level > 0 && skillLevel < cfg.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) + return + } + + if st.Depleted { + if cfg.SharedDeplete > 0 { + sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer)) + } else { + sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) + } + return + } + + wait := cfg.BaseWait + + if len(cfg.Tools) > 0 { + toolSpeed := -1 + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil { + for _, t := range cfg.Tools { + if def.ToolType == t { + toolSpeed = def.ToolSpeed + break + } + } + } + } + if toolSpeed < 0 { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + for _, t := range cfg.Tools { + if def.ToolType == t { + toolSpeed = def.ToolSpeed + break + } + } + if toolSpeed >= 0 { + break + } + } + } + if toolSpeed < 0 { + names := make([]string, len(cfg.Tools)) + for i, t := range cfg.Tools { + names[i] = strings.ReplaceAll(t, "_", " ") + } + toolList := strings.Join(names, " or ") + sess.WriteLine(fmt.Sprintf("You need a %s to do that.", toolList)) + return + } + wait = cfg.BaseWait - toolSpeed + if wait < 1 { + wait = 1 + } + } + + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %s to fish here.", baitName)) + return + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + + data := map[string]any{ + "behavior_id": obj.BehaviorID, + "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + "instance_idx": st.Index + 1, + "effective_wait": wait, + "step": 0, + } + if cfg.SharedDeplete > 0 { + data["shared_deplete"] = true + } + p.Action = &action.Action{ + Type: "gather", + TargetID: st.DefID, + TargetName: obj.Name, + WaitLeft: 0, + Data: data, + } +} + +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) + return + } + + step := p.Action.Data["step"].(int) + wait := p.Action.Data["effective_wait"].(int) + instanceKey := p.Action.Data["instance_key"].(string) + _, shared := p.Action.Data["shared_deplete"] + + if step == 0 { + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName)) + g.CancelAction(p) + return + } + } + sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) + p.Action.Data["step"] = 1 + p.Action.WaitLeft = wait + return + } + + if step == 2 { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.Depleted { + p.Action.WaitLeft = 3 + return + } + if cfg.RespawnMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + + if cfg.Bait != "" { + if !p.HasItem(cfg.Bait) { + baitName := cfg.Bait + if def, err := g.ItemStore.Load(cfg.Bait); err == nil { + baitName = def.Name + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName)) + g.CancelAction(p) + return + } + p.RemoveItem(cfg.Bait, 1) + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level) + + if rand.Float64() < chance { + eligible := filterDropsByLevel(cfg.Drops, skillLevel) + drop := g.BehaviorStore.ResolveDrop(eligible) + if drop != nil { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + itemName := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + itemName = def.Name + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + xp := drop.XP + if xp <= 0 { + xp = cfg.XP + } + if xp > 0 { + p.AddXP(player.SkillName(cfg.Skill), xp) + } + g.AccountStore.SaveCharacter(p) + + msg := drop.Message + if msg == "" { + msg = fmt.Sprintf("You manage to get some %s.", itemName) + } + if xp > 0 && p.Toggles["xpdrops"] { + msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)]) + } + sess.WriteLine(msg) + + g.checkBirdNest(sess, p, cfg, freeSlot) + + if shared { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.SharedTimer <= 0 { + g.depleteSharedTree(sess, p, st, cfg.DepleteDelay, instanceKey) + return + } + } + + if !shared && drop.Depletes { + delay := cfg.DepleteDelay + if delay <= 0 { + delay = 10 + } + st := g.World.GetObjStateByKey(instanceKey) + if st != nil { + st.Depleted = true + st.DepleteTimer = 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!", p.Action.TargetName, p.Name)) + g.CancelAction(op) + } + } + + p.Action.Data["step"] = 2 + p.Action.WaitLeft = 1 + return + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + } + + sess.WriteLine(cfg.FailMsg) + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait +} + +func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry { + var eligible []action.DropEntry + for _, d := range drops { + if level >= d.Level { + eligible = append(eligible, d) + } + } + if len(eligible) == 0 { + return drops + } + return eligible +} + +func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, respawnTicks int, instanceKey string) { + st.Depleted = true + st.DepleteTimer = respawnTicks + st.SharedTimer = 0 + + sess.WriteLine(fmt.Sprintf("\nThe %s falls to the ground!", p.Action.TargetName)) + + 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 falls to the ground!", p.Action.TargetName)) + g.CancelAction(op) + } + } + + p.Action.Data["step"] = 2 + p.Action.WaitLeft = 1 +} + +func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.GatherConfig, currentDropSlot int) { + if cfg.NestChance <= 0 { + return + } + if rand.Intn(cfg.NestChance) != 0 { + return + } + + nestName := "bird's nest" + if def, err := g.ItemStore.Load("birds_nest"); err == nil { + nestName = def.Name + } + + freeSlot := -1 + for i := 0; i < 28; i++ { + if i == currentDropSlot { + continue + } + if p.InvSlot(i) == nil { + freeSlot = i + break + } + } + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, "birds_nest", 1) + sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName)) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: "birds_nest", Quantity: 1}) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) +} diff --git a/internal/game/action_room.go b/internal/game/action_room.go new file mode 100644 index 0000000..426b512 --- /dev/null +++ b/internal/game/action_room.go @@ -0,0 +1,47 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" +) + +func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnEnter) == 0 { + return + } + for _, step := range room.OnEnter { + if step.Condition != nil && !g.checkCondition(sess, step.Condition) { + continue + } + if step.Message != "" { + sess.WriteLine(fmt.Sprintf("\n%s", step.Message)) + } + } +} + +func (g *Game) BroadcastRespawns() { + respawns := g.World.FlushObjRespawns() + for _, st := range respawns { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || def.BehaviorID == "" { + continue + } + cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) + if err != nil || cfg.RespawnBroadcast == "" { + continue + } + name := def.Name + if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { + name += fmt.Sprintf(" [%d]", idx) + } + msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { + sess.WriteLine(fmt.Sprintf("\n%s", msg)) + } + } + } +} diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go new file mode 100644 index 0000000..f4929e9 --- /dev/null +++ b/internal/game/action_talk.go @@ -0,0 +1,211 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { + cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: mob.DefID, + TargetName: mob.Name, + Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: obj.ID, + TargetName: obj.Name, + Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { + sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) + + if node.Action != nil { + g.applyNodeAction(sess, node.Action) + } + + if len(node.Options) == 0 { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + visible := 0 + for _, opt := range node.Options { + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { + continue + } + visible++ + sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text)) + } + sess.State = net.StateTalk + sess.Write("\nChoice: ") +} + +func (g *Game) handleTalkInput(sess *net.Session, input string) { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil || p.Action.Type != "talk" { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + behaviorID := p.Action.Data["behavior_id"].(string) + cfg, err := g.BehaviorStore.LoadTalk(behaviorID) + if err != nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + nodeKey := p.Action.Data["node"].(string) + node, ok := cfg.Nodes[nodeKey] + if !ok { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + idx, err := parseIndex(input) + if err != nil || idx <= 0 { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + validIdx := 0 + var chosen *action.TalkOption + for i := range node.Options { + opt := &node.Options[i] + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { + continue + } + validIdx++ + if validIdx == idx { + chosen = opt + break + } + } + + if chosen == nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.End { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.Goto != "" { + if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { + p.Action.Data["node"] = chosen.Goto + g.showTalkNode(sess, nextNode) + return + } + } + + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") +} + +func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { + p, ok := sess.Player.(*player.Player) + if !ok { + return + } + for k, v := range na.SetFlags { + g.WorldFlags[k] = v + } + for k, v := range na.SetPlayerFlags { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags[k] = v + } + if na.TakeItem != "" { + if p.HasItem(na.TakeItem) { + p.RemoveItem(na.TakeItem, 1) + } + } + if na.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } + if na.Teleport > 0 { + p.RoomID = na.Teleport + g.AccountStore.SaveCharacter(p) + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.ensureRoomObjects(p.RoomID) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + g.RunEnterSteps(sess, p.RoomID) + } + if na.Heal > 0 { + p.HP += na.Heal + if maxHP := p.MaxHP(); p.HP > maxHP { + p.HP = maxHP + } + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal)) + } +} diff --git a/internal/game/action_toggle.go b/internal/game/action_toggle.go new file mode 100644 index 0000000..3818d0d --- /dev/null +++ b/internal/game/action_toggle.go @@ -0,0 +1,46 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/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 + } + + 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(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 new file mode 100644 index 0000000..c1a07ed --- /dev/null +++ b/internal/game/action_use.go @@ -0,0 +1,120 @@ +package game + +import ( + "fmt" + "math/rand" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" +) + +func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) + if err != nil { + sess.WriteLine("You can't use this.") + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) + return + } + _ = qty + } + + if cfg.Success == nil && p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Action = &action.Action{ + Type: "use", + TargetID: obj.ID, + TargetName: obj.Name, + WaitLeft: 1, + Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, + } + + 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) + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) + g.CancelAction(p) + return + } + if !p.RemoveItem(itemID, qty) { + sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) + g.CancelAction(p) + return + } + } + + if cfg.Success != nil { + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level) + if rand.Float64() >= chance { + if cfg.FailMsg != "" { + sess.WriteLine(cfg.FailMsg) + } + p.Action.WaitLeft = cfg.Wait + return + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + qty := cfg.Reward.Quantity + if qty <= 0 { + qty = 1 + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) + if cfg.XP > 0 { + p.AddXP(player.SkillName(cfg.Skill), cfg.XP) + } + g.AccountStore.SaveCharacter(p) + + itemName := cfg.Reward.ItemID + if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil { + itemName = def.Name + } + line := fmt.Sprintf("You make a %s.", itemName) + if cfg.XP > 0 && p.Toggles["xpdrops"] { + line += fmt.Sprintf(" (+%dxp %s)", cfg.XP, player.SkillAbbr[player.SkillName(cfg.Skill)]) + } + sess.WriteLine(line) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + p.Action.WaitLeft = cfg.Wait +} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 2f79a2f..cbe232d 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -8,24 +8,27 @@ import ( "thirdcollapse/internal/player" ) -func (g *Game) doDrop(sess *net.Session, input string) { +func (g *Game) doDropAll(sess *net.Session) { p := sess.Player.(*player.Player) + g.CancelAction(p) - if input == "all" { - count := 0 - for _, slot := range p.Inventory { - if slot != nil && slot.Quantity > 0 { - count++ - } + count := 0 + for _, slot := range p.Inventory { + if slot != nil && slot.Quantity > 0 { + count++ } - if count == 0 { - sess.WriteLine("You have nothing to drop.") - return - } - sess.State = net.StateDropAllConfirm - sess.WriteLine(fmt.Sprintf("\nDrop all %d items? [y/N]", count)) + } + if count == 0 { + sess.WriteLine("You have nothing to drop.") return } + sess.State = net.StateDropAllConfirm + sess.WriteLine(fmt.Sprintf("\nDrop all %d items? [y/N]", count)) +} + +func (g *Game) doDropAllNamed(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + g.CancelAction(p) matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -43,25 +46,141 @@ func (g *Game) doDrop(sess *net.Session, input string) { } itemID := matches[0].ID - slotIdx := matches[0].Slot - slot := p.InvSlot(slotIdx) def, _ := g.ItemStore.Load(itemID) - qty := 1 name := itemID if def != nil { name = def.Name } - if slot.Quantity > qty { - slot.Quantity -= qty + if def != nil && def.Stackable { + slot := p.InvSlot(matches[0].Slot) + qty := slot.Quantity + p.SetInvSlot(matches[0].Slot, nil) + g.World.AddGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + } else { + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name)) + } + return + } + + total := 0 + for _, match := range matches { + slot := p.InvSlot(match.Slot) + if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { + continue + } + total += slot.Quantity + p.SetInvSlot(match.Slot, nil) + g.World.AddGroundItem(p.RoomID, itemID, slot.Quantity) + } + g.AccountStore.SaveCharacter(p) + if total == 1 { + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) } else { - p.SetInvSlot(slotIdx, nil) + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, name)) + } +} + +func (g *Game) doDrop(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + g.CancelAction(p) + + qty, itemName := parseQty(input) + + matches := g.findInventoryMatches(itemName, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", itemName)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + + itemID := matches[0].ID + def, _ := g.ItemStore.Load(itemID) + + name := itemID + if def != nil { + name = def.Name + } + + if qty == 0 { + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 { + qty = slot.Quantity + break + } + } + } else { + qty = 1 + } } - g.World.AddGroundItem(p.RoomID, itemID, qty) + if qty <= 0 { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", name)) + return + } + + if def != nil && def.Stackable { + slot := p.InvSlot(matches[0].Slot) + if qty > slot.Quantity { + qty = slot.Quantity + } + if slot.Quantity > qty { + slot.Quantity -= qty + } else { + p.SetInvSlot(matches[0].Slot, nil) + } + g.World.AddGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + } else { + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name)) + } + return + } + + remaining := qty + for _, match := range matches { + if remaining <= 0 { + break + } + slot := p.InvSlot(match.Slot) + if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { + continue + } + take := 1 + if slot.Quantity > take { + slot.Quantity -= take + } else { + p.SetInvSlot(match.Slot, nil) + } + g.World.AddGroundItem(p.RoomID, itemID, take) + remaining -= take + } + + dropped := qty - remaining g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + if dropped == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", name)) + } else if dropped == 1 { + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + } else { + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", dropped, name)) + } } func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { @@ -71,6 +190,7 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { sess.Write("\r\n> ") return } + g.CancelAction(p) input = strings.TrimSpace(strings.ToLower(input)) if input != "y" && input != "yes" { @@ -103,7 +223,6 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { } else { sess.Write(fmt.Sprintf("\nYou drop your ")) innerPickupReport(sess, dropped) - sess.WriteLine(".") } sess.Write("\r\n> ") } diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go new file mode 100644 index 0000000..cda39b1 --- /dev/null +++ b/internal/game/cmd_equipment.go @@ -0,0 +1,28 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doEquipment(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Equipment:") + + for _, slot := range EquipSlots { + itemID, ok := p.Equipment[slot] + if !ok { + sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot)) + continue + } + def, err := g.ItemStore.Load(itemID) + name := itemID + if err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) + } +} diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index bce8ddc..70b458e 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -9,15 +9,18 @@ import ( func (g *Game) doGet(sess *net.Session, input string) { p := sess.Player.(*player.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.") return } - matches := g.findGroundMatches(input, p.RoomID) + qty, itemName := parseQty(input) + + matches := g.findGroundMatches(itemName, p.RoomID) if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", itemName)) return } @@ -41,18 +44,29 @@ func (g *Game) doGet(sess *net.Session, input string) { def, _ := g.ItemStore.Load(itemID) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") + available := 0 + if gqty, ok := ground[itemID]; ok { + available = gqty + } + if available <= 0 { + sess.WriteLine("There's none of that here.") return } - qty := 1 - if def != nil && def.Stackable { - ground := g.World.GroundItems(p.RoomID) - if gqty, ok := ground[itemID]; ok && gqty > 0 { - qty = gqty + if qty == 0 { + if def != nil && def.Stackable { + qty = available + } else { + qty = 1 } + } else if qty > available { + qty = available + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return } if def != nil && def.Stackable { @@ -67,29 +81,191 @@ func (g *Game) doGet(sess *net.Session, input string) { _ = removed slot.Quantity += qty g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", def.Name, slot.Quantity)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, def.Name, slot.Quantity)) return } } + freeSlot = p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + _ = removed + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + g.AccountStore.SaveCharacter(p) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name)) + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, def.Name)) + } + return } - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return + picked := 0 + for picked < qty { + fs := p.FirstFreeSlot() + if fs == -1 { + break + } + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name) + if !ok { + break + } + _ = removed + p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + picked++ } - _ = removed - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + g.AccountStore.SaveCharacter(p) + if picked == 0 { + sess.WriteLine("Your inventory is full.") + } else if picked == 1 { + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + } else { + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name)) + } +} + +func (g *Game) doGetAllNamed(sess *net.Session, input string) { + p := sess.Player.(*player.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.") + return + } + + matches := g.findGroundMatches(input, p.RoomID) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + return + } + + if len(matches) > 1 { + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + } + + itemID := matches[0].ID + + if itemID == "credits" { + g.pickupCredits(sess, p, itemID) + return + } + + def, _ := g.ItemStore.Load(itemID) + + available := 0 + if gqty, ok := ground[itemID]; ok { + available = gqty + } + if available <= 0 { + sess.WriteLine("There's none of that here.") + return + } + + wanted := available + if def != nil && !def.Stackable { + free := p.FreeSlots() + if wanted > free { + wanted = free + } + } + + if wanted <= 0 { + sess.WriteLine("Your inventory is full.") + return + } + name := itemID if def != nil { name = def.Name } - sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID { + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + slot.Quantity += removed + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, name, slot.Quantity)) + return + } + } + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, wanted, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed}) + g.AccountStore.SaveCharacter(p) + if removed == 1 { + sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, name)) + } + return + } + + picked := 0 + for picked < wanted { + fs := p.FirstFreeSlot() + if fs == -1 { + break + } + _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name) + if !ok { + break + } + p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + picked++ + } + + g.AccountStore.SaveCharacter(p) + if picked == 0 { + sess.WriteLine("Your inventory is full.") + } else if picked == 1 { + sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name)) + } + if picked < available { + sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, name)) + } } func (g *Game) doGetAll(sess *net.Session) { p := sess.Player.(*player.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.") @@ -141,10 +317,10 @@ func (g *Game) doGetAll(sess *net.Session) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { if len(picked) > 0 { - sess.WriteLine("Inventory full. Picked up so far:") + sess.WriteLine("You can't hold everything but you picked up:") innerPickupReport(sess, picked) } else { - sess.WriteLine("Your inventory is full.") + sess.WriteLine("Your inventory is full!") } g.AccountStore.SaveCharacter(p) return @@ -165,10 +341,10 @@ func (g *Game) doGetAll(sess *net.Session) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { if len(picked) > 0 { - sess.WriteLine("Inventory full. Picked up so far:") + sess.WriteLine("You can't hold everything but you picked up:") innerPickupReport(sess, picked) } else { - sess.WriteLine("Your inventory is full.") + sess.WriteLine("Your inventory is full!") } g.AccountStore.SaveCharacter(p) return @@ -194,7 +370,7 @@ func (g *Game) doGetAll(sess *net.Session) { if len(picked) == 0 { sess.WriteLine("Your inventory is full.") } else { - sess.Write("You pick up: ") + sess.Write("You picked up: ") innerPickupReport(sess, picked) } } diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go new file mode 100644 index 0000000..9cc4ac6 --- /dev/null +++ b/internal/game/cmd_inventory.go @@ -0,0 +1,36 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doInventory(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Inventory (28 slots):") + + empty := 0 + num := 0 + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + empty++ + continue + } + num++ + def, err := g.ItemStore.Load(slot.ItemID) + name := slot.ItemID + if err == nil { + name = def.Name + } + if slot.Quantity > 1 { + sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) + } else { + sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) + } + } + sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) +} diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index e7d30d6..48b7daf 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -79,41 +79,94 @@ func (g *Game) doLook(sess *net.Session) { continue } - var activeIdxs, depletedIdxs []int + type instInfo struct { + idx int + depleted bool + sharedMax int + sharedCur int + respawnIn int + } + var instances []instInfo for i := 0; i < count; i++ { st := g.World.GetObjState(p.RoomID, objID, i) - if st != nil && st.Depleted { - depletedIdxs = append(depletedIdxs, i+1) - } else { - activeIdxs = append(activeIdxs, i+1) + if st == nil { + continue } + instances = append(instances, instInfo{ + idx: i + 1, + depleted: st.Depleted, + sharedMax: st.SharedMax, + sharedCur: st.SharedTimer, + respawnIn: st.DepleteTimer, + }) } + multi := len(instances) > 1 + showTimers := p.Toggles["depletion"] - if len(activeIdxs) > 0 { - if len(activeIdxs) == 1 { - sess.Write(fmt.Sprintf(" A %s is here.", def.Name)) + var freshIdxs []int + var timed, depleted []instInfo + for _, ins := range instances { + if ins.depleted { + depleted = append(depleted, ins) + } else if ins.sharedMax > 0 && ins.sharedCur < ins.sharedMax { + timed = append(timed, ins) } else { - sess.Write(fmt.Sprintf(" %d %ss are here.", len(activeIdxs), def.Name)) + freshIdxs = append(freshIdxs, ins.idx) + } + } + + if len(freshIdxs) > 0 { + var suffix string + if multi && (len(timed) > 0 || len(depleted) > 0) { + suffix = fmt.Sprintf(" [%s]", intsJoin(freshIdxs)) } - if count > 1 { - sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(activeIdxs))) + if len(freshIdxs) == 1 { + sess.WriteLine(fmt.Sprintf(" A %s is here.%s", def.Name, suffix)) } else { - sess.WriteLine("") + sess.WriteLine(fmt.Sprintf(" %d %ss are here.%s", len(freshIdxs), def.Name, suffix)) } } - if len(depletedIdxs) > 0 { - if len(depletedIdxs) == 1 { - sess.Write(fmt.Sprintf(" 1 depleted %s is here.", def.Name)) - } else { - sess.Write(fmt.Sprintf(" %d depleted %ss are here.", len(depletedIdxs), def.Name)) + + for _, ins := range timed { + tag := "" + if multi { + tag = fmt.Sprintf(" [%d]", ins.idx) + } + timer := "" + if showTimers { + timer = fmt.Sprintf(" (despawn: %d/%d)", ins.sharedCur, ins.sharedMax) } - sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(depletedIdxs))) + sess.WriteLine(fmt.Sprintf(" A %s is here.%s%s", def.Name, tag, timer)) + } + + for _, ins := range depleted { + tag := "" + if multi { + tag = fmt.Sprintf(" [%d]", ins.idx) + } + timer := "" + if showTimers { + timer = fmt.Sprintf(" (respawns in %d ticks)", ins.respawnIn) + } + sess.WriteLine(fmt.Sprintf(" 1 depleted %s is here.%s%s", def.Name, tag, timer)) } } } ground := g.World.GroundItemsDetailed(p.RoomID) if len(ground) > 0 { + sort.Slice(ground, func(i, j int) bool { + ni := ground[i].ItemID + if def, err := g.ItemStore.Load(ground[i].ItemID); err == nil { + ni = def.Name + } + nj := ground[j].ItemID + if def, err := g.ItemStore.Load(ground[j].ItemID); err == nil { + nj = def.Name + } + return strings.ToLower(ni) < strings.ToLower(nj) + }) + sess.WriteLine("") sess.WriteLine("On the ground:") @@ -281,12 +334,20 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { sess.WriteLine(fmt.Sprintf(" %s", desc)) } - for _, ist := range instances { - if ist.Depleted { - if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf(" %s %d: depleted for %d more ticks.", def.Name, ist.Index+1, ist.DepleteTimer)) - } else { - sess.WriteLine(fmt.Sprintf(" Depleted for %d more ticks.", ist.DepleteTimer)) + if p.Toggles["depletion"] { + for _, ist := range instances { + if ist.Depleted { + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, ist.DepleteTimer)) + } else { + sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", ist.DepleteTimer)) + } + } else if ist.SharedMax > 0 && ist.SharedTimer < ist.SharedMax { + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%d.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax)) + } else { + sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%d ticks.", ist.SharedTimer, ist.SharedMax)) + } } } } @@ -335,14 +396,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { if strings.ToLower(op.Name) != lower { continue } - showPlayerInfo(sess, op) + showPlayerInfo(g, sess, op) return } sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) } -func showPlayerInfo(sess *net.Session, p *player.Player) { +func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { sess.WriteLines( "", p.Name, @@ -363,7 +424,11 @@ func showPlayerInfo(sess *net.Session, p *player.Player) { if !ok { continue } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID)) + name := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) } if p.Description != "" { diff --git a/internal/game/cmd_misc.go b/internal/game/cmd_misc.go deleted file mode 100644 index c02bd35..0000000 --- a/internal/game/cmd_misc.go +++ /dev/null @@ -1,125 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thirdcollapse/internal/net" - "thirdcollapse/internal/player" -) - -func (g *Game) doSay(sess *net.Session, msg string) { - p := sess.Player.(*player.Player) - roomID := p.RoomID - - for _, other := range g.Hub.PlayersInRoom(roomID) { - if other == sess { - other.WriteLine(fmt.Sprintf("\nYou say: %s", msg)) - } else { - other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg)) - } - } -} - -func (g *Game) doScore(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLines( - "", - fmt.Sprintf("Name: %s", p.Name), - fmt.Sprintf("Combat Level: %d", p.CombatLevel()), - fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), - fmt.Sprintf("Credits: %d", p.Credits), - "", - "Skills:", - ) - for _, s := range player.AllSkills { - level := p.Level(s) - xp := p.Skills[s] - next := player.XPForNextLevel(xp) - sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next)) - } -} - -func (g *Game) doInventory(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLine("") - sess.WriteLine("Inventory (28 slots):") - - empty := 0 - num := 0 - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - empty++ - continue - } - num++ - def, err := g.ItemStore.Load(slot.ItemID) - name := slot.ItemID - if err == nil { - name = def.Name - } - if slot.Quantity > 1 { - sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) - } else { - sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) - } - } - sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) -} - -func (g *Game) doEquipment(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLine("") - sess.WriteLine("Equipment:") - - for _, slot := range EquipSlots { - itemID, ok := p.Equipment[slot] - if !ok { - sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot)) - continue - } - def, err := g.ItemStore.Load(itemID) - name := itemID - if err == nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) - } -} - -func (g *Game) doStyle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - styles := []string{"accurate", "aggressive", "defensive", "balanced"} - if input == "" { - sess.WriteLine("\nCombat styles:") - for _, s := range styles { - marker := " " - if string(p.AttackStyle) == s { - marker = "*" - } - sess.WriteLine(fmt.Sprintf(" %s %s", marker, s)) - } - return - } - - input = strings.ToLower(input) - var matches []string - for _, s := range styles { - if strings.HasPrefix(s, input) { - matches = append(matches, s) - } - } - if len(matches) == 1 { - p.AttackStyle = player.AttackStyle(matches[0]) - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", matches[0])) - return - } - if len(matches) > 1 { - sess.WriteLine(fmt.Sprintf("\nAmbiguous style: %s. Choices: %s", input, strings.Join(matches, ", "))) - return - } - sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) -} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index e288dad..79ca45e 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -118,5 +118,11 @@ func (g *Game) ensureRoomObjects(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.SharedDeplete > 0 { + g.World.SetObjSharedDeplete(roomID, obj.ID, cfg.SharedDeplete) + } + } } } diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index a720bff..51e1fee 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -14,6 +14,7 @@ func (g *Game) doQuit(sess *net.Session) { return } + g.CancelAction(p) g.cancelRest(p.Name) ticksLeft := 10 diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go new file mode 100644 index 0000000..9f0cbe8 --- /dev/null +++ b/internal/game/cmd_remove.go @@ -0,0 +1,67 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" +) + +func (g *Game) doRemove(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "" { + sess.WriteLine("Remove what?") + return + } + + g.CancelAction(p) + + var foundSlot object.EquipSlot + var foundItemID string + + for _, slot := range EquipSlots { + itemID, ok := p.Equipment[slot] + if !ok { + continue + } + if strings.ToLower(string(slot)) == lower { + foundSlot = slot + foundItemID = itemID + break + } + def, err := g.ItemStore.Load(itemID) + if err != nil { + continue + } + if def.MatchesName(input) { + foundSlot = slot + foundItemID = itemID + break + } + } + + if foundSlot == "" { + sess.WriteLine(fmt.Sprintf("You aren't wearing any '%s'.", input)) + return + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Nowhere to put that!") + return + } + + delete(p.Equipment, foundSlot) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: foundItemID, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + + name := foundItemID + if def, err := g.ItemStore.Load(foundItemID); err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You remove %s.", name)) +} diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go new file mode 100644 index 0000000..35ec5fa --- /dev/null +++ b/internal/game/cmd_say.go @@ -0,0 +1,21 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doSay(sess *net.Session, msg string) { + p := sess.Player.(*player.Player) + roomID := p.RoomID + + for _, other := range g.Hub.PlayersInRoom(roomID) { + if other == sess { + other.WriteLine(fmt.Sprintf("You say: %s", msg)) + } else { + other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg)) + } + } +} diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go new file mode 100644 index 0000000..9b3904a --- /dev/null +++ b/internal/game/cmd_score.go @@ -0,0 +1,27 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doScore(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLines( + "", + fmt.Sprintf("Name: %s", p.Name), + fmt.Sprintf("Combat Level: %d", p.CombatLevel()), + fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), + fmt.Sprintf("Credits: %d", p.Credits), + "", + "Skills:", + ) + for _, s := range player.AllSkills { + level := p.Level(s) + xp := p.Skills[s] + next := player.XPForNextLevel(xp) + sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next)) + } +} diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go new file mode 100644 index 0000000..341685e --- /dev/null +++ b/internal/game/cmd_search.go @@ -0,0 +1,87 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doSearch(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "" { + sess.WriteLine("Search what?") + return + } + + matches := g.findInventoryMatches(lower, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s' to search.", input)) + return + } + + itemID := matches[0].ID + slotIdx := matches[0].Slot + + if itemID != "birds_nest" { + sess.WriteLine(fmt.Sprintf("You can't search %s.", matches[0].Name)) + return + } + + slot := p.InvSlot(slotIdx) + if slot == nil || slot.ItemID != "birds_nest" { + return + } + + if slot.Quantity > 1 { + slot.Quantity-- + } else { + p.SetInvSlot(slotIdx, nil) + } + + dt, err := g.BehaviorStore.LoadDropTable("birds_nest_drop") + if err != nil || len(dt.Drops) == 0 { + sess.WriteLine("You search the bird's nest but find nothing.") + g.AccountStore.SaveCharacter(p) + return + } + + drop := g.BehaviorStore.ResolveDrop(dt.Drops) + if drop == nil { + sess.WriteLine("You search the bird's nest but find nothing.") + g.AccountStore.SaveCharacter(p) + return + } + + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + if drop.ItemID == "credits" { + p.Credits += qty + sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %d credits.", qty)) + } else { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) + name := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s. It falls to the ground.", name)) + } else { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + name := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s.", name)) + } + } + + g.AccountStore.SaveCharacter(p) +} diff --git a/internal/game/cmd_style.go b/internal/game/cmd_style.go new file mode 100644 index 0000000..d86920c --- /dev/null +++ b/internal/game/cmd_style.go @@ -0,0 +1,45 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doStyle(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + styles := []string{"accurate", "aggressive", "defensive", "balanced"} + if input == "" { + sess.WriteLine("\nCombat styles:") + for _, s := range styles { + marker := " " + if string(p.AttackStyle) == s { + marker = "*" + } + sess.WriteLine(fmt.Sprintf(" %s %s", marker, s)) + } + return + } + + input = strings.ToLower(input) + var matches []string + for _, s := range styles { + if strings.HasPrefix(s, input) { + matches = append(matches, s) + } + } + if len(matches) == 1 { + p.AttackStyle = player.AttackStyle(matches[0]) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", matches[0])) + return + } + if len(matches) > 1 { + sess.WriteLine(fmt.Sprintf("\nAmbiguous style: %s. Choices: %s", input, strings.Join(matches, ", "))) + return + } + sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) +} diff --git a/internal/game/cmd_ticktest.go b/internal/game/cmd_ticktest.go deleted file mode 100644 index 6cadab7..0000000 --- a/internal/game/cmd_ticktest.go +++ /dev/null @@ -1,21 +0,0 @@ -package game - -import ( - "fmt" - - "thirdcollapse/internal/net" -) - -func (g *Game) doTickTest(sess *net.Session) { - count := 0 - g.Ticks.Subscribe(5, func() bool { - count++ - sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count)) - if count >= 3 { - sess.WriteLine("[Tick test complete.]") - return false - } - return true - }) - sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.") -} diff --git a/internal/game/cmd_toggle.go b/internal/game/cmd_toggle.go index 19c111c..25b8955 100644 --- a/internal/game/cmd_toggle.go +++ b/internal/game/cmd_toggle.go @@ -14,12 +14,13 @@ var toggles = []struct { }{ {"description", "Long room descriptions"}, {"tinymap", "Mini-map display"}, - {"xpdrops", "XP drop messages in combat"}, + {"xpdrops", "XP drop messages"}, {"exits", "Long exit display in look"}, {"mobenter", "Messages when mobs enter the room"}, {"mobleave", "Messages when mobs leave the room"}, {"mobspawn", "Messages when mobs spawn in the area"}, {"reserve", "Show full reserved item details"}, + {"depletion", "Show depletion and despawn timers on objects"}, } func (g *Game) doToggle(sess *net.Session, input string) { diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go new file mode 100644 index 0000000..c4b5d12 --- /dev/null +++ b/internal/game/cmd_wear.go @@ -0,0 +1,146 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" +) + +func (g *Game) doWear(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "all" { + g.doWearAll(sess) + return + } + + if lower == "" { + sess.WriteLine("Wear what?") + return + } + + g.CancelAction(p) + + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + return + } + + match := matches[0] + slot := p.InvSlot(match.Slot) + if slot == nil { + return + } + + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.EquipSlot == "" { + sess.WriteLine(fmt.Sprintf("You can't wear %s.", match.Name)) + return + } + + existingItemID, slotOccupied := p.Equipment[def.EquipSlot] + + if slotOccupied { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 && existingItemID != slot.ItemID { + sess.WriteLine("Your inventory is full.") + return + } + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existingItemID, Quantity: 1}) + } + } + + if slot.Quantity > 1 { + slot.Quantity-- + } else { + p.SetInvSlot(match.Slot, nil) + } + + p.Equipment[def.EquipSlot] = slot.ItemID + g.AccountStore.SaveCharacter(p) + + verb := "wear" + if def.WeaponType != "" { + verb = "wield" + } + sess.WriteLine(fmt.Sprintf("You %s %s.", verb, match.Name)) +} + +func (g *Game) doWearAll(sess *net.Session) { + p := sess.Player.(*player.Player) + g.CancelAction(p) + + bestPerSlot := make(map[object.EquipSlot]struct { + itemID string + value int + invSlot int + }) + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || def.EquipSlot == "" { + continue + } + current, exists := bestPerSlot[def.EquipSlot] + if !exists || def.Value > current.value { + bestPerSlot[def.EquipSlot] = struct { + itemID string + value int + invSlot int + }{slot.ItemID, def.Value, i} + } + } + + if len(bestPerSlot) == 0 { + sess.WriteLine("You have nothing to wear.") + return + } + + var equipped []string + for eqSlot, best := range bestPerSlot { + if existing, ok := p.Equipment[eqSlot]; ok && existing == best.itemID { + continue + } + + if existing, ok := p.Equipment[eqSlot]; ok { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + continue + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: existing, Quantity: 1}) + } + + invSlot := p.InvSlot(best.invSlot) + if invSlot != nil && invSlot.Quantity > 1 { + invSlot.Quantity-- + } else { + p.SetInvSlot(best.invSlot, nil) + } + + p.Equipment[eqSlot] = best.itemID + def, _ := g.ItemStore.Load(best.itemID) + name := best.itemID + if def != nil { + name = def.Name + } + equipped = append(equipped, name) + } + + if len(equipped) == 0 { + sess.WriteLine("Nothing new to wear.") + } else { + g.AccountStore.SaveCharacter(p) + sess.Write("You wear: ") + innerPickupReport(sess, equipped) + } +} diff --git a/internal/game/game.go b/internal/game/game.go index 08096b4..50c8f3d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -58,6 +58,7 @@ func (g *Game) SetHub(hub *net.Hub) { } func (g *Game) HandleSession(sess *net.Session, input string) { + input = player.StripControl(input) switch sess.State { case net.StateAccountName: g.handleAccountName(sess, input) @@ -129,13 +130,23 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { if len(args) == 0 { sess.WriteLine("Get what?") } else if args[0] == "all" { - g.doGetAll(sess) + 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, " ")) } @@ -186,15 +197,14 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doToggle(sess, strings.Join(args, " ")) case "exits": g.doExits(sess) - case "ticktest": - g.doTickTest(sess) + case "help": if len(args) == 0 { g.doHelp(sess, "") } else { g.doHelp(sess, strings.Join(args, " ")) } - case "mine", "chop", "fish", "use", "pull", "push": + case "mine", "chop", "fish", "cut", "use", "pull", "push": if len(args) == 0 { sess.WriteLine(fmt.Sprintf("%s what?", cmd)) } else { @@ -214,6 +224,19 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { case "unalias": g.doUnalias(sess, args) return + case "search": + if len(args) == 0 { + sess.WriteLine("Search what?") + } else { + g.doSearch(sess, strings.Join(args, " ")) + } + return + case "wear", "wield": + g.doWear(sess, strings.Join(args, " ")) + return + case "remove", "unwear", "unwield": + g.doRemove(sess, strings.Join(args, " ")) + return default: sess.WriteLine("Unknown command.") } diff --git a/internal/game/login.go b/internal/game/login.go new file mode 100644 index 0000000..3de70bc --- /dev/null +++ b/internal/game/login.go @@ -0,0 +1,550 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) handleAccountName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if name == "" { + sess.Write("Account name: ") + return + } + actualName, err := g.AccountStore.FindAccount(name) + if err == nil { + sess.Account = &net.AccountEntry{Name: actualName} + sess.State = net.StatePassword + sess.Conn.SetEcho(false) + sess.Write("Password: ") + } else { + if verr := player.ValidName(name); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Account name: ") + return + } + sess.Account = &net.AccountEntry{Name: name} + sess.State = net.StateNewAccountPass + sess.Conn.SetEcho(false) + sess.WriteLine("\r\nOh, a new visitor to this rock!") + sess.Write("Choose password: ") + } +} + +func (g *Game) handlePassword(sess *net.Session, input string) { + if input == "" { + sess.Write("Password: ") + return + } + if len(input) > 128 { + sess.Conn.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine("Error loading account.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if !player.CheckPassword(input, acc.PasswordHash) { + sess.Conn.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.Conn.SetEcho(true) + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Characters: acc.Characters, + Aliases: acc.Aliases, + } + if sess.Account.Aliases == nil { + sess.Account.Aliases = make(map[string]string) + } + g.showMenu(sess) +} + +func (g *Game) handleNewAccountPass(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Conn.SetEcho(true) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if verr := player.ValidPassword(input); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Choose password: ") + return + } + sess.PendingPass = input + sess.State = net.StateNewAccountConfirm + sess.Write("Confirm password: ") +} + +func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Conn.SetEcho(true) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if input != sess.PendingPass { + sess.Conn.SetEcho(true) + sess.WriteLine("Passwords do not match.") + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + hash, err := player.HashPassword(input) + if err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + acc := &player.Account{ + Name: sess.Account.Name, + PasswordHash: hash, + } + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.Conn.SetEcho(true) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + sess.Conn.SetEcho(true) + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Aliases: make(map[string]string), + } + sess.PendingPass = "" + g.showMenu(sess) +} + +func (g *Game) showMenu(sess *net.Session) { + sess.State = net.StateMenu + lines := []string{ + "", + fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name), + "", + } + if len(sess.Account.Characters) > 0 { + lines = append(lines, + " (C)onnect character to TC", + "", + " (L)ist characters", + " (R)ename character", + " (D)elete character", + ) + } + lines = append(lines, + " (N)ew character", + " (P)urge account", + " (A)ccount rename", + " (Q)uit", + "", + "> ", + ) + sess.WriteLines(lines...) +} + +func (g *Game) handleMenu(sess *net.Session, input string) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "": + sess.Write("> ") + case "c": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nHey, make a character with 'N' first!") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + g.connectCharacter(sess, sess.Account.Characters[0]) + return + } + sess.WriteLine("\nSelect character:") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.State = net.StateNewCharName + sess.PendingChar = "connect" + sess.Write("\n> ") + + case "l": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nYou don't have any characters! Make one with 'N'!") + } else { + sess.WriteLine("\nCharacters:") + for _, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + } + sess.Write("\n> ") + + case "n": + sess.State = net.StateNewCharName + sess.PendingChar = "" + sess.Write("What's your character name?: ") + + case "a": + sess.State = net.StateRenameAccount + sess.Write("New account name: ") + + case "r": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nRename who? You don't have any characters!") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) + sess.Write("New name: ") + return + } + sess.State = net.StateRenameChar + sess.WriteLine("\nRename which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n> ") + + case "d": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to delete.") + sess.Write("\n> ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + } else { + sess.State = net.StateDeleteChar + sess.WriteLine("\nDelete which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n> ") + return + } + g.showDeleteConfirm(sess) + + case "p": + sess.State = net.StatePurgeAccount + sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) + sess.Write("\n> ") + + case "q": + sess.WriteLine("Later.") + sess.Close() + return + + default: + sess.Write("> ") + } +} + +func (g *Game) handleNewCharName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + + if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = "" + g.connectCharacter(sess, sess.Account.Characters[idx-1]) + return + } + sess.WriteLine("Invalid choice.") + sess.Write("\n> ") + return + } + + if name == "" { + sess.Write("Character name: ") + return + } + + if verr := player.ValidName(name); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Character name: ") + return + } + + if g.AccountStore.CharacterExists(name) { + sess.WriteLine("A character with that name already exists.") + sess.Write("Character name: ") + return + } + + p := player.New(name) + p.RoomID = 1 + + if err := g.AccountStore.SaveCharacter(p); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc.Characters = append(acc.Characters, name) + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + g.connectCharacter(sess, name) +} + +func (g *Game) connectCharacter(sess *net.Session, name string) { + g.charsMu.Lock() + if existing := g.loggedInChars[name]; existing != nil { + g.charsMu.Unlock() + sess.WriteLine("This character is logged in elsewhere.") + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + g.charsMu.Unlock() + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + sess.Player = p + sess.State = net.StateGame + g.loggedInChars[name] = sess + g.charsMu.Unlock() + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.ensureRoomObjects(p.RoomID) + + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.doLook(sess) + sess.Write("\r\n> ") +} + +func (g *Game) handleRenameAccount(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + if newName == "" { + sess.Write("New account name: ") + return + } + if verr := player.ValidName(newName); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("New account name: ") + return + } + oldName := sess.Account.Name + if newName == oldName { + sess.WriteLine("That's already your account name.") + g.showMenu(sess) + return + } + if g.AccountStore.AccountExists(newName) { + sess.WriteLine("An account with that name already exists.") + sess.Write("New account name: ") + return + } + + oldPath := g.AccountStore.AccountPath(oldName) + newPath := g.AccountStore.AccountPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + g.showMenu(sess) + return + } + + sess.Account.Name = newName + sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) + g.showMenu(sess) +} + +func (g *Game) handleRenameChar(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + g.showMenu(sess) + return + } + + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) + sess.Write("New name: ") +} + +func (g *Game) handleRenameCharName(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + oldName := sess.PendingChar + if newName == "" { + sess.Write("New name: ") + return + } + if verr := player.ValidName(newName); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("New name: ") + return + } + if newName == oldName { + sess.WriteLine("That's already the character's name.") + g.showMenu(sess) + return + } + if g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") + return + } + + oldPath := g.AccountStore.CharPath(oldName) + newPath := g.AccountStore.CharPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } + + p, _ := g.AccountStore.LoadCharacter(newName) + if p != nil { + p.Name = newName + g.AccountStore.SaveCharacter(p) + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + for i, c := range acc.Characters { + if c == oldName { + acc.Characters[i] = newName + break + } + } + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + sess.PendingChar = "" + sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + g.showMenu(sess) +} + +func (g *Game) showDeleteConfirm(sess *net.Session) { + sess.State = net.StateDeleteChar + sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) +} + +func (g *Game) handleDeleteChar(sess *net.Session, input string) { + if sess.PendingChar == "" { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + g.showDeleteConfirm(sess) + return + } + + input = strings.TrimSpace(input) + expected := "DELETE " + sess.PendingChar + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Delete cancelled.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + + charPath := g.AccountStore.CharPath(sess.PendingChar) + if err := os.Remove(charPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + var newChars []string + for _, c := range acc.Characters { + if c != sess.PendingChar { + newChars = append(newChars, c) + } + } + acc.Characters = newChars + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = newChars + + sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) +} + +func (g *Game) handlePurgeAccount(sess *net.Session, input string) { + input = strings.TrimSpace(input) + expected := "PURGE " + sess.Account.Name + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Purge cancelled.") + g.showMenu(sess) + return + } + + for _, name := range sess.Account.Characters { + os.Remove(g.AccountStore.CharPath(name)) + } + + os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) + + sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) + sess.Close() +} diff --git a/internal/game/session.go b/internal/game/session.go deleted file mode 100644 index 7fe45e7..0000000 --- a/internal/game/session.go +++ /dev/null @@ -1,505 +0,0 @@ -package game - -import ( - "fmt" - "os" - "strings" - - "thirdcollapse/internal/net" - "thirdcollapse/internal/player" -) - -func (g *Game) handleAccountName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if name == "" { - sess.Write("Account name: ") - return - } - actualName, err := g.AccountStore.FindAccount(name) - if err == nil { - sess.Account = &net.AccountEntry{Name: actualName} - sess.State = net.StatePassword - sess.Write("Password: ") - } else { - sess.Account = &net.AccountEntry{Name: name} - sess.State = net.StateNewAccountPass - sess.Write("A new visitor to this rock!\nChoose password: ") - } -} - -func (g *Game) handlePassword(sess *net.Session, input string) { - if input == "" { - sess.Write("Password: ") - return - } - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err != nil { - sess.WriteLine("Error loading account.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if !player.CheckPassword(input, acc.PasswordHash) { - sess.WriteLine("Wrong password.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - sess.Account = &net.AccountEntry{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - Characters: acc.Characters, - Aliases: acc.Aliases, - } - if sess.Account.Aliases == nil { - sess.Account.Aliases = make(map[string]string) - } - g.showMenu(sess) -} - -func (g *Game) handleNewAccountPass(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - sess.PendingPass = input - sess.State = net.StateNewAccountConfirm - sess.Write("Confirm password: ") -} - -func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if input != sess.PendingPass { - sess.WriteLine("Passwords do not match.") - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - hash, err := player.HashPassword(input) - if err != nil { - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - acc := &player.Account{ - Name: sess.Account.Name, - PasswordHash: hash, - } - if err := g.AccountStore.SaveAccount(acc); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - sess.Account = &net.AccountEntry{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - Aliases: make(map[string]string), - } - sess.PendingPass = "" - g.showMenu(sess) -} - -func (g *Game) showMenu(sess *net.Session) { - sess.State = net.StateMenu - lines := []string{ - "", - fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name), - "", - } - if len(sess.Account.Characters) > 0 { - lines = append(lines, - " (C)onnect character to TC", - "", - " (L)ist characters", - " (R)ename character", - " (D)elete character", - ) - } - lines = append(lines, - " (N)ew character", - " (P)urge account", - " (A)ccount rename", - " (Q)uit", - "", - "INPUT: ", - ) - sess.WriteLines(lines...) -} - -func (g *Game) handleMenu(sess *net.Session, input string) { - switch strings.ToLower(strings.TrimSpace(input)) { - case "": - sess.Write("INPUT: ") - case "c": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!") - sess.Write("\nINPUT: ") - return - } - if len(sess.Account.Characters) == 1 { - g.connectCharacter(sess, sess.Account.Characters[0]) - return - } - sess.WriteLine("\nSelect character:") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.State = net.StateNewCharName - sess.PendingChar = "connect" - sess.Write("\nChoice: ") - - case "l": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)") - } else { - sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:") - for _, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } - } - sess.Write("\nINPUT: ") - - case "n": - sess.State = net.StateNewCharName - sess.PendingChar = "" - sess.Write("What's your character name?: ") - - case "a": - sess.State = net.StateRenameAccount - sess.Write("New account name: ") - - case "r": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nNo characters to rename.") - sess.Write("\nChoice: ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) - sess.Write("New name: ") - return - } - sess.State = net.StateRenameChar - sess.WriteLine("\nRename which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n> : ") - - case "d": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nNo characters to delete.") - sess.Write("\nINPUT: ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - } else { - sess.State = net.StateDeleteChar - sess.WriteLine("\nDelete which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n>: ") - return - } - g.showDeleteConfirm(sess) - - case "p": - sess.State = net.StatePurgeAccount - sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) - sess.Write("\n> ") - - case "q": - sess.WriteLine("Later.") - sess.Conn.Close() - return - - default: - sess.Write("SYNTAX ERROR\nINPUT: ") - } -} - -func (g *Game) handleNewCharName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - - if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = "" - g.connectCharacter(sess, sess.Account.Characters[idx-1]) - return - } - sess.WriteLine("Invalid choice.") - sess.Write("\nChoice: ") - return - } - - if name == "" { - sess.Write("Character name: ") - return - } - - if g.AccountStore.CharacterExists(name) { - sess.WriteLine("A character with that name already exists.") - sess.Write("Character name: ") - return - } - - p := player.New(name) - p.RoomID = 1 - - if err := g.AccountStore.SaveCharacter(p); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - acc.Characters = append(acc.Characters, name) - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - g.connectCharacter(sess, name) -} - -func (g *Game) connectCharacter(sess *net.Session, name string) { - g.charsMu.Lock() - if existing := g.loggedInChars[name]; existing != nil { - g.charsMu.Unlock() - sess.WriteLine("This character is logged in elsewhere.") - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - g.charsMu.Unlock() - sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - sess.Player = p - sess.State = net.StateGame - g.loggedInChars[name] = sess - g.charsMu.Unlock() - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.ensureRoomObjects(p.RoomID) - - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - - g.doLook(sess) - sess.Write("\r\n> ") -} - -func (g *Game) handleRenameAccount(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - if newName == "" { - sess.Write("New account name: ") - return - } - oldName := sess.Account.Name - if newName == oldName { - sess.WriteLine("That's already your account name.") - g.showMenu(sess) - return - } - if g.AccountStore.AccountExists(newName) { - sess.WriteLine("An account with that name already exists.") - sess.Write("New account name: ") - return - } - - oldPath := g.AccountStore.AccountPath(oldName) - newPath := g.AccountStore.AccountPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) - g.showMenu(sess) - return - } - - sess.Account.Name = newName - sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) - g.showMenu(sess) -} - -func (g *Game) handleRenameChar(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - g.showMenu(sess) - return - } - - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) - sess.Write("New name: ") -} - -func (g *Game) handleRenameCharName(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - oldName := sess.PendingChar - if newName == "" { - sess.Write("New name: ") - return - } - if newName == oldName { - sess.WriteLine("That's already the character's name.") - g.showMenu(sess) - return - } - if g.AccountStore.CharacterExists(newName) { - sess.WriteLine("A character with that name already exists.") - sess.Write("New name: ") - return - } - - oldPath := g.AccountStore.CharPath(oldName) - newPath := g.AccountStore.CharPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) - g.showMenu(sess) - return - } - - p, _ := g.AccountStore.LoadCharacter(newName) - if p != nil { - p.Name = newName - g.AccountStore.SaveCharacter(p) - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - for i, c := range acc.Characters { - if c == oldName { - acc.Characters[i] = newName - break - } - } - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - sess.PendingChar = "" - sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) - g.showMenu(sess) -} - -func (g *Game) showDeleteConfirm(sess *net.Session) { - sess.State = net.StateDeleteChar - sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) -} - -func (g *Game) handleDeleteChar(sess *net.Session, input string) { - if sess.PendingChar == "" { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - g.showDeleteConfirm(sess) - return - } - - input = strings.TrimSpace(input) - expected := "DELETE " + sess.PendingChar - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Delete cancelled.") - sess.PendingChar = "" - g.showMenu(sess) - return - } - - charPath := g.AccountStore.CharPath(sess.PendingChar) - if err := os.Remove(charPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - var newChars []string - for _, c := range acc.Characters { - if c != sess.PendingChar { - newChars = append(newChars, c) - } - } - acc.Characters = newChars - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = newChars - - sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) -} - -func (g *Game) handlePurgeAccount(sess *net.Session, input string) { - input = strings.TrimSpace(input) - expected := "PURGE " + sess.Account.Name - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Purge cancelled.") - g.showMenu(sess) - return - } - - for _, name := range sess.Account.Characters { - os.Remove(g.AccountStore.CharPath(name)) - } - - os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) - - sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) - sess.Conn.Close() -} diff --git a/internal/game/tick.go b/internal/game/tick.go index 9595668..7eb63eb 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -6,6 +6,7 @@ import ( "thirdcollapse/internal/combat" "thirdcollapse/internal/player" + "thirdcollapse/internal/world" ) func (g *Game) DisconnectTick() { @@ -72,29 +73,32 @@ func (g *Game) WanderTick() { var moves []moveEvent - // Mob wandering + // Mob wandering — exit-based for _, inst := range g.MobStore.AllInstances() { - if inst.HP <= 0 || len(inst.WanderRooms) == 0 || inst.WanderInterval <= 0 { + if inst.HP <= 0 || inst.WanderInterval <= 0 { continue } if combat.IsMobInCombat(inst.InstanceID) { continue } inst.WanderTickCounter++ - if inst.WanderTickCounter >= inst.WanderInterval { - inst.WanderTickCounter = 0 - toRoom := inst.WanderRooms[rand.Intn(len(inst.WanderRooms))] - if toRoom == inst.RoomID { - continue - } - moves = append(moves, moveEvent{ - name: mobDisplayName(inst, false), - fromRoom: inst.RoomID, - toRoom: toRoom, - level: mobCombatLevel(inst), - }) - inst.RoomID = toRoom + if inst.WanderTickCounter < inst.WanderInterval { + continue } + inst.WanderTickCounter = 0 + + legal := g.legalMobExits(inst) + if len(legal) == 0 { + continue + } + toRoom := legal[rand.Intn(len(legal))] + moves = append(moves, moveEvent{ + name: mobDisplayName(inst, false), + fromRoom: inst.RoomID, + toRoom: toRoom, + level: mobCombatLevel(inst), + }) + inst.RoomID = toRoom } // Object wandering (fishing spots, etc.) @@ -139,3 +143,61 @@ func (g *Game) WanderTick() { } } } + +func (g *Game) WoodcuttingTick() { + all := g.World.AllSharedObjStates() + for _, st := range all { + if st.Depleted { + continue + } + key := g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) + choppers := g.countChoppers(st.RoomID, key) + if choppers > 0 { + if st.SharedTimer > 0 { + st.SharedTimer-- + } + } else { + if st.SharedTimer < st.SharedMax { + st.SharedTimer++ + } + } + } +} + +func (g *Game) countChoppers(roomID int, instanceKey string) int { + count := 0 + for _, sess := range g.Hub.PlayersInRoom(roomID) { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil || p.Action.Type != "gather" { + continue + } + if key, ok := p.Action.Data["instance_key"].(string); ok && key == instanceKey { + count++ + } + } + return count +} + +func (g *Game) legalMobExits(inst *world.MobInstance) []int { + room, err := g.World.LoadRoom(inst.RoomID) + if err != nil || len(room.Exits) == 0 { + return nil + } + allowed := make(map[int]bool) + if len(inst.WanderRooms) > 0 { + for _, r := range inst.WanderRooms { + allowed[r] = true + } + } + var legal []int + for _, exitDef := range room.Exits { + if exitDef.Condition != nil { + continue + } + if len(allowed) > 0 && !allowed[exitDef.Room] { + continue + } + legal = append(legal, exitDef.Room) + } + return legal +} diff --git a/internal/game/utils.go b/internal/game/utils.go index 0559f4f..c78889d 100644 --- a/internal/game/utils.go +++ b/internal/game/utils.go @@ -3,6 +3,8 @@ package game import ( "fmt" "math/rand" + "strconv" + "strings" "thirdcollapse/internal/net" "thirdcollapse/internal/object" @@ -43,6 +45,16 @@ func plural(n int) string { return "s" } +func parseQty(input string) (int, string) { + parts := strings.Fields(input) + if len(parts) > 1 { + if n, err := strconv.Atoi(parts[0]); err == nil && n > 0 { + return n, strings.Join(parts[1:], " ") + } + } + return 0, input +} + func parseIndex(s string) (int, error) { var idx int _, err := fmt.Sscanf(s, "%d", &idx) diff --git a/internal/net/conn.go b/internal/net/conn.go new file mode 100644 index 0000000..e46031b --- /dev/null +++ b/internal/net/conn.go @@ -0,0 +1,101 @@ +package net + +import ( + "bufio" + "bytes" + "io" + "net" + "strings" +) + +type Conn interface { + ReadMessage() (string, error) + io.Writer + io.Closer + SetEcho(bool) error +} + +type tcpConn struct { + conn net.Conn + reader *bufio.Reader + echoOff bool +} + +func newTCPConn(conn net.Conn) *tcpConn { + return &tcpConn{ + conn: conn, + reader: bufio.NewReader(conn), + } +} + +func (t *tcpConn) ReadMessage() (string, error) { + line, err := t.reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimRight(line, "\r\n") + line = stripIAC(line) + if t.echoOff { + t.conn.Write([]byte("\r\n")) + } + return line, nil +} + +func (t *tcpConn) Write(b []byte) (int, error) { + return t.conn.Write(b) +} + +func (t *tcpConn) Close() error { + return t.conn.Close() +} + +func (t *tcpConn) SetEcho(on bool) error { + t.echoOff = !on + if on { + return t.writeTelnetCmd(wont, echo) + } + return t.writeTelnetCmd(will, echo) +} + +const ( + iac = 255 + will = 251 + wont = 252 + do = 253 + dont = 254 + sb = 250 + se = 240 + echo = 1 +) + +func (t *tcpConn) writeTelnetCmd(cmd, opt byte) error { + _, err := t.conn.Write([]byte{iac, cmd, opt}) + return err +} + +func stripIAC(s string) string { + b := []byte(s) + var out []byte + i := 0 + iacSE := []byte{iac, se} + for i < len(b) { + if b[i] == iac && i+2 < len(b) { + if b[i+1] == sb { + end := bytes.Index(b[i+2:], iacSE) + if end >= 0 { + i += end + 4 + continue + } + } + i += 3 + continue + } + if b[i] == iac { + i++ + continue + } + out = append(out, b[i]) + i++ + } + return string(out) +} diff --git a/internal/net/server.go b/internal/net/server.go index 08d427f..7e73bc9 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -1,11 +1,13 @@ package net import ( - "bufio" + "crypto/tls" "fmt" "log" "net" - "strings" + "net/http" + + "thirdcollapse/internal/config" ) type SessionState int @@ -30,14 +32,13 @@ const ( ) type Session struct { - Conn net.Conn - Reader *bufio.Reader - State SessionState - Account *AccountEntry - Player interface{} // *player.Player once character is selected - PendingChar string // char being renamed/deleted - PendingPass string // first password during signup - Disconnecting bool + Conn Conn + State SessionState + Account *AccountEntry + Player interface{} + PendingChar string + PendingPass string + Disconnecting bool DisconnectTicks int } @@ -49,14 +50,18 @@ type AccountEntry struct { } type Server struct { - listener net.Listener - hub *Hub + config *config.Config + telnetLn net.Listener + httpServer *http.Server + httpsServer *http.Server + hub *Hub + handler func(*Session, string) } type Hub struct { - sessions map[*Session]bool - rooms map[int]map[*Session]bool - onRemove func(*Session) + sessions map[*Session]bool + rooms map[int]map[*Session]bool + onRemove func(*Session) } func NewHub() *Hub { @@ -125,12 +130,40 @@ func (h *Hub) PlayersInRoom(roomID int) []*Session { return out } -func NewServer(addr string) (*Server, error) { - l, err := net.Listen("tcp", addr) - if err != nil { - return nil, err +func NewServer(cfg *config.Config) (*Server, error) { + s := &Server{ + config: cfg, + hub: NewHub(), + } + + if cfg.Telnet.Enabled { + addr := fmt.Sprintf(":%d", cfg.Telnet.Port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("telnet listen: %w", err) + } + s.telnetLn = ln + } + + if cfg.HTTP.Enabled { + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.HTTP.Port), + Handler: newHTTPMux(s), + } + } + + if cfg.HTTPS.Enabled { + if cfg.HTTPS.CertFile == "" || cfg.HTTPS.KeyFile == "" { + return nil, fmt.Errorf("https enabled but cert_file and key_file are required") + } + s.httpsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.HTTPS.Port), + Handler: newHTTPMux(s), + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + } } - return &Server{listener: l, hub: NewHub()}, nil + + return s, nil } func (s *Server) Hub() *Hub { @@ -138,31 +171,66 @@ func (s *Server) Hub() *Hub { } func (s *Server) ListenAndServe(handler func(*Session, string)) error { + s.handler = handler + + if s.telnetLn != nil { + go s.serveTelnet() + } + + if s.httpServer != nil { + go s.serveHTTP() + } + + if s.httpsServer != nil { + go s.serveHTTPS() + } + + if s.telnetLn == nil && s.httpServer == nil && s.httpsServer == nil { + return fmt.Errorf("no listeners configured") + } + + select {} +} + +func (s *Server) serveTelnet() { for { - conn, err := s.listener.Accept() + conn, err := s.telnetLn.Accept() if err != nil { - return err + return } - session := &Session{ - Conn: conn, - Reader: bufio.NewReader(conn), - State: StateAccountName, + sess := &Session{ + Conn: newTCPConn(conn), + State: StateAccountName, } - s.hub.Add(session) - go s.handleSession(session, handler) + s.hub.Add(sess) + go s.handleSession(sess, s.handler) } } -func (s *Server) handleSession(session *Session, handler func(*Session, string)) { +func (s *Server) serveHTTP() { + err := s.httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Printf("http server error: %v", err) + } +} + +func (s *Server) serveHTTPS() { + err := s.httpsServer.ListenAndServeTLS(s.config.HTTPS.CertFile, s.config.HTTPS.KeyFile) + if err != nil && err != http.ErrServerClosed { + log.Printf("https server error: %v", err) + } +} + +func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { defer func() { if r := recover(); r != nil { log.Printf("session panic: %v", r) } - s.hub.Remove(session) - session.Conn.Close() + s.hub.Remove(sess) + sess.Close() }() - _, _ = session.Conn.Write([]byte("\033[2J\033[H")) // clear screen + sess.Conn.Write([]byte("\033[2J\033[H")) welcomeart := ` , 3333333 333 333 333 3333333 3333333 @@ -179,29 +247,30 @@ func (s *Server) handleSession(session *Session, handler func(*Session, string)) :!! !!: !!! !!: !!: !!: !!! !!: !:! !!: :: :: : : :. : : ::.: : : ::.: : : : : : ::.: : : :: ::: - ` - session.Write(welcomeart) - session.Write("ACCOUNT NAME> ") + sess.Write(welcomeart) + sess.Write("What's your account name? ") for { - line, err := session.Reader.ReadString('\n') + line, err := sess.Conn.ReadMessage() if err != nil { log.Printf("session read error: %v", err) return } - line = strings.TrimSpace(line) - handler(session, line) + if len(line) > 1024 { + line = line[:1024] + } + handler(sess, line) } } func (sess *Session) Write(msg string) { - _, _ = sess.Conn.Write([]byte(msg)) + sess.Conn.Write([]byte(msg)) } func (sess *Session) WriteLine(msg string) { - _, _ = sess.Conn.Write([]byte(msg + "\r\n")) + sess.Conn.Write([]byte(msg + "\r\n")) } func (sess *Session) WriteLines(lines ...string) { @@ -213,3 +282,7 @@ func (sess *Session) WriteLines(lines ...string) { func (sess *Session) Writef(format string, args ...interface{}) { sess.Write(fmt.Sprintf(format, args...)) } + +func (sess *Session) Close() error { + return sess.Conn.Close() +} diff --git a/internal/net/terminal.html b/internal/net/terminal.html new file mode 100644 index 0000000..2d98178 --- /dev/null +++ b/internal/net/terminal.html @@ -0,0 +1,164 @@ + + +
+ + +