aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/action/action.go2
-rw-r--r--internal/action/behavior.go21
-rw-r--r--internal/config/config.go59
-rw-r--r--internal/game/action.go593
-rw-r--r--internal/game/action_gather.go355
-rw-r--r--internal/game/action_room.go47
-rw-r--r--internal/game/action_talk.go211
-rw-r--r--internal/game/action_toggle.go46
-rw-r--r--internal/game/action_use.go120
-rw-r--r--internal/game/cmd_drop.go163
-rw-r--r--internal/game/cmd_equipment.go28
-rw-r--r--internal/game/cmd_get.go222
-rw-r--r--internal/game/cmd_inventory.go36
-rw-r--r--internal/game/cmd_look.go119
-rw-r--r--internal/game/cmd_misc.go125
-rw-r--r--internal/game/cmd_move.go6
-rw-r--r--internal/game/cmd_quit.go1
-rw-r--r--internal/game/cmd_remove.go67
-rw-r--r--internal/game/cmd_say.go21
-rw-r--r--internal/game/cmd_score.go27
-rw-r--r--internal/game/cmd_search.go87
-rw-r--r--internal/game/cmd_style.go45
-rw-r--r--internal/game/cmd_ticktest.go21
-rw-r--r--internal/game/cmd_toggle.go3
-rw-r--r--internal/game/cmd_wear.go146
-rw-r--r--internal/game/game.go31
-rw-r--r--internal/game/login.go (renamed from internal/game/session.go)83
-rw-r--r--internal/game/tick.go92
-rw-r--r--internal/game/utils.go12
-rw-r--r--internal/net/conn.go101
-rw-r--r--internal/net/server.go153
-rw-r--r--internal/net/terminal.html164
-rw-r--r--internal/net/web.go72
-rw-r--r--internal/net/wsconn.go34
-rw-r--r--internal/object/item.go39
-rw-r--r--internal/player/player.go14
-rw-r--r--internal/player/validate.go37
-rw-r--r--internal/world/mob.go46
-rw-r--r--internal/world/room.go21
-rw-r--r--internal/world/world.go107
40 files changed, 2640 insertions, 937 deletions
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/session.go b/internal/game/login.go
index 7fe45e7..3de70bc 100644
--- a/internal/game/session.go
+++ b/internal/game/login.go
@@ -19,11 +19,19 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
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.Write("A new visitor to this rock!\nChoose password: ")
+ sess.Conn.SetEcho(false)
+ sess.WriteLine("\r\nOh, a new visitor to this rock!")
+ sess.Write("Choose password: ")
}
}
@@ -32,19 +40,29 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
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,
@@ -60,11 +78,17 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
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: ")
@@ -73,12 +97,14 @@ func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
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
@@ -88,6 +114,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
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
@@ -100,6 +127,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
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
@@ -107,6 +135,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
return
}
+ sess.Conn.SetEcho(true)
sess.Account = &net.AccountEntry{
Name: acc.Name,
PasswordHash: acc.PasswordHash,
@@ -120,7 +149,7 @@ func (g *Game) showMenu(sess *net.Session) {
sess.State = net.StateMenu
lines := []string{
"",
- fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name),
+ fmt.Sprintf("Welcome back to Gaia 04, %s.", sess.Account.Name),
"",
}
if len(sess.Account.Characters) > 0 {
@@ -138,7 +167,7 @@ func (g *Game) showMenu(sess *net.Session) {
" (A)ccount rename",
" (Q)uit",
"",
- "INPUT: ",
+ "> ",
)
sess.WriteLines(lines...)
}
@@ -146,11 +175,11 @@ func (g *Game) showMenu(sess *net.Session) {
func (g *Game) handleMenu(sess *net.Session, input string) {
switch strings.ToLower(strings.TrimSpace(input)) {
case "":
- sess.Write("INPUT: ")
+ sess.Write("> ")
case "c":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!")
- sess.Write("\nINPUT: ")
+ sess.WriteLine("\nHey, make a character with 'N' first!")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -163,18 +192,18 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
}
sess.State = net.StateNewCharName
sess.PendingChar = "connect"
- sess.Write("\nChoice: ")
+ sess.Write("\n> ")
case "l":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)")
+ sess.WriteLine("\nYou don't have any characters! Make one with 'N'!")
} else {
- sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:")
+ sess.WriteLine("\nCharacters:")
for _, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" - %s", name))
}
}
- sess.Write("\nINPUT: ")
+ sess.Write("\n> ")
case "n":
sess.State = net.StateNewCharName
@@ -187,8 +216,8 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
case "r":
if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to rename.")
- sess.Write("\nChoice: ")
+ sess.WriteLine("\nRename who? You don't have any characters!")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -203,12 +232,12 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
}
- sess.Write("\n> : ")
+ sess.Write("\n> ")
case "d":
if len(sess.Account.Characters) == 0 {
sess.WriteLine("\nNo characters to delete.")
- sess.Write("\nINPUT: ")
+ sess.Write("\n> ")
return
}
if len(sess.Account.Characters) == 1 {
@@ -219,7 +248,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
}
- sess.Write("\n>: ")
+ sess.Write("\n> ")
return
}
g.showDeleteConfirm(sess)
@@ -231,11 +260,11 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
case "q":
sess.WriteLine("Later.")
- sess.Conn.Close()
+ sess.Close()
return
default:
- sess.Write("SYNTAX ERROR\nINPUT: ")
+ sess.Write("> ")
}
}
@@ -249,7 +278,7 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
return
}
sess.WriteLine("Invalid choice.")
- sess.Write("\nChoice: ")
+ sess.Write("\n> ")
return
}
@@ -258,6 +287,12 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
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: ")
@@ -324,6 +359,11 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) {
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.")
@@ -382,6 +422,11 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
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)
@@ -501,5 +546,5 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
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()
+ sess.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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Third Collapse</title>
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ background: #0a0a0a;
+ color: #c0c0c0;
+ font-family: "Courier New", monospace;
+ font-size: 15px;
+ line-height: 1.25;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+#output {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 14px;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+#input-line {
+ display: flex;
+ border-top: 1px solid #222;
+}
+#input-line span {
+ padding: 8px 0 8px 14px;
+ color: #888;
+}
+#input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: #c0c0c0;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 8px 14px 8px 4px;
+ outline: none;
+}
+</style>
+</head>
+<body>
+<pre id="output"></pre>
+<div id="input-line">
+ <span>&gt;</span>
+ <input type="text" id="input" autofocus autocomplete="off" spellcheck="false">
+</div>
+<script>
+(function () {
+ const out = document.getElementById("output");
+ const inp = document.getElementById("input");
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(proto + "//" + location.host + "/ws");
+
+ function ansiToHTML(text) {
+ let out = "";
+ let i = 0;
+ const stack = [];
+ while (i < text.length) {
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
+ let j = i + 2;
+ while (j < text.length && (text[j] < "A" || text[j] > "z" || text[j] === "[" || text[j] === ";")) j++;
+ if (j < text.length) j++;
+ const seq = text.slice(i + 2, j);
+ out += ansiSeq(seq, stack);
+ i = j;
+ continue;
+ }
+ out += escapeHTML(text[i]);
+ i++;
+ }
+ for (let k = stack.length - 1; k >= 0; k--) out += "</span>";
+ return out;
+ }
+
+ function escapeHTML(c) {
+ if (c === "&") return "&amp;";
+ if (c === "<") return "&lt;";
+ if (c === ">") return "&gt;";
+ return c;
+ }
+
+ function ansiSeq(seq, stack) {
+ if (seq === "0" || seq === "") {
+ let s = "";
+ for (let k = stack.length - 1; k >= 0; k--) s += "</span>";
+ stack.length = 0;
+ return s;
+ }
+ const codes = seq.endsWith("m") ? seq.slice(0, -1).split(";") : [];
+ let style = "";
+ for (const c of codes) {
+ switch (c) {
+ case "1": style += "font-weight:bold;"; break;
+ case "2": style += "opacity:0.6;"; break;
+ case "30": style += "color:#000;"; break;
+ case "31": style += "color:#c44;"; break;
+ case "32": style += "color:#4c4;"; break;
+ case "33": style += "color:#cc4;"; break;
+ case "34": style += "color:#44c;"; break;
+ case "35": style += "color:#c4c;"; break;
+ case "36": style += "color:#4cc;"; break;
+ case "37": style += "color:#ccc;"; break;
+ case "40": style += "background:#000;"; break;
+ case "41": style += "background:#c44;"; break;
+ case "42": style += "background:#4c4;"; break;
+ case "43": style += "background:#cc4;"; break;
+ case "44": style += "background:#44c;"; break;
+ case "45": style += "background:#c4c;"; break;
+ case "46": style += "background:#4cc;"; break;
+ case "47": style += "background:#ccc;"; break;
+ }
+ }
+ if (style) {
+ stack.push(1);
+ return '<span style="' + style + '">';
+ }
+ return "";
+ }
+
+ ws.onmessage = function (e) {
+ var data = e.data;
+ var re = /\x1b\]10;([01])\x07/g;
+ var match;
+ while ((match = re.exec(data)) !== null) {
+ inp.type = match[1] === "1" ? "password" : "text";
+ }
+ data = data.replace(re, "");
+ if (data.length > 0) {
+ out.innerHTML += ansiToHTML(data);
+ out.scrollTop = out.scrollHeight;
+ }
+ };
+
+ inp.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ var text = inp.value;
+ inp.value = "";
+ if (inp.type !== "password") {
+ out.innerHTML += ansiToHTML(text + "\n");
+ } else {
+ out.innerHTML += "\n";
+ }
+ out.scrollTop = out.scrollHeight;
+ ws.send(text);
+ }
+ });
+
+ ws.onclose = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection lost.\u001b[0m\n");
+ };
+
+ ws.onerror = function () {
+ out.innerHTML += ansiToHTML("\n\u001b[31mConnection error.\u001b[0m\n");
+ };
+})();
+</script>
+</body>
+</html>
diff --git a/internal/net/web.go b/internal/net/web.go
new file mode 100644
index 0000000..e573aba
--- /dev/null
+++ b/internal/net/web.go
@@ -0,0 +1,72 @@
+package net
+
+import (
+ "embed"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gorilla/websocket"
+)
+
+//go:embed terminal.html
+var terminalHTML embed.FS
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ host := r.Host
+ hostname, _, err := net.SplitHostPort(host)
+ if err != nil {
+ hostname = host
+ }
+ if strings.EqualFold(origin, "http://"+host) ||
+ strings.EqualFold(origin, "https://"+host) ||
+ strings.EqualFold(origin, "http://"+hostname) ||
+ strings.EqualFold(origin, "https://"+hostname) {
+ return true
+ }
+ if hn, _, err := net.SplitHostPort(r.RemoteAddr); err == nil &&
+ (hn == "127.0.0.1" || hn == "::1") {
+ return true
+ }
+ log.Printf("ws rejected origin: %s (host: %s)", origin, r.Host)
+ return false
+ },
+}
+
+func newHTTPMux(s *Server) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ data, err := terminalHTML.ReadFile("terminal.html")
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(data)
+ })
+
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("ws upgrade error: %v", err)
+ return
+ }
+ sess := &Session{
+ Conn: newWSConn(ws),
+ State: StateAccountName,
+ }
+ s.hub.Add(sess)
+ go s.handleSession(sess, s.handler)
+ })
+
+ return mux
+}
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
new file mode 100644
index 0000000..d6f45dc
--- /dev/null
+++ b/internal/net/wsconn.go
@@ -0,0 +1,34 @@
+package net
+
+import (
+ "github.com/gorilla/websocket"
+)
+
+type wsConn struct {
+ conn *websocket.Conn
+}
+
+func newWSConn(conn *websocket.Conn) *wsConn {
+ return &wsConn{conn: conn}
+}
+
+func (w *wsConn) ReadMessage() (string, error) {
+ _, msg, err := w.conn.ReadMessage()
+ return string(msg), err
+}
+
+func (w *wsConn) Write(b []byte) (int, error) {
+ err := w.conn.WriteMessage(websocket.TextMessage, b)
+ return len(b), err
+}
+
+func (w *wsConn) Close() error {
+ return w.conn.Close()
+}
+
+func (w *wsConn) SetEcho(on bool) error {
+ if on {
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;0\x07"))
+ }
+ return w.conn.WriteMessage(websocket.TextMessage, []byte("\x1b]10;1\x07"))
+}
diff --git a/internal/object/item.go b/internal/object/item.go
index 170e07f..c9b2717 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -37,7 +37,6 @@ type ItemDef struct {
WeaponType WeaponType `yaml:"weapon_type"`
Stats ItemStats `yaml:"stats"`
Speed int `yaml:"speed"`
- Toolbelt bool `yaml:"toolbelt"`
ToolType string `yaml:"tool_type"`
ToolSpeed int `yaml:"tool_speed"`
}
@@ -51,14 +50,46 @@ type ItemStats struct {
}
func (d *ItemDef) MatchesName(input string) bool {
- lower := strings.ToLower(input)
+ lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "" {
+ return false
+ }
if strings.ToLower(d.Name) == lower {
return true
}
- for _, word := range strings.Fields(d.Name) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
+ for _, alias := range d.Aliases {
+ if strings.ToLower(alias) == lower {
+ return true
+ }
+ }
+ if wordPrefixMatch(lower, d.Name) {
+ return true
+ }
+ for _, alias := range d.Aliases {
+ if wordPrefixMatch(lower, alias) {
return true
}
}
return false
}
+
+func wordPrefixMatch(input, name string) bool {
+ inputWords := strings.Fields(input)
+ if len(inputWords) == 0 {
+ return false
+ }
+ nameWords := strings.Fields(strings.ToLower(name))
+ for _, iw := range inputWords {
+ found := false
+ for _, nw := range nameWords {
+ if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/player/player.go b/internal/player/player.go
index fee6578..cccf979 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -16,6 +16,7 @@ const (
Fishing SkillName = "fishing"
Cooking SkillName = "cooking"
Woodcutting SkillName = "woodcutting"
+ Firemaking SkillName = "firemaking"
Mining SkillName = "mining"
Smithing SkillName = "smithing"
Crafting SkillName = "crafting"
@@ -32,7 +33,7 @@ const (
var AllSkills = []SkillName{
Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
- Fishing, Cooking, Woodcutting, Mining, Smithing, Crafting, Fletching,
+ Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching,
Alchemy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
@@ -47,6 +48,7 @@ var SkillAbbr = map[SkillName]string{
Fishing: "fis",
Cooking: "cok",
Woodcutting: "wct",
+ Firemaking: "fmk",
Mining: "min",
Smithing: "smt",
Crafting: "cft",
@@ -89,7 +91,6 @@ type Player struct {
Skills map[SkillName]int `yaml:"skills"`
Inventory map[int]*InventorySlot `yaml:"inventory"`
Equipment map[object.EquipSlot]string `yaml:"equipment"`
- Toolbelt []string `yaml:"toolbelt"`
RoomID int `yaml:"room_id"`
HP int `yaml:"hp"`
Credits int `yaml:"credits"`
@@ -195,15 +196,6 @@ func (p *Player) StartRegen() {
}
}
-func (p *Player) HasToolbeltItem(itemID string) bool {
- for _, id := range p.Toolbelt {
- if id == itemID {
- return true
- }
- }
- return false
-}
-
func (p *Player) HasItem(itemID string) bool {
for _, slot := range p.Inventory {
if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
diff --git a/internal/player/validate.go b/internal/player/validate.go
new file mode 100644
index 0000000..63f40d2
--- /dev/null
+++ b/internal/player/validate.go
@@ -0,0 +1,37 @@
+package player
+
+import (
+ "fmt"
+ "regexp"
+ "unicode"
+)
+
+var validNameRE = regexp.MustCompile(`^[A-Za-z0-9 ]{1,30}$`)
+
+func ValidName(name string) error {
+ if !validNameRE.MatchString(name) {
+ return fmt.Errorf("name must be 1-30 alphanumeric characters or spaces")
+ }
+ return nil
+}
+
+func ValidPassword(pw string) error {
+ if len(pw) > 128 {
+ return fmt.Errorf("Password too long.")
+ }
+ return nil
+}
+
+func StripControl(input string) string {
+ runes := make([]rune, 0, len(input))
+ for _, r := range input {
+ if r == '\n' || r == '\t' {
+ continue
+ }
+ if unicode.IsControl(r) {
+ continue
+ }
+ runes = append(runes, r)
+ }
+ return string(runes)
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 9dfec59..59c8a20 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -33,8 +33,6 @@ type MobDef struct {
Protected bool `yaml:"protected"`
Unique bool `yaml:"unique"`
RespawnTicks int `yaml:"respawn_ticks"`
- WanderRooms []int `yaml:"wander_rooms"`
- WanderInterval int `yaml:"wander_interval"`
Drops DropTable `yaml:"drops"`
}
@@ -70,13 +68,24 @@ const (
)
func WordPrefixMatch(input, name string) bool {
- lower := strings.ToLower(input)
- for _, word := range strings.Fields(name) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
- return true
+ inputWords := strings.Fields(strings.ToLower(input))
+ if len(inputWords) == 0 {
+ return false
+ }
+ nameWords := strings.Fields(strings.ToLower(name))
+ for _, iw := range inputWords {
+ found := false
+ for _, nw := range nameWords {
+ if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
}
}
- return false
+ return true
}
func (m *MobInstance) MatchQuality(input string) int {
@@ -198,20 +207,19 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) {
return
}
inst.IdleDescription = pickIdleDescription(def.IdleDescriptions)
- inst.WanderRooms = def.WanderRooms
- inst.WanderInterval = def.WanderInterval
inst.WanderTickCounter = 0
}
-func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
+func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) {
type defWrapper struct {
def *MobDef
err error
+ rm RoomMob
}
- defs := make([]defWrapper, len(mobIDs))
- for i, defID := range mobIDs {
- d, err := s.LoadDef(defID)
- defs[i] = defWrapper{d, err}
+ defs := make([]defWrapper, len(entries))
+ for i, rm := range entries {
+ d, err := s.LoadDef(rm.ID)
+ defs[i] = defWrapper{d, err, rm}
}
s.mu.Lock()
@@ -220,11 +228,9 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
if dw.err != nil {
continue
}
- defID := mobIDs[i]
+ defID := dw.rm.ID
instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i)
- if inst, exists := s.instances[instID]; exists {
- // Already exists — skip (respawn is handled by timers)
- _ = inst
+ if _, exists := s.instances[instID]; exists {
continue
}
inst := &MobInstance{
@@ -242,8 +248,8 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
Protected: dw.def.Protected,
Unique: dw.def.Unique,
RespawnTicks: dw.def.RespawnTicks,
- WanderRooms: dw.def.WanderRooms,
- WanderInterval: dw.def.WanderInterval,
+ WanderRooms: dw.rm.WanderRooms,
+ WanderInterval: dw.rm.WanderInterval,
RoomID: roomID,
HomeRoomID: roomID,
Drops: dw.def.Drops,
diff --git a/internal/world/room.go b/internal/world/room.go
index 58fa82b..46fb876 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -73,10 +73,29 @@ type Room struct {
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`
Spawns []SpawnDef `yaml:"spawns"`
- Mobs []string `yaml:"mobs"`
+ Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
}
+type RoomMob struct {
+ ID string `yaml:"id"`
+ WanderRooms []int `yaml:"wander_rooms"`
+ WanderInterval int `yaml:"wander_interval"`
+}
+
+func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var s string
+ if err := value.Decode(&s); err != nil {
+ return err
+ }
+ rm.ID = s
+ return nil
+ }
+ type raw RoomMob
+ return value.Decode((*raw)(rm))
+}
+
type EnterStep struct {
Message string `yaml:"message"`
Condition *action.Condition `yaml:"condition"`
diff --git a/internal/world/world.go b/internal/world/world.go
index 71fd518..c8270b1 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -61,6 +61,8 @@ type ObjState struct {
WanderRooms []int
WanderInterval int
WanderCounter int
+ SharedMax int
+ SharedTimer int
}
func (w *World) ObjStateKey(roomID int, defID string, index int) string {
@@ -148,6 +150,8 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
RoomID: st.RoomID,
Depleted: st.Depleted,
DepleteTimer: st.DepleteTimer,
+ SharedMax: st.SharedMax,
+ SharedTimer: st.SharedTimer,
})
_ = key
}
@@ -161,21 +165,26 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
}
func wordMatchesObj(lower, defID, objName string) bool {
- // Check against display name (space-separated words)
- for _, word := range strings.Fields(objName) {
- if strings.HasPrefix(strings.ToLower(word), lower) {
- return true
- }
+ if WordPrefixMatch(lower, objName) {
+ return true
}
- // Check defID both as whole and split by underscores
- if strings.HasPrefix(strings.ToLower(defID), lower) {
+ nameWords := strings.Fields(strings.ToLower(objName))
+ inputWords := strings.Fields(strings.ToLower(lower))
+
+ // Check defID as whole (bidirectional)
+ if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) {
return true
}
+ // Check each defID part (split by underscore) against each input word (bidirectional)
for _, part := range strings.Split(defID, "_") {
- if strings.HasPrefix(strings.ToLower(part), lower) {
- return true
+ for _, iw := range inputWords {
+ pl := strings.ToLower(part)
+ if strings.HasPrefix(pl, iw) || strings.HasPrefix(iw, pl) {
+ return true
+ }
}
}
+ _ = nameWords
return false
}
@@ -202,6 +211,30 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int
}
}
+func (w *World) SetObjSharedDeplete(roomID int, defID string, max int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for key, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 {
+ st.SharedMax = max
+ st.SharedTimer = max
+ }
+ _ = key
+ }
+}
+
+func (w *World) AllSharedObjStates() []*ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []*ObjState
+ for _, st := range w.objStates {
+ if st.SharedMax > 0 {
+ out = append(out, st)
+ }
+ }
+ return out
+}
+
func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -239,7 +272,7 @@ func (w *World) LoadRoom(id int) (*Room, error) {
room.Spawns = make([]SpawnDef, 0)
}
if room.Mobs == nil {
- room.Mobs = make([]string, 0)
+ room.Mobs = make([]RoomMob, 0)
}
return &room, nil
}
@@ -305,6 +338,14 @@ func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner
func (w *World) AddGroundItem(roomID int, itemID string, qty int) {
w.mu.Lock()
defer w.mu.Unlock()
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity > 0 && strings.EqualFold(e.itemID, itemID) &&
+ (e.reserveTimer <= 0 || e.reservedFor == "") {
+ e.quantity += qty
+ e.despawnTimer = DropDespawnTicks
+ return
+ }
+ }
e := &groundEntry{
itemID: itemID,
quantity: qty,
@@ -395,14 +436,28 @@ func (w *World) SeedGroundItems(roomID int) {
defer w.mu.Unlock()
for _, s := range room.Spawns {
- e := &groundEntry{
- itemID: s.ItemID,
- quantity: s.Quantity,
- isSpawn: true,
- respawnDelay: s.RespawnTicks,
- respawnQty: s.Quantity,
+ merged := false
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity > 0 && strings.EqualFold(e.itemID, s.ItemID) &&
+ (e.reserveTimer <= 0 || e.reservedFor == "") {
+ e.quantity += s.Quantity
+ e.respawnQty += s.Quantity
+ e.isSpawn = true
+ e.respawnDelay = s.RespawnTicks
+ merged = true
+ break
+ }
+ }
+ if !merged {
+ e := &groundEntry{
+ itemID: s.ItemID,
+ quantity: s.Quantity,
+ isSpawn: true,
+ respawnDelay: s.RespawnTicks,
+ respawnQty: s.Quantity,
+ }
+ w.groundItems[roomID] = append(w.groundItems[roomID], e)
}
- w.groundItems[roomID] = append(w.groundItems[roomID], e)
}
}
@@ -412,12 +467,19 @@ func (w *World) Tick() {
for _, entries := range w.groundItems {
for _, e := range entries {
- if e.respawnTimer > 0 {
- e.respawnTimer--
- if e.respawnTimer <= 0 {
- e.quantity = e.respawnQty
+ if e.respawnTimer > 0 {
+ e.respawnTimer--
+ if e.respawnTimer <= 0 {
+ e.quantity = e.respawnQty
+ for _, other := range entries {
+ if other != e && other.quantity > 0 && strings.EqualFold(other.itemID, e.itemID) &&
+ (other.reserveTimer <= 0 || other.reservedFor == "") {
+ e.quantity += other.quantity
+ other.quantity = 0
+ }
}
}
+ }
if e.despawnTimer > 0 {
e.despawnTimer--
if e.despawnTimer <= 0 {
@@ -439,6 +501,9 @@ func (w *World) Tick() {
if st.DepleteTimer <= 0 {
st.Depleted = false
st.JustRespawned = true
+ if st.SharedMax > 0 {
+ st.SharedTimer = st.SharedMax
+ }
}
}
}