aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-11 04:46:27 -0400
committerhistoria <[not public]>2026-06-11 08:58:37 +0000
commit1b9e2da3b3c438d8dc53d3489725dd5ba0022777 (patch)
tree62264f24420bf4cfaa734942c65cc8dd45ba3d3b /internal/game
parent06c02a697e5daf8832a462de5970dd4c0e13a02c (diff)
downloadthehouseoficarus-1b9e2da3b3c438d8dc53d3489725dd5ba0022777.tar.gz
feat: web client, get/drop updates and fixes
Diffstat (limited to 'internal/game')
-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
26 files changed, 1875 insertions, 832 deletions
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)