aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/action/action.go32
-rw-r--r--internal/action/behavior.go68
-rw-r--r--internal/action/store.go174
-rw-r--r--internal/cmd/registry.go48
-rw-r--r--internal/combat/state.go47
-rw-r--r--internal/game/action.go765
-rw-r--r--internal/game/cmd_attack.go471
-rw-r--r--internal/game/cmd_description.go34
-rw-r--r--internal/game/cmd_drop.go48
-rw-r--r--internal/game/cmd_get.go251
-rw-r--r--internal/game/cmd_look.go403
-rw-r--r--internal/game/cmd_misc.go117
-rw-r--r--internal/game/cmd_move.go122
-rw-r--r--internal/game/cmd_quit.go53
-rw-r--r--internal/game/cmd_ticktest.go21
-rw-r--r--internal/game/cmd_toggle.go54
-rw-r--r--internal/game/game.go2177
-rw-r--r--internal/game/session.go500
-rw-r--r--internal/game/tick.go141
-rw-r--r--internal/game/utils.go114
-rw-r--r--internal/net/server.go1
-rw-r--r--internal/object/item.go8
-rw-r--r--internal/object/object.go8
-rw-r--r--internal/player/player.go48
-rw-r--r--internal/world/mob.go100
-rw-r--r--internal/world/room.go55
-rw-r--r--internal/world/world.go250
27 files changed, 3791 insertions, 2319 deletions
diff --git a/internal/action/action.go b/internal/action/action.go
new file mode 100644
index 0000000..9cd4c0f
--- /dev/null
+++ b/internal/action/action.go
@@ -0,0 +1,32 @@
+package action
+
+type Action struct {
+ Type string
+ TargetID string
+ TargetName string
+ Step int
+ WaitLeft int
+ Data map[string]any
+}
+
+func (a *Action) Advance() bool {
+ if a.WaitLeft > 0 {
+ a.WaitLeft--
+ return false
+ }
+ return true
+}
+
+type DropEntry struct {
+ ItemID string `yaml:"item_id"`
+ Table string `yaml:"table"`
+ Weight int `yaml:"weight"`
+ Quantity int `yaml:"quantity"`
+ Depletes bool `yaml:"depletes"`
+ Message string `yaml:"message"`
+}
+
+type DropTableDef struct {
+ ID string `yaml:"id"`
+ Drops []DropEntry `yaml:"drops"`
+}
diff --git a/internal/action/behavior.go b/internal/action/behavior.go
new file mode 100644
index 0000000..d7f683a
--- /dev/null
+++ b/internal/action/behavior.go
@@ -0,0 +1,68 @@
+package action
+
+type GatherConfig struct {
+ Skill string `yaml:"skill"`
+ Level int `yaml:"level"`
+ BaseWait int `yaml:"base_wait"`
+ Tool string `yaml:"tool"`
+ Success SuccessFormula `yaml:"success"`
+ GatherMsg string `yaml:"gather_message"`
+ FailMsg string `yaml:"fail_message"`
+ Drops []DropEntry `yaml:"drops"`
+ DepleteDelay int `yaml:"deplete_delay"`
+ RespawnMsg string `yaml:"respawn_message"`
+ RespawnBroadcast string `yaml:"respawn_broadcast"`
+}
+
+type SuccessFormula struct {
+ Base float64 `yaml:"base"`
+ PerLevel float64 `yaml:"per_level"`
+ Cap float64 `yaml:"cap"`
+}
+
+type TalkConfig struct {
+ Nodes map[string]TalkNode `yaml:"nodes"`
+}
+
+type TalkNode struct {
+ Message string `yaml:"message"`
+ Options []TalkOption `yaml:"options"`
+ Action *NodeAction `yaml:"action"`
+}
+
+type TalkOption struct {
+ Text string `yaml:"text"`
+ Goto string `yaml:"goto"`
+ End bool `yaml:"end"`
+ Condition *Condition `yaml:"condition"`
+}
+
+type NodeAction struct {
+ SetFlags map[string]any `yaml:"set_flags"`
+ GiveItem string `yaml:"give_item"`
+ TakeItem string `yaml:"take_item"`
+}
+
+type Condition struct {
+ Flag string `yaml:"flag"`
+ Value any `yaml:"value"`
+ Not bool `yaml:"not"`
+ HasItem string `yaml:"has_item"`
+}
+
+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"`
+ Success *SuccessFormula `yaml:"success"`
+ Skill string `yaml:"skill"`
+ Level int `yaml:"level"`
+}
+
+type ToggleConfig struct {
+ Message string `yaml:"message"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ Check *Condition `yaml:"check"`
+}
diff --git a/internal/action/store.go b/internal/action/store.go
new file mode 100644
index 0000000..fd58281
--- /dev/null
+++ b/internal/action/store.go
@@ -0,0 +1,174 @@
+package action
+
+import (
+ "fmt"
+ "math/rand"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Store struct {
+ dataDir string
+ mu sync.Mutex
+ cache map[string]*RawBehavior
+}
+
+type RawBehavior struct {
+ ID string
+ Type string
+ Raw map[string]any
+}
+
+func NewStore(dataDir string) *Store {
+ return &Store{
+ dataDir: dataDir,
+ cache: make(map[string]*RawBehavior),
+ }
+}
+
+func (s *Store) Load(id string) (*RawBehavior, error) {
+ s.mu.Lock()
+ if b, ok := s.cache[id]; ok {
+ s.mu.Unlock()
+ return b, nil
+ }
+ s.mu.Unlock()
+
+ path := filepath.Join(s.dataDir, "behaviors", id+".yaml")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read behavior %s: %w", id, err)
+ }
+
+ var raw map[string]any
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("parse behavior %s: %w", id, err)
+ }
+
+ rb := &RawBehavior{ID: id, Raw: raw}
+ if t, ok := raw["type"].(string); ok {
+ rb.Type = t
+ }
+ if rid, ok := raw["id"].(string); ok {
+ rb.ID = rid
+ }
+
+ s.mu.Lock()
+ s.cache[id] = rb
+ s.mu.Unlock()
+ return rb, nil
+}
+
+func unmarshalRaw[T any](rb *RawBehavior, expectedType string) (*T, error) {
+ if rb.Type != expectedType {
+ return nil, fmt.Errorf("behavior %s is type %s, expected %s", rb.ID, rb.Type, expectedType)
+ }
+ var cfg T
+ raw, _ := yaml.Marshal(rb.Raw)
+ if err := yaml.Unmarshal(raw, &cfg); err != nil {
+ return nil, fmt.Errorf("parse %s config %s: %w", expectedType, rb.ID, err)
+ }
+ return &cfg, nil
+}
+
+func (s *Store) LoadGather(id string) (*GatherConfig, error) {
+ rb, err := s.Load(id)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalRaw[GatherConfig](rb, "gather")
+}
+
+func (s *Store) LoadTalk(id string) (*TalkConfig, error) {
+ rb, err := s.Load(id)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalRaw[TalkConfig](rb, "talk")
+}
+
+func (s *Store) LoadUse(id string) (*UseConfig, error) {
+ rb, err := s.Load(id)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalRaw[UseConfig](rb, "use")
+}
+
+func (s *Store) LoadToggle(id string) (*ToggleConfig, error) {
+ rb, err := s.Load(id)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalRaw[ToggleConfig](rb, "toggle")
+}
+
+func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 {
+ chance := cfg.Base + float64(level-requiredLevel)*cfg.PerLevel
+ if chance > cfg.Cap {
+ chance = cfg.Cap
+ }
+ if chance < 0 {
+ chance = 0
+ }
+ return chance
+}
+
+func (s *Store) LoadDropTable(id string) (*DropTableDef, error) {
+ path := filepath.Join(s.dataDir, "drops", id+".yaml")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read drop table %s: %w", id, err)
+ }
+ var dt DropTableDef
+ if err := yaml.Unmarshal(data, &dt); err != nil {
+ return nil, fmt.Errorf("parse drop table %s: %w", id, err)
+ }
+ return &dt, nil
+}
+
+func (s *Store) ResolveDrop(drops []DropEntry) *DropEntry {
+ if len(drops) == 0 {
+ return nil
+ }
+ total := 0
+ for _, d := range drops {
+ total += d.Weight
+ }
+ if total <= 0 {
+ return nil
+ }
+ roll := rand.Intn(total)
+ cumulative := 0
+ for i := range drops {
+ cumulative += drops[i].Weight
+ if roll < cumulative {
+ if drops[i].Table != "" {
+ sub, err := s.LoadDropTable(drops[i].Table)
+ if err == nil {
+ if resolved := s.ResolveDrop(sub.Drops); resolved != nil {
+ qty := drops[i].Quantity
+ if qty <= 0 {
+ qty = resolved.Quantity
+ }
+ if qty <= 0 {
+ qty = 1
+ }
+ return &DropEntry{
+ ItemID: resolved.ItemID,
+ Quantity: qty,
+ Depletes: drops[i].Depletes,
+ Message: drops[i].Message,
+ }
+ }
+ }
+ return nil
+ }
+ return &drops[i]
+ }
+ }
+ return &drops[0]
+}
diff --git a/internal/cmd/registry.go b/internal/cmd/registry.go
deleted file mode 100644
index e056469..0000000
--- a/internal/cmd/registry.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "strings"
-)
-
-type Handler func(args []string)
-
-type Command struct {
- Name string
- Aliases []string
- Handler Handler
- MinArgs int
-}
-
-type Registry struct {
- cmds map[string]Command
-}
-
-func NewRegistry() *Registry {
- return &Registry{cmds: make(map[string]Command)}
-}
-
-func (r *Registry) Register(cmd Command) {
- r.cmds[cmd.Name] = cmd
- for _, a := range cmd.Aliases {
- r.cmds[a] = cmd
- }
-}
-
-func (r *Registry) Dispatch(input string) error {
- parts := strings.Fields(input)
- if len(parts) == 0 {
- return nil
- }
- name := strings.ToLower(parts[0])
- cmd, ok := r.cmds[name]
- if !ok {
- return fmt.Errorf("unknown command: %s", name)
- }
- args := parts[1:]
- if len(args) < cmd.MinArgs {
- return fmt.Errorf("usage: %s requires at least %d arguments", cmd.Name, cmd.MinArgs)
- }
- cmd.Handler(args)
- return nil
-}
diff --git a/internal/combat/state.go b/internal/combat/state.go
index 0d8de45..43b693b 100644
--- a/internal/combat/state.go
+++ b/internal/combat/state.go
@@ -3,12 +3,10 @@ package combat
import "sync"
type State struct {
- PlayerName string
- MobID string
- MobAttacks int // attacks mob has made (3-hit flee rule)
- PlayerDamage int // total damage player dealt this combat
- Active bool
- LockedTicks int // ticks remaining before player can move
+ PlayerName string
+ MobID string
+ Active bool
+ LockedTicks int
}
var (
@@ -59,32 +57,6 @@ func GetMobTarget(mobID string) string {
return mobTargets[mobID]
}
-func RecordMobAttack(playerName string) {
- mu.Lock()
- defer mu.Unlock()
- if state, ok := combatants[playerName]; ok {
- state.MobAttacks++
- }
-}
-
-func CanFlee(playerName string) bool {
- mu.Lock()
- defer mu.Unlock()
- state, ok := combatants[playerName]
- if !ok {
- return true
- }
- return state.MobAttacks >= 3
-}
-
-func RecordPlayerDamage(playerName string, dmg int) {
- mu.Lock()
- defer mu.Unlock()
- if state, ok := combatants[playerName]; ok {
- state.PlayerDamage += dmg
- }
-}
-
func TickCombat() {
mu.Lock()
defer mu.Unlock()
@@ -95,13 +67,4 @@ func TickCombat() {
}
}
-func GetTotalDamage(playerName string) int {
- mu.Lock()
- defer mu.Unlock()
- if state, ok := combatants[playerName]; ok {
- d := state.PlayerDamage
- state.PlayerDamage = 0
- return d
- }
- return 0
-}
+
diff --git a/internal/game/action.go b/internal/game/action.go
new file mode 100644
index 0000000..029fec3
--- /dev/null
+++ b/internal/game/action.go
@@ -0,0 +1,765 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "sort"
+ "strconv"
+ "strings"
+
+ "thirdcollapse/internal/action"
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+func (g *Game) StartAction(sess *net.Session, verb, target string) {
+ p := sess.Player.(*player.Player)
+
+ verb = normalizeVerb(verb)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't do that during combat!")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ lower := strings.ToLower(target)
+ instanceIdx := -1
+ if dotPos := strings.Index(lower, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 {
+ instanceIdx = n - 1
+ lower = lower[dotPos+1:]
+ }
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, lower)
+ sort.Slice(instances, func(i, j int) bool {
+ return instances[i].Index < instances[j].Index
+ })
+
+ var obj *object.ObjectDef
+ var mob *world.MobInstance
+ var behaviorID string
+
+ if len(instances) > 0 {
+ var chosen *world.ObjState
+ if instanceIdx >= 0 && instanceIdx < len(instances) {
+ chosen = &instances[instanceIdx]
+ } else {
+ for i := range instances {
+ if !instances[i].Depleted {
+ chosen = &instances[i]
+ break
+ }
+ }
+ if chosen == nil {
+ chosen = &instances[0]
+ }
+ }
+ if chosen == nil {
+ sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
+ return
+ }
+ var err error
+ obj, err = g.ObjectStore.Load(chosen.DefID)
+ if err != nil || obj.BehaviorID == "" {
+ sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name))
+ return
+ }
+ behaviorID = obj.BehaviorID
+ } else {
+ mobs := g.MobStore.MobsInRoom(p.RoomID)
+ var candidates []*world.MobInstance
+ for _, m := range mobs {
+ if m.BehaviorID == "" {
+ continue
+ }
+ if q := m.MatchQuality(lower); q >= world.MatchPrefix {
+ candidates = append(candidates, m)
+ }
+ }
+ if len(candidates) == 0 {
+ sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
+ return
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].InstanceID < candidates[j].InstanceID
+ })
+ if instanceIdx >= 0 && instanceIdx < len(candidates) {
+ mob = candidates[instanceIdx]
+ } else if len(candidates) == 1 {
+ mob = candidates[0]
+ } else {
+ sess.WriteLine("Which one?")
+ return
+ }
+ behaviorID = mob.BehaviorID
+ }
+
+ bh, err := g.BehaviorStore.Load(behaviorID)
+ if err != nil {
+ if obj != nil {
+ sess.WriteLine(fmt.Sprintf("Something is wrong with the %s.", obj.Name))
+ } else {
+ sess.WriteLine("Something is wrong with that.")
+ }
+ return
+ }
+
+ if bh.Type != verb {
+ if obj != nil {
+ sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You can't %s that.", verb))
+ }
+ return
+ }
+
+ switch bh.Type {
+ case "gather":
+ if obj == nil {
+ 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
+ })
+ var st *world.ObjState
+ for i := range chosenObj {
+ if !chosenObj[i].Depleted {
+ st = &chosenObj[i]
+ break
+ }
+ }
+ if st == nil && len(chosenObj) > 0 {
+ st = &chosenObj[0]
+ }
+ if st == nil {
+ sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
+ return
+ }
+ g.startGather(sess, p, obj, st)
+ case "talk":
+ if mob != nil {
+ g.startMobTalk(sess, p, mob)
+ } else {
+ g.startTalk(sess, p, obj)
+ }
+ case "use":
+ g.startUse(sess, p, obj)
+ case "toggle":
+ g.startToggle(sess, p, obj)
+ default:
+ sess.WriteLine(fmt.Sprintf("You can't %s that.", verb))
+ }
+}
+
+func (g *Game) CancelAction(p *player.Player) {
+ p.Action = nil
+}
+
+func (g *Game) AdvanceActions() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p.Action == nil {
+ continue
+ }
+ if !p.Action.Advance() {
+ continue
+ }
+ switch p.Action.Type {
+ case "gather":
+ g.advanceGather(sess, p)
+ case "use":
+ g.advanceUse(sess, p)
+ }
+ }
+}
+
+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
+ }
+
+ for i, opt := range node.Options {
+ if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue
+ }
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, 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
+ }
+ 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)
+ }
+ }
+}
+
+func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
+ p, _ := sess.Player.(*player.Player)
+ if c.Flag != "" {
+ val, ok := g.WorldFlags[c.Flag]
+ if c.Not {
+ return !ok || val != c.Value
+ }
+ return ok && val == c.Value
+ }
+ if c.HasItem != "" {
+ has := p != nil && p.HasItem(c.HasItem)
+ if c.Not {
+ return !has
+ }
+ return has
+ }
+ 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) CheckExitCondition(c *world.ExitCondition) bool {
+ if c.Flag != "" {
+ val, ok := g.WorldFlags[c.Flag]
+ if c.Not {
+ return !ok || val != c.Value
+ }
+ return ok && val == c.Value
+ }
+ return true
+}
+
+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.CheckExitCondition(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/cmd_attack.go b/internal/game/cmd_attack.go
new file mode 100644
index 0000000..ebf88ac
--- /dev/null
+++ b/internal/game/cmd_attack.go
@@ -0,0 +1,471 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+func (g *Game) doAttack(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You are already in combat!")
+ return
+ }
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ mob := g.findMob(sess, input, p.RoomID)
+ if mob == nil {
+ return
+ }
+
+ if mob.HP <= 0 {
+ sess.WriteLine("That is already dead.")
+ return
+ }
+
+ if mob.Protected {
+ sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true)))
+ return
+ }
+
+ if combat.IsMobInCombat(mob.InstanceID) {
+ sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
+ return
+ }
+
+ g.startCombat(sess, p, mob)
+}
+
+func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance {
+ lower := strings.ToLower(input)
+ mobs := g.MobStore.MobsInRoom(roomID)
+
+ idx := -1
+ name := lower
+ if dotPos := strings.Index(lower, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 {
+ idx = n
+ name = lower[dotPos+1:]
+ }
+ }
+
+ var exact []*world.MobInstance
+ var prefix []*world.MobInstance
+ for _, m := range mobs {
+ q := m.MatchQuality(name)
+ if q == world.MatchExact {
+ exact = append(exact, m)
+ } else if q == world.MatchPrefix {
+ prefix = append(prefix, m)
+ }
+ }
+
+ candidates := exact
+ if len(candidates) == 0 {
+ candidates = prefix
+ }
+
+ if len(candidates) == 0 {
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+ return nil
+ }
+
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].InstanceID < candidates[j].InstanceID
+ })
+
+ if idx > 0 {
+ if idx-1 < len(candidates) {
+ return candidates[idx-1]
+ }
+ return nil
+ }
+
+ if len(candidates) == 1 {
+ return candidates[0]
+ }
+
+ seen := make(map[string]bool)
+ for _, m := range candidates {
+ seen[mobDisplayName(m, false)] = true
+ }
+ if len(seen) > 1 {
+ sess.WriteLine("Which one?")
+ return nil
+ }
+ return candidates[0]
+}
+
+func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ g.cancelRest(p.Name)
+
+ combat.EnterCombat(p.Name, mob.InstanceID)
+
+ playerSpeed := g.playerWeaponSpeed(p)
+
+ attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+ var styleParts []string
+ if attBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus))
+ }
+ if strBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus))
+ }
+ if defBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus))
+ }
+ styleStr := ""
+ if len(styleParts) > 0 {
+ styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
+ }
+ sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr))
+
+ g.Ticks.Subscribe(playerSpeed, func() bool {
+ cs := combat.GetCombat(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ g.playerAttack(sess, p, currentMob)
+ if currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+
+ g.Ticks.Subscribe(mob.Speed, func() bool {
+ cs := combat.GetCombat(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ return false
+ }
+ g.mobAttack(sess, p, currentMob)
+ if p.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+}
+
+func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
+
+ equipAtt := 0
+ equipStr := 0
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil {
+ equipAtt = def.Stats.AttackBonus
+ equipStr = def.Stats.StrengthBonus
+ }
+ }
+
+ attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
+ defRoll := combat.DefenseRoll(mob.Defense, 0, 0)
+
+ if combat.HitCheck(attRoll, defRoll) {
+ maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr)
+ dmg := combat.RollDamage(maxHit)
+
+ mob.HP -= dmg
+ if mob.HP < 0 {
+ mob.HP = 0
+ }
+ if mob.HP < mob.MaxHP && mob.HP > 0 {
+ mob.StartRegen()
+ }
+ gains := g.awardCombatXP(p, dmg)
+
+ mobName := mobDisplayName(mob, true)
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg))
+ if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w {
+ w = w2
+ }
+ g.combatPadWidth = w
+ prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)
+ hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP)
+ line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)
+ if p.Toggles["xpdrops"] && len(gains) > 0 {
+ var parts []string
+ for _, gain := range gains {
+ parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
+ }
+ line += " (" + strings.Join(parts, ", ") + ")"
+ }
+ sess.WriteLine(line)
+ } else {
+ sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))
+ }
+}
+
+func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+
+ equipDef := 0
+ for _, itemID := range p.Equipment {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil {
+ equipDef += def.Stats.DefenseBonus
+ }
+ }
+
+ attRoll := combat.AttackRoll(mob.Attack, 0, 0)
+ defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef)
+
+ if combat.HitCheck(attRoll, defRoll) {
+ maxHit := combat.MaxHit(mob.Strength, 0, 0)
+ dmg := combat.RollDamage(maxHit)
+
+ p.HP -= dmg
+ if p.HP < 0 {
+ p.HP = 0
+ }
+ p.StartRegen()
+ g.AccountStore.SaveCharacter(p)
+
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ mobName := mobDisplayName(mob, true)
+ w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg))
+ if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w {
+ w = w2
+ }
+ g.combatPadWidth = w
+ prefix := fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)
+ hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP())
+ sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart))
+ } else {
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %s misses you.", attacker))
+ }
+}
+
+func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ g.combatPadWidth = 0
+ combat.LeaveCombat(p.Name)
+
+ if p.HP <= 0 {
+ sess.WriteLine("\nOh dear, you are dead!")
+ g.dropItemsOnDeath(p)
+ p.HP = p.MaxHP()
+ p.RoomID = 1
+ g.AccountStore.SaveCharacter(p)
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+ g.doLook(sess)
+ return
+ }
+
+ if mob != nil && mob.HP <= 0 {
+ sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other != sess && other.Player != nil {
+ other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob)))
+ }
+ }
+ }
+
+ if mob.Drops.Remains != "" {
+ g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
+ def, _ := g.ItemStore.Load(mob.Drops.Remains)
+ name := mob.Drops.Remains
+ if def != nil {
+ name = def.Name
+ }
+ dropper := mob.Name
+ if !mob.Unique {
+ dropper = "The " + mob.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
+ }
+
+ if len(mob.Drops.Loot) > 0 {
+ entry := g.BehaviorStore.ResolveDrop(mob.Drops.Loot)
+ if entry != nil && entry.ItemID != "" {
+ qty := entry.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+ g.World.AddReservedGroundItem(p.RoomID, entry.ItemID, qty, p.Name)
+ def, _ := g.ItemStore.Load(entry.ItemID)
+ name := entry.ItemID
+ if def != nil {
+ name = def.Name
+ }
+ dropper := mob.Name
+ if !mob.Unique {
+ dropper = "The " + mob.Name
+ }
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, qty, name))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
+ }
+ }
+ }
+
+ respawnTicks := mob.RespawnTicks
+ if respawnTicks <= 0 {
+ respawnTicks = 30
+ }
+ instanceID := mob.InstanceID
+ g.Ticks.Subscribe(respawnTicks, func() bool {
+ g.respawnMob(instanceID)
+ return false
+ })
+ }
+}
+
+func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain {
+ baseXP := dmg * 4
+ var gains []xpGain
+
+ switch p.AttackStyle {
+ case player.Accurate:
+ gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Aggressive:
+ gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Defensive:
+ gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ case player.Balanced:
+ quarter := baseXP / 4
+ gains = []xpGain{
+ {string(player.Attack), quarter},
+ {string(player.Strength), quarter},
+ {string(player.Defense), quarter},
+ {string(player.Hitpoints), quarter},
+ }
+ }
+
+ for _, gain := range gains {
+ p.AddXP(player.SkillName(gain.Skill), gain.XP)
+ }
+ g.AccountStore.SaveCharacter(p)
+ return gains
+}
+
+func (g *Game) dropItemsOnDeath(p *player.Player) {
+ roomID := p.RoomID
+
+ if p.Credits > 0 {
+ g.World.AddGroundItem(roomID, "credits", p.Credits)
+ p.Credits = 0
+ }
+
+ var items []deathDrop
+
+ for slot, inv := range p.Inventory {
+ if inv == nil || inv.Quantity <= 0 {
+ continue
+ }
+ val := 0
+ if def, err := g.ItemStore.Load(inv.ItemID); err == nil {
+ val = def.Value * inv.Quantity
+ }
+ items = append(items, deathDrop{
+ itemID: inv.ItemID,
+ quantity: inv.Quantity,
+ totalVal: val,
+ invSlot: slot,
+ })
+ }
+
+ for eqSlot, itemID := range p.Equipment {
+ val := 0
+ if def, err := g.ItemStore.Load(itemID); err == nil {
+ val = def.Value
+ }
+ items = append(items, deathDrop{
+ itemID: itemID,
+ quantity: 1,
+ totalVal: val,
+ isEquip: true,
+ equipSlot: eqSlot,
+ })
+ }
+
+ if len(items) <= 3 {
+ return
+ }
+
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].totalVal > items[j].totalVal
+ })
+
+ for i := 3; i < len(items); i++ {
+ it := items[i]
+ if it.isEquip {
+ delete(p.Equipment, it.equipSlot)
+ } else {
+ p.SetInvSlot(it.invSlot, nil)
+ }
+ g.World.AddGroundItem(roomID, it.itemID, it.quantity)
+ }
+}
+
+func (g *Game) stopCombat(playerName string) {
+ if cs := combat.GetCombat(playerName); cs != nil {
+ combat.LeaveCombat(playerName)
+ }
+}
+
+func (g *Game) respawnMob(instanceID string) {
+ inst := g.MobStore.GetInstance(instanceID)
+ if inst == nil {
+ return
+ }
+ homeRoom := inst.HomeRoomID
+ inst.RoomID = homeRoom
+ inst.HP = inst.MaxHP
+ g.MobStore.RollIdleDescription(inst)
+
+ if g.Hub != nil {
+ for _, sess := range g.Hub.PlayersInRoom(homeRoom) {
+ if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] {
+ sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters the area.", mobDisplayName(inst, false), mobCombatLevel(inst)))
+ }
+ }
+ }
+}
+
+func (g *Game) playerWeaponSpeed(p *player.Player) int {
+ if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
+ def, err := g.ItemStore.Load(itemID)
+ if err == nil && def.Speed > 0 {
+ return def.Speed
+ }
+ }
+ return 5
+}
diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go
new file mode 100644
index 0000000..6875840
--- /dev/null
+++ b/internal/game/cmd_description.go
@@ -0,0 +1,34 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doDescription(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLine("")
+ if p.Description != "" {
+ sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description))
+ } else {
+ sess.WriteLine("You don't have a description set.")
+ }
+ sess.WriteLine("")
+ sess.Write("Enter a new description (or press enter to keep current): ")
+ sess.State = net.StateChangeDescription
+}
+
+func (g *Game) handleDescriptionChange(sess *net.Session, input string) {
+ if input != "" {
+ p := sess.Player.(*player.Player)
+ p.Description = input
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("Description set to: %s", input))
+ } else {
+ sess.WriteLine("Description left unchanged.")
+ }
+ sess.State = net.StateGame
+ sess.Write("\r\n> ")
+}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
new file mode 100644
index 0000000..511688a
--- /dev/null
+++ b/internal/game/cmd_drop.go
@@ -0,0 +1,48 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doDrop(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ matches := g.findInventoryMatches(input, p)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input))
+ 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
+ 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
+ } else {
+ p.SetInvSlot(slotIdx, nil)
+ }
+
+ g.World.AddGroundItem(p.RoomID, itemID, qty)
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
new file mode 100644
index 0000000..bce8ddc
--- /dev/null
+++ b/internal/game/cmd_get.go
@@ -0,0 +1,251 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doGet(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ 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)
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ sess.WriteLine("Your inventory is full.")
+ 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 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, qty, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
+ return
+ }
+ _ = removed
+ slot.Quantity += qty
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", def.Name, slot.Quantity))
+ 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)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+}
+
+func (g *Game) doGetAll(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ ground := g.World.GroundItems(p.RoomID)
+ if len(ground) == 0 {
+ sess.WriteLine("There's nothing on the ground to pick up.")
+ return
+ }
+
+ var picked []string
+ for itemID, qty := range ground {
+ if qty <= 0 {
+ continue
+ }
+ if itemID == "credits" {
+ taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
+ if !ok {
+ continue
+ }
+ p.Credits += taken
+ if taken == 1 {
+ picked = append(picked, "1 credit")
+ } else {
+ picked = append(picked, fmt.Sprintf("%d credits", taken))
+ }
+ continue
+ }
+
+ def, _ := g.ItemStore.Load(itemID)
+ for qty > 0 {
+ if def != nil && def.Stackable {
+ stacked := false
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot != nil && slot.ItemID == itemID {
+ _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
+ if !ok {
+ qty = 0
+ stacked = true
+ break
+ }
+ slot.Quantity += qty
+ picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity))
+ stacked = true
+ qty = 0
+ break
+ }
+ }
+ if stacked {
+ break
+ }
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ if len(picked) > 0 {
+ sess.WriteLine("Inventory full. Picked up so far:")
+ innerPickupReport(sess, picked)
+ } else {
+ sess.WriteLine("Your inventory is full.")
+ }
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+ _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
+ if !ok {
+ break
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ picked = append(picked, name)
+ break
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ if len(picked) > 0 {
+ sess.WriteLine("Inventory full. Picked up so far:")
+ innerPickupReport(sess, picked)
+ } else {
+ sess.WriteLine("Your inventory is full.")
+ }
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ take := 1
+ _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name)
+ if !ok {
+ break
+ }
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take})
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ picked = append(picked, name)
+ qty -= take
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+
+ if len(picked) == 0 {
+ sess.WriteLine("Your inventory is full.")
+ } else {
+ sess.Write("You pick up: ")
+ innerPickupReport(sess, picked)
+ }
+}
+
+func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
+ qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
+ if !ok {
+ sess.WriteLine("That's not yours!")
+ return
+ }
+ if qty <= 0 {
+ return
+ }
+ p.Credits += qty
+ g.AccountStore.SaveCharacter(p)
+ if qty == 1 {
+ sess.WriteLine("You pick up 1 credit.")
+ } else {
+ sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits))
+ }
+}
+
+func (g *Game) findGroundMatches(input string, roomID int) []itemMatch {
+ ground := g.World.GroundItems(roomID)
+ var matches []itemMatch
+ for itemID := range ground {
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil {
+ continue
+ }
+ if def.MatchesName(input) {
+ matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1})
+ }
+ }
+ return matches
+}
+
+func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch {
+ var matches []itemMatch
+ 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
+ }
+ if def.MatchesName(input) {
+ matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i})
+ }
+ }
+ return matches
+}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
new file mode 100644
index 0000000..a544ec1
--- /dev/null
+++ b/internal/game/cmd_look.go
@@ -0,0 +1,403 @@
+package game
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+func (g *Game) doLook(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("You are in a void.")
+ return
+ }
+
+ sess.WriteLines(
+ "",
+ room.Name,
+ room.Description,
+ )
+
+ mobs := g.MobStore.MobsInRoom(p.RoomID)
+ if len(mobs) > 0 {
+ sort.Slice(mobs, func(i, j int) bool {
+ iDamaged := mobs[i].HP < mobs[i].MaxHP
+ jDamaged := mobs[j].HP < mobs[j].MaxHP
+ if iDamaged != jDamaged {
+ return iDamaged
+ }
+ return mobs[i].InstanceID < mobs[j].InstanceID
+ })
+ sess.WriteLine("")
+ for _, m := range mobs {
+ hp := ""
+ if m.HP < m.MaxHP {
+ hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP)
+ }
+ var desc string
+ if combat.IsMobInCombat(m.InstanceID) {
+ def, err := g.MobStore.LoadDef(m.DefID)
+ if err == nil && len(def.CombatDescriptions) > 0 {
+ target := combat.GetMobTarget(m.InstanceID)
+ pattern := def.CombatDescriptions[randInt(len(def.CombatDescriptions))]
+ desc = " " + fmt.Sprintf(pattern, target)
+ }
+ } else if m.IdleDescription != "" {
+ desc = fmt.Sprintf(" %s", m.IdleDescription)
+ }
+ displayName := m.Name
+ if !m.Unique {
+ displayName = "A " + m.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc))
+ }
+ }
+
+ objs := g.World.AllObjInstances(p.RoomID)
+ if len(objs) > 0 {
+ sess.WriteLine("")
+ grouped := make(map[string]int)
+ var order []string
+ for _, o := range objs {
+ if _, ok := grouped[o.DefID]; !ok {
+ order = append(order, o.DefID)
+ }
+ grouped[o.DefID]++
+ }
+ for _, objID := range order {
+ count := grouped[objID]
+ def, err := g.ObjectStore.Load(objID)
+ if err != nil {
+ continue
+ }
+
+ var activeIdxs, depletedIdxs []int
+ 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 len(activeIdxs) > 0 {
+ if len(activeIdxs) == 1 {
+ sess.Write(fmt.Sprintf(" A %s is here.", def.Name))
+ } else {
+ sess.Write(fmt.Sprintf(" %d %ss are here.", len(activeIdxs), def.Name))
+ }
+ if count > 1 {
+ sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(activeIdxs)))
+ } else {
+ sess.WriteLine("")
+ }
+ }
+ 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))
+ }
+ sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(depletedIdxs)))
+ }
+ }
+ }
+
+ ground := g.World.GroundItemsDetailed(p.RoomID)
+ if len(ground) > 0 {
+ sess.WriteLine("")
+ sess.WriteLine("On the ground:")
+ for _, info := range ground {
+ def, err := g.ItemStore.Load(info.ItemID)
+ name := info.ItemID
+ if err == nil {
+ name = def.Name
+ }
+ line := ""
+ if info.Quantity > 1 {
+ line = fmt.Sprintf(" %d x %s", info.Quantity, name)
+ } else {
+ line = fmt.Sprintf(" %s", name)
+ }
+ if info.ReservedFor != "" {
+ if p.Toggles["reserve"] {
+ line += fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer)
+ } else {
+ line += " (reserved)"
+ }
+ }
+ sess.WriteLine(line)
+ }
+ }
+
+ if len(room.Exits) > 0 {
+ sess.WriteLine("")
+ if p.Toggles["exits"] {
+ sess.WriteLine("Exits:")
+ for _, dir := range world.ExitOrder {
+ exitDef, ok := room.Exits[dir]
+ if !ok {
+ continue
+ }
+ targetRoom, err := g.World.LoadRoom(exitDef.Room)
+ targetName := fmt.Sprintf("#%d", exitDef.Room)
+ if err == nil {
+ targetName = targetRoom.Name
+ }
+ if exitDef.Condition != nil && !g.CheckExitCondition(exitDef.Condition) {
+ targetName += " (blocked)"
+ }
+ sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
+ }
+ } else {
+ sess.Write("Exits: ")
+ first := true
+ for _, dir := range world.ExitOrder {
+ if _, ok := room.Exits[dir]; ok {
+ if !first {
+ sess.Write(", ")
+ }
+ sess.Write(string(dir))
+ first = false
+ }
+ }
+ sess.WriteLine("")
+ }
+ }
+
+ others := g.Hub.PlayersInRoom(p.RoomID)
+ for _, other := range others {
+ if other != sess && other.Player != nil {
+ op := other.Player.(*player.Player)
+ line := fmt.Sprintf("\n%s is here", op.Name)
+ if cs := combat.GetCombat(op.Name); cs != nil {
+ if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 {
+ name := mobDisplayName(mob, false)
+ if idx := mobInstanceIdx(mob, g.MobStore.MobsInRoom(p.RoomID)); idx > 0 {
+ name += fmt.Sprintf(" [%d]", idx)
+ }
+ line += fmt.Sprintf(" (fighting %s)", name)
+ }
+ } else if desc := actionDesc(op); desc != "" {
+ line += ", " + desc
+ }
+ sess.WriteLine(line + ".")
+ }
+ }
+}
+
+func (g *Game) doLookTarget(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ lower := strings.ToLower(input)
+
+ if exitDir := g.World.ResolveExit(lower); exitDir != "" {
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("You can't see anything that way.")
+ return
+ }
+ exitDef, ok := room.Exits[exitDir]
+ if !ok {
+ sess.WriteLine("You can't see anything that way.")
+ return
+ }
+ g.World.SeedGroundItems(exitDef.Room)
+ g.seedRoomMobs(exitDef.Room)
+ origRoom := p.RoomID
+ p.RoomID = exitDef.Room
+ g.doLook(sess)
+ p.RoomID = origRoom
+ return
+ }
+
+ var best *world.MobInstance
+ bestQ := world.MatchNone
+ for _, m := range g.MobStore.MobsInRoom(p.RoomID) {
+ q := m.MatchQuality(lower)
+ if q > bestQ {
+ bestQ = q
+ best = m
+ }
+ }
+ if best != nil {
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)),
+ )
+ if best.IdleDescription != "" {
+ sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
+ }
+ sess.WriteLines(
+ "",
+ fmt.Sprintf(" Attack: %d", best.Attack),
+ fmt.Sprintf(" Strength: %d", best.Strength),
+ fmt.Sprintf(" Defense: %d", best.Defense),
+ fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP),
+ )
+ return
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, lower)
+ if len(instances) > 0 {
+ st := &instances[0]
+ def, _ := g.ObjectStore.Load(st.DefID)
+ sess.WriteLine("")
+ if len(instances) > 1 {
+ sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name))
+ } else {
+ sess.WriteLine(def.Name)
+ }
+
+ if desc, ok := def.Props["description"].(string); ok && desc != "" {
+ 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))
+ }
+ }
+ }
+ return
+ }
+
+ ground := g.World.GroundItems(p.RoomID)
+ for itemID := range ground {
+ def, err := g.ItemStore.Load(itemID)
+ if err != nil || !def.MatchesName(input) {
+ continue
+ }
+ sess.WriteLines(
+ "",
+ def.Name,
+ fmt.Sprintf(" %s", def.Description),
+ fmt.Sprintf(" Value: %d credits", def.Value),
+ )
+ return
+ }
+
+ 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.MatchesName(input) {
+ continue
+ }
+ sess.WriteLines(
+ "",
+ def.Name,
+ fmt.Sprintf(" %s", def.Description),
+ fmt.Sprintf(" Value: %d credits", def.Value),
+ )
+ return
+ }
+
+ others := g.Hub.PlayersInRoom(p.RoomID)
+ for _, other := range others {
+ if other == sess || other.Player == nil {
+ continue
+ }
+ op := other.Player.(*player.Player)
+ if strings.ToLower(op.Name) != lower {
+ continue
+ }
+ showPlayerInfo(sess, op)
+ return
+ }
+
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+}
+
+func showPlayerInfo(sess *net.Session, p *player.Player) {
+ sess.WriteLines(
+ "",
+ p.Name,
+ fmt.Sprintf(" Combat Level: %d", p.CombatLevel()),
+ fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()),
+ "",
+ )
+
+ for _, s := range player.AllSkills {
+ level := p.Level(s)
+ sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level))
+ }
+
+ sess.WriteLine("")
+ sess.WriteLine(" Equipment:")
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ continue
+ }
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID))
+ }
+
+ if p.Description != "" {
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf(" %s", p.Description))
+ }
+}
+
+func (g *Game) doExits(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil || len(room.Exits) == 0 {
+ sess.WriteLine("There are no exits here.")
+ return
+ }
+ for _, dir := range world.ExitOrder {
+ exitDef, ok := room.Exits[dir]
+ if !ok {
+ continue
+ }
+ targetRoom, err := g.World.LoadRoom(exitDef.Room)
+ targetName := fmt.Sprintf("#%d", exitDef.Room)
+ if err == nil {
+ targetName = targetRoom.Name
+ }
+ sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
+ }
+}
+
+func intsJoin(nums []int) string {
+ var parts []string
+ for _, n := range nums {
+ parts = append(parts, strconv.Itoa(n))
+ }
+ return strings.Join(parts, ",")
+}
+
+func mobInstanceIdx(mob *world.MobInstance, roomMobs []*world.MobInstance) int {
+ var same []*world.MobInstance
+ for _, m := range roomMobs {
+ if m.DefID == mob.DefID {
+ same = append(same, m)
+ }
+ }
+ if len(same) <= 1 {
+ return 0
+ }
+ sort.Slice(same, func(i, j int) bool {
+ return same[i].InstanceID < same[j].InstanceID
+ })
+ for i, m := range same {
+ if m.InstanceID == mob.InstanceID {
+ return i + 1
+ }
+ }
+ return 0
+}
diff --git a/internal/game/cmd_misc.go b/internal/game/cmd_misc.go
new file mode 100644
index 0000000..f56a0ff
--- /dev/null
+++ b/internal/game/cmd_misc.go
@@ -0,0 +1,117 @@
+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)
+ for _, s := range styles {
+ if s == input {
+ p.AttackStyle = player.AttackStyle(s)
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s))
+ 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
new file mode 100644
index 0000000..b95f5c5
--- /dev/null
+++ b/internal/game/cmd_move.go
@@ -0,0 +1,122 @@
+package game
+
+import (
+ "fmt"
+
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doMove(sess *net.Session, dir string) {
+ p := sess.Player.(*player.Player)
+ exitDir := g.World.ResolveExit(dir)
+ if exitDir == "" {
+ sess.WriteLine("Go where?")
+ return
+ }
+
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil {
+ sess.WriteLine("You can't move from here.")
+ return
+ }
+
+ exitDef, ok := room.Exits[exitDir]
+ if !ok {
+ sess.WriteLine("You can't go that way.")
+ return
+ }
+
+ if exitDef.Condition != nil && !g.CheckExitCondition(exitDef.Condition) {
+ msg := exitDef.BlockedMessage
+ if msg == "" {
+ msg = fmt.Sprintf("The way %s is blocked.", exitDir)
+ }
+ sess.WriteLine(msg)
+ return
+ }
+
+ targetID := exitDef.Room
+
+ _, err = g.World.LoadRoom(targetID)
+ if err != nil {
+ sess.WriteLine("That path seems blocked.")
+ return
+ }
+
+ if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 {
+ sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks)))
+ return
+ }
+
+ g.stopCombat(p.Name)
+
+ if p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ oldRoom := p.RoomID
+ p.RoomID = targetID
+ g.AccountStore.SaveCharacter(p)
+
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.ensureRoomObjects(p.RoomID)
+
+ if g.Hub != nil {
+ for _, other := range g.Hub.PlayersInRoom(oldRoom) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir))
+ }
+ }
+ g.Hub.EnterRoom(sess, targetID)
+ for _, other := range g.Hub.PlayersInRoom(targetID) {
+ if other != sess {
+ other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
+ }
+ }
+ }
+
+ sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir))
+ if p.Toggles["description"] {
+ g.doLook(sess)
+ } else {
+ targetRoom, _ := g.World.LoadRoom(targetID)
+ if targetRoom != nil {
+ sess.WriteLine(targetRoom.Name)
+ }
+ }
+
+ g.RunEnterSteps(sess, targetID)
+}
+
+func (g *Game) seedRoomMobs(roomID int) {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil || len(room.Mobs) == 0 {
+ return
+ }
+ g.MobStore.SeedMobs(roomID, room.Mobs)
+}
+
+func (g *Game) ensureRoomObjects(roomID int) {
+ room, err := g.World.LoadRoom(roomID)
+ if err != nil {
+ return
+ }
+ var ids []string
+ for _, obj := range room.Objects {
+ ids = append(ids, obj.ID)
+ }
+ g.World.EnsureObjectStates(roomID, ids)
+ for _, obj := range room.Objects {
+ def, err := g.ObjectStore.Load(obj.ID)
+ if err != nil {
+ continue
+ }
+ g.World.SetObjName(roomID, obj.ID, def.Name)
+ if len(obj.WanderRooms) > 0 {
+ g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval)
+ }
+ }
+}
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
new file mode 100644
index 0000000..a720bff
--- /dev/null
+++ b/internal/game/cmd_quit.go
@@ -0,0 +1,53 @@
+package game
+
+import (
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doQuit(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+
+ if combat.GetCombat(p.Name) != nil {
+ sess.WriteLine("You can't rest during combat!")
+ return
+ }
+
+ g.cancelRest(p.Name)
+
+ ticksLeft := 10
+ id := g.Ticks.Subscribe(1, func() bool {
+ switch ticksLeft {
+ case 10:
+ sess.WriteLine("You sit down to rest...")
+ case 6:
+ sess.WriteLine("You catch your breath...")
+ case 3:
+ sess.WriteLine("You close your eyes...")
+ case 0:
+ g.cancelRest(p.Name)
+ g.AccountStore.SaveCharacter(p)
+ g.charsMu.Lock()
+ delete(g.loggedInChars, p.Name)
+ g.charsMu.Unlock()
+ if g.Hub != nil {
+ g.Hub.LeaveRoom(sess)
+ }
+ sess.Player = nil
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return false
+ }
+ ticksLeft--
+ return true
+ })
+ g.restTimers[p.Name] = id
+}
+
+func (g *Game) cancelRest(playerName string) {
+ if id, ok := g.restTimers[playerName]; ok {
+ g.Ticks.Unsubscribe(id)
+ delete(g.restTimers, playerName)
+ }
+}
diff --git a/internal/game/cmd_ticktest.go b/internal/game/cmd_ticktest.go
new file mode 100644
index 0000000..6cadab7
--- /dev/null
+++ b/internal/game/cmd_ticktest.go
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..19c111c
--- /dev/null
+++ b/internal/game/cmd_toggle.go
@@ -0,0 +1,54 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+var toggles = []struct {
+ Name string
+ Description string
+}{
+ {"description", "Long room descriptions"},
+ {"tinymap", "Mini-map display"},
+ {"xpdrops", "XP drop messages in combat"},
+ {"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"},
+}
+
+func (g *Game) doToggle(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+
+ if input == "" {
+ sess.WriteLine("")
+ for _, t := range toggles {
+ status := "Off"
+ if p.Toggles[t.Name] {
+ status = "On"
+ }
+ sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description))
+ }
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+
+ for _, t := range toggles {
+ if strings.ToLower(input) == t.Name {
+ p.Toggles[t.Name] = !p.Toggles[t.Name]
+ status := "Off"
+ if p.Toggles[t.Name] {
+ status = "On"
+ }
+ sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status))
+ g.AccountStore.SaveCharacter(p)
+ return
+ }
+ }
+ sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input))
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index 528c895..d793516 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -2,13 +2,10 @@ package game
import (
"fmt"
- "math/rand"
- "os"
- "sort"
- "strconv"
"strings"
+ "sync"
- "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/action"
"thirdcollapse/internal/engine"
"thirdcollapse/internal/net"
"thirdcollapse/internal/object"
@@ -17,28 +14,33 @@ import (
)
type Game struct {
- World *world.World
- ObjectStore *object.ObjectStore
- ItemStore *object.ItemStore
- AccountStore *player.AccountStore
- MobStore *world.MobStore
- Hub *net.Hub
- Ticks *engine.Engine
- dataDir string
- restTimers map[string]uint64 // player name -> tick subscription ID
- loggedInChars map[string]*net.Session // character name -> session
+ World *world.World
+ ObjectStore *object.ObjectStore
+ ItemStore *object.ItemStore
+ AccountStore *player.AccountStore
+ MobStore *world.MobStore
+ BehaviorStore *action.Store
+ Hub *net.Hub
+ Ticks *engine.Engine
+ WorldFlags map[string]any
+ dataDir string
+ restTimers map[string]uint64
+ charsMu sync.Mutex
+ loggedInChars map[string]*net.Session
combatPadWidth int
}
func New(dataDir string) *Game {
return &Game{
- World: world.New(dataDir),
- ObjectStore: object.NewObjectStore(dataDir),
- ItemStore: object.NewItemStore(dataDir),
- AccountStore: player.NewAccountStore(dataDir),
- MobStore: world.NewMobStore(dataDir),
- Ticks: engine.New(),
- dataDir: dataDir,
+ World: world.New(dataDir),
+ ObjectStore: object.NewObjectStore(dataDir),
+ ItemStore: object.NewItemStore(dataDir),
+ AccountStore: player.NewAccountStore(dataDir),
+ MobStore: world.NewMobStore(dataDir),
+ BehaviorStore: action.NewStore(dataDir),
+ Ticks: engine.New(),
+ WorldFlags: make(map[string]any),
+ dataDir: dataDir,
restTimers: make(map[string]uint64),
loggedInChars: make(map[string]*net.Session),
}
@@ -48,7 +50,9 @@ func (g *Game) SetHub(hub *net.Hub) {
g.Hub = hub
hub.OnRemove(func(sess *net.Session) {
if p, ok := sess.Player.(*player.Player); ok {
+ g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
+ g.charsMu.Unlock()
}
})
}
@@ -81,508 +85,11 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleGameCommand(sess, input)
case net.StateChangeDescription:
g.handleDescriptionChange(sess, input)
+ case net.StateTalk:
+ g.handleTalkInput(sess, input)
}
}
-func (g *Game) handleAccountName(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
- if name == "" {
- sess.Write("Account name: ")
- return
- }
- actualName, err := g.AccountStore.FindAccount(name)
- if err == nil {
- sess.Account = &net.AccountEntry{Name: actualName}
- sess.State = net.StatePassword
- sess.Write("Password: ")
- } else {
- sess.Account = &net.AccountEntry{Name: name}
- sess.State = net.StateNewAccountPass
- sess.Write("A new visitor to this rock!\nChoose password: ")
- }
-}
-
-func (g *Game) handlePassword(sess *net.Session, input string) {
- if input == "" {
- sess.Write("Password: ")
- return
- }
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err != nil {
- sess.WriteLine("Error loading account.")
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- if !player.CheckPassword(input, acc.PasswordHash) {
- sess.WriteLine("Wrong password.")
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- sess.Account = &net.AccountEntry{
- Name: acc.Name,
- PasswordHash: acc.PasswordHash,
- Characters: acc.Characters,
- }
- g.showMenu(sess)
-}
-
-func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- if input == "" {
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- sess.PendingPass = input
- sess.State = net.StateNewAccountConfirm
- sess.Write("Confirm password: ")
-}
-
-func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- if input == "" {
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- if input != sess.PendingPass {
- sess.WriteLine("Passwords do not match.")
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- hash, err := player.HashPassword(input)
- if err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- acc := &player.Account{
- Name: sess.Account.Name,
- PasswordHash: hash,
- }
- if err := g.AccountStore.SaveAccount(acc); err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- sess.Account = &net.AccountEntry{
- Name: acc.Name,
- PasswordHash: acc.PasswordHash,
- }
- sess.PendingPass = ""
- g.showMenu(sess)
-}
-
-func (g *Game) handleRenameAccount(sess *net.Session, input string) {
- newName := strings.TrimSpace(input)
- if newName == "" {
- sess.Write("New account name: ")
- return
- }
- oldName := sess.Account.Name
- if newName == oldName {
- sess.WriteLine("That's already your account name.")
- g.showMenu(sess)
- return
- }
- if g.AccountStore.AccountExists(newName) {
- sess.WriteLine("An account with that name already exists.")
- sess.Write("New account name: ")
- return
- }
-
- // Rename the account file
- oldPath := g.AccountStore.AccountPath(oldName)
- newPath := g.AccountStore.AccountPath(newName)
- if err := os.Rename(oldPath, newPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err))
- g.showMenu(sess)
- return
- }
-
- sess.Account.Name = newName
- sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName))
- g.showMenu(sess)
-}
-
-func (g *Game) handleRenameChar(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = sess.Account.Characters[idx-1]
- } else {
- sess.PendingChar = name
- }
-
- // Verify the character exists on this account
- found := false
- for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
- found = true
- break
- }
- }
- if !found {
- sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
- g.showMenu(sess)
- return
- }
-
- sess.State = net.StateRenameCharName
- sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar))
- sess.Write("New name: ")
-}
-
-func (g *Game) handleRenameCharName(sess *net.Session, input string) {
- newName := strings.TrimSpace(input)
- oldName := sess.PendingChar
- if newName == "" {
- sess.Write("New name: ")
- return
- }
- if newName == oldName {
- sess.WriteLine("That's already the character's name.")
- g.showMenu(sess)
- return
- }
- if g.AccountStore.CharacterExists(newName) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("New name: ")
- return
- }
-
- // Rename character file
- oldPath := g.AccountStore.CharPath(oldName)
- newPath := g.AccountStore.CharPath(newName)
- if err := os.Rename(oldPath, newPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
- g.showMenu(sess)
- return
- }
-
- // Load and update the character's internal name
- p, _ := g.AccountStore.LoadCharacter(newName)
- if p != nil {
- p.Name = newName
- g.AccountStore.SaveCharacter(p)
- }
-
- // Update account character list
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- for i, c := range acc.Characters {
- if c == oldName {
- acc.Characters[i] = newName
- break
- }
- }
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
-
- sess.PendingChar = ""
- sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName))
- g.showMenu(sess)
-}
-
-func (g *Game) showDeleteConfirm(sess *net.Session) {
- sess.State = net.StateDeleteChar
- sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar))
-}
-
-func (g *Game) handleDeleteChar(sess *net.Session, input string) {
- // If PendingChar not set, this is the character selection step
- if sess.PendingChar == "" {
- name := strings.TrimSpace(input)
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = sess.Account.Characters[idx-1]
- } else {
- sess.PendingChar = name
- }
- found := false
- for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
- found = true
- break
- }
- }
- if !found {
- sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
- g.showDeleteConfirm(sess)
- return
- }
-
- // Confirmation step
- input = strings.TrimSpace(input)
- expected := "DELETE " + sess.PendingChar
- if strings.ToUpper(input) != strings.ToUpper(expected) {
- sess.WriteLine("Delete cancelled.")
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
-
- // Delete the character file
- charPath := g.AccountStore.CharPath(sess.PendingChar)
- if err := os.Remove(charPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
-
- // Remove from account
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- var newChars []string
- for _, c := range acc.Characters {
- if c != sess.PendingChar {
- newChars = append(newChars, c)
- }
- }
- acc.Characters = newChars
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = newChars
-
- sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar))
- sess.PendingChar = ""
- g.showMenu(sess)
-}
-
-func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- expected := "PURGE " + sess.Account.Name
- if strings.ToUpper(input) != strings.ToUpper(expected) {
- sess.WriteLine("Purge cancelled.")
- g.showMenu(sess)
- return
- }
-
- // Delete all character files
- for _, name := range sess.Account.Characters {
- os.Remove(g.AccountStore.CharPath(name))
- }
-
- // Delete account file
- 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()
-}
-
-func (g *Game) showMenu(sess *net.Session) {
- sess.State = net.StateMenu
- lines := []string{
- "",
- fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name),
- "",
- }
- if len(sess.Account.Characters) > 0 {
- lines = append(lines,
- " (C)onnect character to TC",
- "",
- " (L)ist characters",
- " (R)ename character",
- " (D)elete character",
- )
- }
- lines = append(lines,
- " (N)ew character",
- " (P)urge account",
- " (A)ccount rename",
- " (Q)uit",
- "",
- "INPUT: ",
- )
- sess.WriteLines(lines...)
-}
-
-func (g *Game) handleMenu(sess *net.Session, input string) {
- switch strings.ToLower(strings.TrimSpace(input)) {
- case "":
- sess.Write("INPUT: ")
- case "c":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!")
- sess.Write("\nINPUT: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- g.connectCharacter(sess, sess.Account.Characters[0])
- return
- }
- sess.WriteLine("\nSelect character:")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.State = net.StateNewCharName
- sess.PendingChar = "connect"
- sess.Write("\nChoice: ")
-
- case "l":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)")
- } else {
- sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:")
- for _, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" - %s", name))
- }
- }
- sess.Write("\nINPUT: ")
-
- case "n":
- sess.State = net.StateNewCharName
- sess.PendingChar = ""
- sess.Write("What's your character name?: ")
-
- case "a":
- sess.State = net.StateRenameAccount
- sess.Write("New account name: ")
-
- case "r":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to rename.")
- sess.Write("\nChoice: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- sess.PendingChar = sess.Account.Characters[0]
- sess.State = net.StateRenameCharName
- sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar))
- sess.Write("New name: ")
- return
- }
- sess.State = net.StateRenameChar
- sess.WriteLine("\nRename which character?")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.Write("\n> : ")
-
- case "d":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to delete.")
- sess.Write("\nINPUT: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- sess.PendingChar = sess.Account.Characters[0]
- } else {
- sess.State = net.StateDeleteChar
- sess.WriteLine("\nDelete which character?")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.Write("\n>: ")
- return
- }
- g.showDeleteConfirm(sess)
-
- case "p":
- sess.State = net.StatePurgeAccount
- sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name))
- sess.Write("\n> ")
-
- case "q":
- sess.WriteLine("Later.")
- sess.Conn.Close()
- return
-
- default:
- sess.Write("SYNTAX ERROR\nINPUT: ")
- }
-}
-
-func (g *Game) handleNewCharName(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
-
- // Coming from "c" menu — selecting a character by number
- if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 {
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = ""
- g.connectCharacter(sess, sess.Account.Characters[idx-1])
- return
- }
- sess.WriteLine("Invalid choice.")
- sess.Write("\nChoice: ")
- return
- }
-
- if name == "" {
- sess.Write("Character name: ")
- return
- }
-
- if g.AccountStore.CharacterExists(name) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("Character name: ")
- return
- }
-
- // Create new character
- p := player.New(name)
- p.RoomID = 1 // spawn room
-
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- // Add to account
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- acc.Characters = append(acc.Characters, name)
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
-
- g.connectCharacter(sess, name)
-}
-
-func (g *Game) connectCharacter(sess *net.Session, name string) {
- if existing := g.loggedInChars[name]; existing != nil {
- sess.WriteLine("This character is logged in elsewhere.")
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- p, err := g.AccountStore.LoadCharacter(name)
- if err != nil {
- sess.WriteLine(fmt.Sprintf("Error loading character: %v", err))
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- sess.Player = p
- sess.State = net.StateGame
- g.loggedInChars[name] = sess
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
-
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
-
- g.doLook(sess)
- sess.Write("\r\n> ")
-}
-
func (g *Game) handleGameCommand(sess *net.Session, input string) {
if input == "" {
sess.Write("> ")
@@ -596,7 +103,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
parts := strings.Fields(strings.ToLower(input))
cmd := parts[0]
args := parts[1:]
-
+
switch cmd {
case "get", "take", "grab", "pick":
if len(args) == 0 {
@@ -662,1627 +169,23 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
} else {
g.doHelp(sess, strings.Join(args, " "))
}
- default:
- sess.WriteLine("Unknown command.")
- }
-
- sess.Write("\r\n> ")
-}
-
-var toggles = []struct {
- Name string
- Description string
-}{
- {"description", "Long room descriptions"},
- {"tinymap", "Mini-map display"},
- {"xpdrops", "XP drop messages in combat"},
- {"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"},
-}
-
-func (g *Game) doToggle(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- if input == "" {
- sess.WriteLine("")
- for _, t := range toggles {
- status := "Off"
- if p.Toggles[t.Name] {
- status = "On"
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description))
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- for _, t := range toggles {
- if strings.ToLower(input) == t.Name {
- p.Toggles[t.Name] = !p.Toggles[t.Name]
- status := "Off"
- if p.Toggles[t.Name] {
- status = "On"
- }
- sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status))
- g.AccountStore.SaveCharacter(p)
- return
- }
- }
- sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input))
-}
-
-func (g *Game) doMove(sess *net.Session, dir string) {
- p := sess.Player.(*player.Player)
- exitDir := g.World.ResolveExit(dir)
- if exitDir == "" {
- sess.WriteLine("Go where?")
- return
- }
-
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You can't move from here.")
- return
- }
-
- targetID, ok := room.Exits[exitDir]
- if !ok {
- sess.WriteLine("You can't go that way.")
- return
- }
-
- _, err = g.World.LoadRoom(targetID)
- if err != nil {
- sess.WriteLine("That path seems blocked.")
- return
- }
-
- // Check combat lock
- if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 {
- sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks)))
- return
- }
-
- // Interrupt combat on move
- g.stopCombat(p.Name)
-
- oldRoom := p.RoomID
- p.RoomID = targetID
- g.AccountStore.SaveCharacter(p)
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
-
- if g.Hub != nil {
- // Notify people in old room
- for _, other := range g.Hub.PlayersInRoom(oldRoom) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir))
- }
- }
- g.Hub.EnterRoom(sess, targetID)
- // Notify people in new room
- for _, other := range g.Hub.PlayersInRoom(targetID) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
- }
- }
- }
-
- sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir))
- if p.Toggles["description"] {
- g.doLook(sess)
- } else {
- targetRoom, _ := g.World.LoadRoom(targetID)
- if targetRoom != nil {
- sess.WriteLine(targetRoom.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)
- for _, s := range styles {
- if s == input {
- p.AttackStyle = player.AttackStyle(s)
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s))
- return
- }
- }
- sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input))
-}
-
-func (g *Game) doLook(sess *net.Session) {
- p := sess.Player.(*player.Player)
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You are in a void.")
- return
- }
-
- sess.WriteLines(
- "",
- room.Name,
- room.Description,
- )
-
- // Mobs
- mobs := g.MobStore.MobsInRoom(p.RoomID)
- if len(mobs) > 0 {
- sort.Slice(mobs, func(i, j int) bool {
- iDamaged := mobs[i].HP < mobs[i].MaxHP
- jDamaged := mobs[j].HP < mobs[j].MaxHP
- if iDamaged != jDamaged {
- return iDamaged
- }
- return mobs[i].InstanceID < mobs[j].InstanceID
- })
- sess.WriteLine("")
- for _, m := range mobs {
- hp := ""
- if m.HP < m.MaxHP {
- hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP)
- }
- var desc string
- if combat.IsMobInCombat(m.InstanceID) {
- def, err := g.MobStore.LoadDef(m.DefID)
- if err == nil && len(def.CombatDescriptions) > 0 {
- target := combat.GetMobTarget(m.InstanceID)
- pattern := def.CombatDescriptions[rand.Intn(len(def.CombatDescriptions))]
- desc = " " + fmt.Sprintf(pattern, target)
- }
- } else if m.IdleDescription != "" {
- desc = fmt.Sprintf(" %s", m.IdleDescription)
- }
- displayName := m.Name
- if !m.Unique {
- displayName = "A " + m.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc))
- }
- }
-
- // Ground items
- ground := g.World.GroundItemsDetailed(p.RoomID)
- if len(ground) > 0 {
- sess.WriteLine("")
- sess.WriteLine("On the ground:")
- for _, info := range ground {
- def, err := g.ItemStore.Load(info.ItemID)
- name := info.ItemID
- if err == nil {
- name = def.Name
- }
- line := ""
- if info.Quantity > 1 {
- line = fmt.Sprintf(" %d x %s", info.Quantity, name)
- } else {
- line = fmt.Sprintf(" %s", name)
- }
- if info.ReservedFor != "" {
- if p.Toggles["reserve"] {
- line += fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer)
- } else {
- line += " (reserved)"
- }
- }
- sess.WriteLine(line)
- }
- }
-
- // Exits
- if len(room.Exits) > 0 {
- sess.WriteLine("")
- if p.Toggles["exits"] {
- sess.WriteLine("Exits:")
- for _, dir := range world.ExitOrder {
- targetID, ok := room.Exits[dir]
- if !ok {
- continue
- }
- targetRoom, err := g.World.LoadRoom(targetID)
- targetName := fmt.Sprintf("#%d", targetID)
- if err == nil {
- targetName = targetRoom.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
- }
- } else {
- sess.Write("Exits: ")
- first := true
- for _, dir := range world.ExitOrder {
- if _, ok := room.Exits[dir]; ok {
- if !first {
- sess.Write(", ")
- }
- sess.Write(string(dir))
- first = false
- }
- }
- sess.WriteLine("")
- }
- }
-
- // Show other players
- others := g.Hub.PlayersInRoom(p.RoomID)
- for _, other := range others {
- if other != sess && other.Player != nil {
- op := other.Player.(*player.Player)
- line := fmt.Sprintf("\n%s is here", op.Name)
- if cs := combat.GetCombat(op.Name); cs != nil {
- if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 {
- line += fmt.Sprintf(" (fighting %s)", mobDisplayName(mob, false))
- }
- }
- sess.WriteLine(line + ".")
- }
- }
-}
-
-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))
+ case "mine", "chop", "fish", "use", "pull", "push":
+ if len(args) == 0 {
+ sess.WriteLine(fmt.Sprintf("%s what?", cmd))
} 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:")
-
- slots := []object.EquipSlot{
- object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
- object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
- object.SlotMainHand, object.SlotOffHand, object.SlotRing,
- }
- for _, slot := range slots {
- 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) cancelRest(playerName string) {
- if id, ok := g.restTimers[playerName]; ok {
- g.Ticks.Unsubscribe(id)
- delete(g.restTimers, playerName)
- }
-}
-
-func (g *Game) doQuit(sess *net.Session) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You can't rest during combat!")
- return
- }
-
- g.cancelRest(p.Name)
-
- ticksLeft := 10
- id := g.Ticks.Subscribe(1, func() bool {
- switch ticksLeft {
- case 10:
- sess.WriteLine("You sit down to rest...")
- case 6:
- sess.WriteLine("You catch your breath...")
- case 3:
- sess.WriteLine("You close your eyes...")
- case 0:
- g.cancelRest(p.Name)
- g.AccountStore.SaveCharacter(p)
- delete(g.loggedInChars, p.Name)
- if g.Hub != nil {
- g.Hub.LeaveRoom(sess)
- }
- sess.Player = nil
- sess.State = net.StateMenu
- g.showMenu(sess)
- return false
- }
- ticksLeft--
- return true
- })
- g.restTimers[p.Name] = id
-}
-
-func plural(n int) string {
- if n == 1 {
- return ""
- }
- return "s"
-}
-
-func parseIndex(s string) (int, error) {
- var idx int
- _, err := fmt.Sscanf(s, "%d", &idx)
- if err != nil {
- return 0, err
- }
- return idx, nil
-}
-
-func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
- qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- if qty <= 0 {
- return
- }
- p.Credits += qty
- g.AccountStore.SaveCharacter(p)
- if qty == 1 {
- sess.WriteLine("You pick up 1 credit.")
- } else {
- sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits))
- }
-}
-
-func (g *Game) doGetAll(sess *net.Session) {
- p := sess.Player.(*player.Player)
- ground := g.World.GroundItems(p.RoomID)
- if len(ground) == 0 {
- sess.WriteLine("There's nothing on the ground to pick up.")
- return
- }
-
- var picked []string
- for itemID, qty := range ground {
- if qty <= 0 {
- continue
- }
- if itemID == "credits" {
- taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
- continue
- }
- p.Credits += taken
- if taken == 1 {
- picked = append(picked, "1 credit")
- } else {
- picked = append(picked, fmt.Sprintf("%d credits", taken))
- }
- continue
- }
-
- def, _ := g.ItemStore.Load(itemID)
- for qty > 0 {
- if def != nil && def.Stackable {
- stacked := false
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- qty = 0
- stacked = true
- break
- }
- slot.Quantity += qty
- picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity))
- stacked = true
- qty = 0
- break
- }
- }
- if stacked {
- break
- }
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
- innerPickupReport(sess, picked)
- } else {
- sess.WriteLine("Your inventory is full.")
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- break
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
- name := itemID
- if def != nil {
- name = def.Name
- }
- picked = append(picked, name)
- break
- }
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
- innerPickupReport(sess, picked)
- } else {
- sess.WriteLine("Your inventory is full.")
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- take := 1
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name)
- if !ok {
- break
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take})
- name := itemID
- if def != nil {
- name = def.Name
- }
- picked = append(picked, name)
- qty -= take
- }
- }
-
- g.AccountStore.SaveCharacter(p)
-
- if len(picked) == 0 {
- sess.WriteLine("Your inventory is full.")
- } else {
- sess.Write("You pick up: ")
- innerPickupReport(sess, picked)
- }
-}
-
-func (g *Game) doLookTarget(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
- lower := strings.ToLower(input)
-
- // Check if looking at an exit direction
- if exitDir := g.World.ResolveExit(lower); exitDir != "" {
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You can't see anything that way.")
- return
- }
- targetID, ok := room.Exits[exitDir]
- if !ok {
- sess.WriteLine("You can't see anything that way.")
- return
- }
- // Seed target room and show its look output
- g.World.SeedGroundItems(targetID)
- g.seedRoomMobs(targetID)
- origRoom := p.RoomID
- p.RoomID = targetID
- g.doLook(sess)
- p.RoomID = origRoom
- return
- }
-
- // Check mobs — prefer exact matches over prefix
- var best *world.MobInstance
- bestQ := world.MatchNone
- for _, m := range g.MobStore.MobsInRoom(p.RoomID) {
- q := m.MatchQuality(lower)
- if q > bestQ {
- bestQ = q
- best = m
- }
- }
- if best != nil {
- sess.WriteLines(
- "",
- fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)),
- )
- if best.IdleDescription != "" {
- sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
- }
- sess.WriteLines(
- "",
- fmt.Sprintf(" Attack: %d", best.Attack),
- fmt.Sprintf(" Strength: %d", best.Strength),
- fmt.Sprintf(" Defense: %d", best.Defense),
- fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP),
- )
- return
- }
-
- // Check room objects
- room, err := g.World.LoadRoom(p.RoomID)
- if err == nil {
- for _, objID := range room.Objects {
- def, err := g.ObjectStore.Load(objID)
- if err != nil {
- continue
- }
- if !world.WordPrefixMatch(lower, def.Name) && !world.WordPrefixMatch(lower, def.ID) {
- continue
- }
- sess.WriteLine("")
- sess.WriteLine(def.Name)
- if desc, ok := def.Props["description"].(string); ok && desc != "" {
- sess.WriteLine(fmt.Sprintf(" %s", desc))
- }
+ g.StartAction(sess, cmd, strings.Join(args, " "))
return
}
- }
-
- // Check ground items
- ground := g.World.GroundItems(p.RoomID)
- for itemID := range ground {
- def, err := g.ItemStore.Load(itemID)
- if err != nil || !def.MatchesName(input) {
- continue
- }
- sess.WriteLines(
- "",
- def.Name,
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
- )
- return
- }
-
- // Check inventory items
- 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.MatchesName(input) {
- continue
- }
- sess.WriteLines(
- "",
- def.Name,
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
- )
- return
- }
-
- // Check other players
- others := g.Hub.PlayersInRoom(p.RoomID)
- for _, other := range others {
- if other == sess || other.Player == nil {
- continue
- }
- op := other.Player.(*player.Player)
- if strings.ToLower(op.Name) != lower {
- continue
- }
- showPlayerInfo(sess, op)
- return
- }
-
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
-}
-
-func showPlayerInfo(sess *net.Session, p *player.Player) {
- sess.WriteLines(
- "",
- p.Name,
- fmt.Sprintf(" Combat Level: %d", p.CombatLevel()),
- fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()),
- "",
- )
-
- // Skills
- for _, s := range player.AllSkills {
- level := p.Level(s)
- xp := p.Skills[s]
- sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level))
- _ = xp
- }
-
- // Equipment
- sess.WriteLine("")
- sess.WriteLine(" Equipment:")
- slots := []object.EquipSlot{
- object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
- object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
- object.SlotMainHand, object.SlotOffHand, object.SlotRing,
- }
- for _, slot := range slots {
- itemID, ok := p.Equipment[slot]
- if !ok {
- continue
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID))
- }
-
- if p.Description != "" {
- sess.WriteLine("")
- sess.WriteLine(fmt.Sprintf(" %s", p.Description))
- }
-}
-
-func (g *Game) doExits(sess *net.Session) {
- p := sess.Player.(*player.Player)
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil || len(room.Exits) == 0 {
- sess.WriteLine("There are no exits here.")
- return
- }
- for _, dir := range world.ExitOrder {
- targetID, ok := room.Exits[dir]
- if !ok {
- continue
- }
- targetRoom, err := g.World.LoadRoom(targetID)
- targetName := fmt.Sprintf("#%d", targetID)
- if err == nil {
- targetName = targetRoom.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
- }
-}
-
-func (g *Game) doDescription(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- if p.Description != "" {
- sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description))
- } else {
- sess.WriteLine("You don't have a description set.")
- }
- sess.WriteLine("")
- sess.Write("Enter a new description (or press enter to keep current): ")
- sess.State = net.StateChangeDescription
-}
-
-func (g *Game) handleDescriptionChange(sess *net.Session, input string) {
- if input != "" {
- p := sess.Player.(*player.Player)
- p.Description = input
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("Description set to: %s", input))
- } else {
- sess.WriteLine("Description left unchanged.")
- }
- sess.State = net.StateGame
- sess.Write("\r\n> ")
-}
-
-func innerPickupReport(sess *net.Session, picked []string) {
- for i, name := range picked {
- if i == len(picked)-1 {
- sess.WriteLine(name)
- } else if i == len(picked)-2 {
- sess.Write(name + " and ")
+ case "talk", "speak", "ask":
+ if len(args) == 0 {
+ sess.WriteLine("Talk to whom?")
} else {
- sess.Write(name + ", ")
- }
- }
-}
-
-func (g *Game) doGet(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
- 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))
- }
+ g.StartAction(sess, "talk", strings.Join(args, " "))
return
}
+ default:
+ sess.WriteLine("Unknown command.")
}
- itemID := matches[0].ID
-
- // Credits go to the credits field, not inventory
- if itemID == "credits" {
- g.pickupCredits(sess, p, itemID)
- return
- }
-
- def, _ := g.ItemStore.Load(itemID)
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is full.")
- return
- }
-
- qty := 1
- if def != nil && def.Stackable {
- ground := g.World.GroundItems(p.RoomID)
- if gqty, ok := ground[itemID]; ok && gqty > 0 {
- qty = gqty
- }
- }
- // Check if we can stack
- 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, qty, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- _ = removed
- slot.Quantity += qty
- g.AccountStore.SaveCharacter(p)
- name := def.Name
- sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", name, slot.Quantity))
- 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)
- name := itemID
- if def != nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
-}
-
-func (g *Game) doDrop(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- matches := g.findInventoryMatches(input, p)
- if len(matches) == 0 {
- sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input))
- 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
- 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
- } else {
- p.SetInvSlot(slotIdx, nil)
- }
-
- g.World.AddGroundItem(p.RoomID, itemID, qty)
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
-}
-
-type itemMatch struct {
- ID string
- Name string
- Slot int // inventory slot index, -1 for ground
-}
-
-func (g *Game) findGroundMatches(input string, roomID int) []itemMatch {
- ground := g.World.GroundItems(roomID)
- var matches []itemMatch
- for itemID := range ground {
- def, err := g.ItemStore.Load(itemID)
- if err != nil {
- continue
- }
- if def.MatchesName(input) {
- matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1})
- }
- }
- return matches
-}
-
-func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch {
- var matches []itemMatch
- 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
- }
- if def.MatchesName(input) {
- matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i})
- }
- }
- return matches
-}
-
-func uniqueItemNames(matches []itemMatch) []string {
- seen := make(map[string]bool)
- var out []string
- for _, m := range matches {
- if !seen[m.Name] {
- seen[m.Name] = true
- out = append(out, m.Name)
- }
- }
- return out
-}
-
-func (g *Game) doTickTest(sess *net.Session) {
- count := 0
- id := 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
- })
- _ = id
- sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.")
-}
-
-func (g *Game) doAttack(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You are already in combat!")
- return
- }
-
- mob := g.findMob(sess, input, p.RoomID)
- if mob == nil {
- return
- }
-
- if mob.HP <= 0 {
- sess.WriteLine("That is already dead.")
- return
- }
-
- if mob.Protected {
- sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true)))
- return
- }
-
- if combat.IsMobInCombat(mob.InstanceID) {
- sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
- return
- }
-
- g.startCombat(sess, p, mob)
-}
-
-func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance {
- lower := strings.ToLower(input)
- mobs := g.MobStore.MobsInRoom(roomID)
-
- idx := -1
- name := lower
- if dotPos := strings.Index(lower, "."); dotPos > 0 {
- if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 {
- idx = n
- name = lower[dotPos+1:]
- }
- }
-
- var exact []*world.MobInstance
- var prefix []*world.MobInstance
- for _, m := range mobs {
- q := m.MatchQuality(name)
- if q == world.MatchExact {
- exact = append(exact, m)
- } else if q == world.MatchPrefix {
- prefix = append(prefix, m)
- }
- }
-
- // Prefer exact matches
- candidates := exact
- if len(candidates) == 0 {
- candidates = prefix
- }
-
- if len(candidates) == 0 {
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
- return nil
- }
-
- sort.Slice(candidates, func(i, j int) bool {
- return candidates[i].InstanceID < candidates[j].InstanceID
- })
-
- if idx > 0 {
- if idx-1 < len(candidates) {
- return candidates[idx-1]
- }
- return nil
- }
-
- if len(candidates) == 1 {
- return candidates[0]
- }
-
- // Multiple candidates — different names means ambiguous
- seen := make(map[string]bool)
- for _, m := range candidates {
- seen[mobDisplayName(m, false)] = true
- }
- if len(seen) > 1 {
- sess.WriteLine("Which one?")
- return nil
- }
- // Same name — return the first
- return candidates[0]
-}
-
-func (g *Game) seedRoomMobs(roomID int) {
- room, err := g.World.LoadRoom(roomID)
- if err != nil {
- return
- }
- if len(room.Mobs) == 0 {
- return
- }
- g.MobStore.SeedMobs(roomID, room.Mobs)
-}
-
-func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- g.cancelRest(p.Name)
-
- instanceID := g.findMobInstanceID(mob)
-
- combat.EnterCombat(p.Name, instanceID)
-
- playerSpeed := g.playerWeaponSpeed(p)
-
- attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
- var styleParts []string
- if attBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus))
- }
- if strBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus))
- }
- if defBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus))
- }
- styleStr := ""
- if len(styleParts) > 0 {
- styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
- }
- sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr))
-
- // Player tick callback
- g.Ticks.Subscribe(playerSpeed, func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- g.playerAttack(sess, p, currentMob)
- if currentMob.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
-
- // Mob tick callback
- g.Ticks.Subscribe(mob.Speed, func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- return false
- }
- combat.RecordMobAttack(p.Name)
- g.mobAttack(sess, p, currentMob)
- if p.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
-}
-
-func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
-
- equipAtt := 0
- equipStr := 0
- if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipAtt = def.Stats.AttackBonus
- equipStr = def.Stats.StrengthBonus
- }
- }
-
- attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
- defRoll := combat.DefenseRoll(mob.Defense, 0, 0)
-
- if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr)
- dmg := combat.RollDamage(maxHit)
-
- mob.HP -= dmg
- if mob.HP < 0 {
- mob.HP = 0
- }
- if mob.HP < mob.MaxHP && mob.HP > 0 {
- mob.StartRegen()
- }
- gains := g.awardCombatXP(p, dmg)
-
- mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg))
- if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w {
- w = w2
- }
- g.combatPadWidth = w
- prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)
- hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP)
- line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)
- if p.Toggles["xpdrops"] && len(gains) > 0 {
- var parts []string
- for _, g := range gains {
- parts = append(parts, fmt.Sprintf("+%dxp %s", g.XP, player.SkillAbbr[g.Skill]))
- }
- line += " (" + strings.Join(parts, ", ") + ")"
- }
- sess.WriteLine(line)
- } else {
- sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))
- }
-}
-
-func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
-
- equipDef := 0
- for _, itemID := range p.Equipment {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipDef += def.Stats.DefenseBonus
- }
- }
-
- attRoll := combat.AttackRoll(mob.Attack, 0, 0)
- defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef)
-
- if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(mob.Strength, 0, 0)
- dmg := combat.RollDamage(maxHit)
-
- p.HP -= dmg
- if p.HP < 0 {
- p.HP = 0
- }
- p.StartRegen()
- g.AccountStore.SaveCharacter(p)
-
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- mobName := mobDisplayName(mob, true)
- w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg))
- if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w {
- w = w2
- }
- g.combatPadWidth = w
- prefix := fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)
- hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP())
- sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart))
- } else {
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s misses you.", attacker))
- }
-}
-
-func (g *Game) DisconnectTick() {
- if g.Hub == nil {
- return
- }
- for _, sess := range g.Hub.AllSessions() {
- if !sess.Disconnecting {
- continue
- }
- p, ok := sess.Player.(*player.Player)
- if !ok {
- g.Hub.Remove(sess)
- continue
- }
- if combat.GetCombat(p.Name) != nil {
- continue
- }
- sess.DisconnectTicks--
- if sess.DisconnectTicks <= 0 {
- g.AccountStore.SaveCharacter(p)
- g.Hub.HardRemove(sess)
- }
- }
-}
-
-func (g *Game) RegenTick() {
- if g.Hub == nil {
- return
- }
- for _, sess := range g.Hub.AllSessions() {
- p, ok := sess.Player.(*player.Player)
- if !ok {
- continue
- }
- if p.HP <= 0 || p.HP >= p.MaxHP() {
- p.RegenerateTick = 0
- continue
- }
- p.RegenerateTick--
- if p.RegenerateTick <= 0 {
- p.HP++
- if p.HP >= p.MaxHP() {
- p.HP = p.MaxHP()
- p.RegenerateTick = 0
- } else {
- p.RegenerateTick = 100
- }
- }
- }
-}
-
-func (g *Game) roomsWithinRange(homeID int, maxDist int) map[int]bool {
- reachable := map[int]bool{homeID: true}
- if maxDist <= 0 {
- return reachable
- }
- frontier := []int{homeID}
- dist := map[int]int{homeID: 0}
- for len(frontier) > 0 {
- current := frontier[0]
- frontier = frontier[1:]
- if dist[current] >= maxDist {
- continue
- }
- room, err := g.World.LoadRoom(current)
- if err != nil {
- continue
- }
- for _, targetID := range room.Exits {
- if _, ok := dist[targetID]; ok {
- continue
- }
- dist[targetID] = dist[current] + 1
- reachable[targetID] = true
- frontier = append(frontier, targetID)
- }
- }
- return reachable
-}
-
-func (g *Game) WanderTick() {
- if g.Hub == nil {
- return
- }
-
- type moveEvent struct {
- inst *world.MobInstance
- fromRoom int
- toRoom int
- exitDir world.ExitDir
- }
- var moves []moveEvent
-
- for _, inst := range g.MobStore.AllInstances() {
- if inst.HP <= 0 || inst.Wander <= 0 || inst.WanderTick <= 0 {
- continue
- }
- if combat.IsMobInCombat(inst.InstanceID) {
- continue
- }
- inst.WanderTickCounter++
- if inst.WanderTickCounter >= inst.WanderTick {
- inst.WanderTickCounter = 0
-
- if rand.Float64() < inst.WanderChance {
- room, err := g.World.LoadRoom(inst.RoomID)
- if err != nil || len(room.Exits) == 0 {
- continue
- }
-
- reachable := g.roomsWithinRange(inst.HomeRoomID, inst.Wander)
- var validDirs []world.ExitDir
- for dir, targetID := range room.Exits {
- if reachable[targetID] {
- validDirs = append(validDirs, dir)
- }
- }
- if len(validDirs) > 0 {
- dir := validDirs[rand.Intn(len(validDirs))]
- moves = append(moves, moveEvent{inst, inst.RoomID, room.Exits[dir], dir})
- }
- }
- }
- }
-
- for _, m := range moves {
- m.inst.RoomID = m.toRoom
- for _, sess := range g.Hub.AllSessions() {
- p, ok := sess.Player.(*player.Player)
- if !ok {
- continue
- }
- if p.RoomID == m.fromRoom && p.Toggles["mobleave"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), m.exitDir))
- }
- if p.RoomID == m.toRoom && p.Toggles["mobenter"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters from the %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), world.OppositeExit[m.exitDir]))
- }
- }
- }
-}
-
-func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- g.combatPadWidth = 0
- combat.LeaveCombat(p.Name)
-
- if p.HP <= 0 {
- sess.WriteLine(fmt.Sprintf("\nOh dear, you are dead!"))
- g.dropItemsOnDeath(p)
- p.HP = p.MaxHP()
- p.RoomID = 1
- g.AccountStore.SaveCharacter(p)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- return
- }
-
- if mob != nil && mob.HP <= 0 {
- sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other != sess && other.Player != nil {
- other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob)))
- }
- }
- }
-
- // Always drop remains
- if mob.Drops.Remains != "" {
- g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
- def, _ := g.ItemStore.Load(mob.Drops.Remains)
- name := mob.Drops.Remains
- if def != nil {
- name = def.Name
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
- }
-
- // Weighted loot roll — exactly one result
- if len(mob.Drops.Loot) > 0 {
- totalWeight := 0
- for _, e := range mob.Drops.Loot {
- totalWeight += e.Weight
- }
- roll := randInt(totalWeight)
- cumulative := 0
- for _, e := range mob.Drops.Loot {
- cumulative += e.Weight
- if roll < cumulative {
- g.World.AddReservedGroundItem(p.RoomID, e.ItemID, e.Quantity, p.Name)
- def, _ := g.ItemStore.Load(e.ItemID)
- name := e.ItemID
- if def != nil {
- name = def.Name
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- if e.Quantity > 1 {
- sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, e.Quantity, name))
- } else {
- sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
- }
- break
- }
- }
- }
-
- // Schedule respawn
- instanceID := g.findMobInstanceID(mob)
- respawnTicks := mob.RespawnTicks
- if respawnTicks <= 0 {
- respawnTicks = 30
- }
- g.Ticks.Subscribe(respawnTicks, func() bool {
- g.respawnMob(instanceID)
- return false
- })
- }
-}
-
-type xpGain struct {
- Skill player.SkillName
- XP int
-}
-
-func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain {
- baseXP := dmg * 4
- var gains []xpGain
-
- switch p.AttackStyle {
- case player.Accurate:
- gains = []xpGain{{player.Attack, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Aggressive:
- gains = []xpGain{{player.Strength, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Defensive:
- gains = []xpGain{{player.Defense, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Balanced:
- quarter := baseXP / 4
- gains = []xpGain{{player.Attack, quarter}, {player.Strength, quarter}, {player.Defense, quarter}, {player.Hitpoints, quarter}}
- }
-
- for _, g := range gains {
- p.AddXP(g.Skill, g.XP)
- }
- g.AccountStore.SaveCharacter(p)
- return gains
-}
-
-type deathDrop struct {
- itemID string
- quantity int
- totalVal int
- isEquip bool
- equipSlot object.EquipSlot
- invSlot int
-}
-
-func (g *Game) dropItemsOnDeath(p *player.Player) {
- roomID := p.RoomID
-
- // Credits always drop on death
- if p.Credits > 0 {
- g.World.AddGroundItem(roomID, "credits", p.Credits)
- p.Credits = 0
- }
-
- var items []deathDrop
-
- // Inventory
- for slot, inv := range p.Inventory {
- if inv == nil || inv.Quantity <= 0 {
- continue
- }
- val := 0
- if def, err := g.ItemStore.Load(inv.ItemID); err == nil {
- val = def.Value * inv.Quantity
- }
- items = append(items, deathDrop{
- itemID: inv.ItemID,
- quantity: inv.Quantity,
- totalVal: val,
- invSlot: slot,
- })
- }
-
- // Equipment
- for eqSlot, itemID := range p.Equipment {
- val := 0
- if def, err := g.ItemStore.Load(itemID); err == nil {
- val = def.Value
- }
- items = append(items, deathDrop{
- itemID: itemID,
- quantity: 1,
- totalVal: val,
- isEquip: true,
- equipSlot: eqSlot,
- })
- }
-
- if len(items) <= 3 {
- return
- }
-
- sort.Slice(items, func(i, j int) bool {
- return items[i].totalVal > items[j].totalVal
- })
-
- for i := 3; i < len(items); i++ {
- it := items[i]
- if it.isEquip {
- delete(p.Equipment, it.equipSlot)
- } else {
- p.SetInvSlot(it.invSlot, nil)
- }
- g.World.AddGroundItem(roomID, it.itemID, it.quantity)
- }
-}
-
-func (g *Game) stopCombat(playerName string) {
- cs := combat.GetCombat(playerName)
- if cs == nil {
- return
- }
- combat.LeaveCombat(playerName)
-}
-
-func (g *Game) respawnMob(instanceID string) {
- inst := g.MobStore.GetInstance(instanceID)
- if inst == nil {
- return
- }
- homeRoom := inst.HomeRoomID
- inst.RoomID = homeRoom
- inst.HP = inst.MaxHP
- g.MobStore.RollIdleDescription(inst)
-
- if g.Hub != nil {
- for _, sess := range g.Hub.PlayersInRoom(homeRoom) {
- if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters the area.", mobDisplayName(inst, false), mobCombatLevel(inst)))
- }
- }
- }
-}
-
-func (g *Game) findMobInstanceID(mob *world.MobInstance) string {
- return mob.InstanceID
-}
-
-func (g *Game) playerWeaponSpeed(p *player.Player) int {
- if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
- def, err := g.ItemStore.Load(itemID)
- if err == nil && def.Speed > 0 {
- return def.Speed
- }
- }
- return 5 // unarmed speed
-}
-
-func mobDisplayName(m *world.MobInstance, definite bool) string {
- if m.Unique {
- return m.Name
- }
- if definite {
- return "the " + m.Name
- }
- return "a " + m.Name
-}
-
-func mobCombatLevel(m *world.MobInstance) int {
- base := 0.25 * float64(m.Defense+m.MaxHP+m.Defense)
- base += 0.25 * float64(m.Attack+m.Strength)
- return int(base)
-}
-
-func randInt(max int) int {
- if max <= 0 {
- return 0
- }
- return combat.RollDamage(max) - 1
+ sess.Write("\r\n> ")
}
diff --git a/internal/game/session.go b/internal/game/session.go
new file mode 100644
index 0000000..edc904d
--- /dev/null
+++ b/internal/game/session.go
@@ -0,0 +1,500 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) handleAccountName(sess *net.Session, input string) {
+ name := strings.TrimSpace(input)
+ if name == "" {
+ sess.Write("Account name: ")
+ return
+ }
+ actualName, err := g.AccountStore.FindAccount(name)
+ if err == nil {
+ sess.Account = &net.AccountEntry{Name: actualName}
+ sess.State = net.StatePassword
+ sess.Write("Password: ")
+ } else {
+ sess.Account = &net.AccountEntry{Name: name}
+ sess.State = net.StateNewAccountPass
+ sess.Write("A new visitor to this rock!\nChoose password: ")
+ }
+}
+
+func (g *Game) handlePassword(sess *net.Session, input string) {
+ if input == "" {
+ sess.Write("Password: ")
+ return
+ }
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine("Error loading account.")
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+ if !player.CheckPassword(input, acc.PasswordHash) {
+ sess.WriteLine("Wrong password.")
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+ sess.Account = &net.AccountEntry{
+ Name: acc.Name,
+ PasswordHash: acc.PasswordHash,
+ Characters: acc.Characters,
+ }
+ g.showMenu(sess)
+}
+
+func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+ sess.PendingPass = input
+ sess.State = net.StateNewAccountConfirm
+ sess.Write("Confirm password: ")
+}
+
+func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+ if input != sess.PendingPass {
+ sess.WriteLine("Passwords do not match.")
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+
+ hash, err := player.HashPassword(input)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+
+ acc := &player.Account{
+ Name: sess.Account.Name,
+ PasswordHash: hash,
+ }
+ if err := g.AccountStore.SaveAccount(acc); err != nil {
+ sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.Write("Account name: ")
+ return
+ }
+
+ sess.Account = &net.AccountEntry{
+ Name: acc.Name,
+ PasswordHash: acc.PasswordHash,
+ }
+ sess.PendingPass = ""
+ g.showMenu(sess)
+}
+
+func (g *Game) showMenu(sess *net.Session) {
+ sess.State = net.StateMenu
+ lines := []string{
+ "",
+ fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name),
+ "",
+ }
+ if len(sess.Account.Characters) > 0 {
+ lines = append(lines,
+ " (C)onnect character to TC",
+ "",
+ " (L)ist characters",
+ " (R)ename character",
+ " (D)elete character",
+ )
+ }
+ lines = append(lines,
+ " (N)ew character",
+ " (P)urge account",
+ " (A)ccount rename",
+ " (Q)uit",
+ "",
+ "INPUT: ",
+ )
+ sess.WriteLines(lines...)
+}
+
+func (g *Game) handleMenu(sess *net.Session, input string) {
+ switch strings.ToLower(strings.TrimSpace(input)) {
+ case "":
+ sess.Write("INPUT: ")
+ case "c":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!")
+ sess.Write("\nINPUT: ")
+ return
+ }
+ if len(sess.Account.Characters) == 1 {
+ g.connectCharacter(sess, sess.Account.Characters[0])
+ return
+ }
+ sess.WriteLine("\nSelect character:")
+ for i, name := range sess.Account.Characters {
+ sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
+ }
+ sess.State = net.StateNewCharName
+ sess.PendingChar = "connect"
+ sess.Write("\nChoice: ")
+
+ case "l":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)")
+ } else {
+ sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:")
+ for _, name := range sess.Account.Characters {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
+ }
+ }
+ sess.Write("\nINPUT: ")
+
+ case "n":
+ sess.State = net.StateNewCharName
+ sess.PendingChar = ""
+ sess.Write("What's your character name?: ")
+
+ case "a":
+ sess.State = net.StateRenameAccount
+ sess.Write("New account name: ")
+
+ case "r":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nNo characters to rename.")
+ sess.Write("\nChoice: ")
+ return
+ }
+ if len(sess.Account.Characters) == 1 {
+ sess.PendingChar = sess.Account.Characters[0]
+ sess.State = net.StateRenameCharName
+ sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar))
+ sess.Write("New name: ")
+ return
+ }
+ sess.State = net.StateRenameChar
+ sess.WriteLine("\nRename which character?")
+ for i, name := range sess.Account.Characters {
+ sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
+ }
+ sess.Write("\n> : ")
+
+ case "d":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nNo characters to delete.")
+ sess.Write("\nINPUT: ")
+ return
+ }
+ if len(sess.Account.Characters) == 1 {
+ sess.PendingChar = sess.Account.Characters[0]
+ } else {
+ sess.State = net.StateDeleteChar
+ sess.WriteLine("\nDelete which character?")
+ for i, name := range sess.Account.Characters {
+ sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
+ }
+ sess.Write("\n>: ")
+ return
+ }
+ g.showDeleteConfirm(sess)
+
+ case "p":
+ sess.State = net.StatePurgeAccount
+ sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name))
+ sess.Write("\n> ")
+
+ case "q":
+ sess.WriteLine("Later.")
+ sess.Conn.Close()
+ return
+
+ default:
+ sess.Write("SYNTAX ERROR\nINPUT: ")
+ }
+}
+
+func (g *Game) handleNewCharName(sess *net.Session, input string) {
+ name := strings.TrimSpace(input)
+
+ if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 {
+ if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
+ sess.PendingChar = ""
+ g.connectCharacter(sess, sess.Account.Characters[idx-1])
+ return
+ }
+ sess.WriteLine("Invalid choice.")
+ sess.Write("\nChoice: ")
+ return
+ }
+
+ if name == "" {
+ sess.Write("Character name: ")
+ return
+ }
+
+ if g.AccountStore.CharacterExists(name) {
+ sess.WriteLine("A character with that name already exists.")
+ sess.Write("Character name: ")
+ return
+ }
+
+ p := player.New(name)
+ p.RoomID = 1
+
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
+
+ acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc.Characters = append(acc.Characters, name)
+ g.AccountStore.SaveAccount(acc)
+ sess.Account.Characters = acc.Characters
+
+ g.connectCharacter(sess, name)
+}
+
+func (g *Game) connectCharacter(sess *net.Session, name string) {
+ g.charsMu.Lock()
+ if existing := g.loggedInChars[name]; existing != nil {
+ g.charsMu.Unlock()
+ sess.WriteLine("This character is logged in elsewhere.")
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
+
+ p, err := g.AccountStore.LoadCharacter(name)
+ if err != nil {
+ g.charsMu.Unlock()
+ sess.WriteLine(fmt.Sprintf("Error loading character: %v", err))
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
+
+ sess.Player = p
+ sess.State = net.StateGame
+ g.loggedInChars[name] = sess
+ g.charsMu.Unlock()
+
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.ensureRoomObjects(p.RoomID)
+
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+
+ g.doLook(sess)
+ sess.Write("\r\n> ")
+}
+
+func (g *Game) handleRenameAccount(sess *net.Session, input string) {
+ newName := strings.TrimSpace(input)
+ if newName == "" {
+ sess.Write("New account name: ")
+ return
+ }
+ oldName := sess.Account.Name
+ if newName == oldName {
+ sess.WriteLine("That's already your account name.")
+ g.showMenu(sess)
+ return
+ }
+ if g.AccountStore.AccountExists(newName) {
+ sess.WriteLine("An account with that name already exists.")
+ sess.Write("New account name: ")
+ return
+ }
+
+ oldPath := g.AccountStore.AccountPath(oldName)
+ newPath := g.AccountStore.AccountPath(newName)
+ if err := os.Rename(oldPath, newPath); err != nil {
+ sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err))
+ g.showMenu(sess)
+ return
+ }
+
+ sess.Account.Name = newName
+ sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName))
+ g.showMenu(sess)
+}
+
+func (g *Game) handleRenameChar(sess *net.Session, input string) {
+ name := strings.TrimSpace(input)
+ if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
+ sess.PendingChar = sess.Account.Characters[idx-1]
+ } else {
+ sess.PendingChar = name
+ }
+
+ found := false
+ for _, c := range sess.Account.Characters {
+ if c == sess.PendingChar {
+ found = true
+ break
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
+ g.showMenu(sess)
+ return
+ }
+
+ sess.State = net.StateRenameCharName
+ sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar))
+ sess.Write("New name: ")
+}
+
+func (g *Game) handleRenameCharName(sess *net.Session, input string) {
+ newName := strings.TrimSpace(input)
+ oldName := sess.PendingChar
+ if newName == "" {
+ sess.Write("New name: ")
+ return
+ }
+ if newName == oldName {
+ sess.WriteLine("That's already the character's name.")
+ g.showMenu(sess)
+ return
+ }
+ if g.AccountStore.CharacterExists(newName) {
+ sess.WriteLine("A character with that name already exists.")
+ sess.Write("New name: ")
+ return
+ }
+
+ oldPath := g.AccountStore.CharPath(oldName)
+ newPath := g.AccountStore.CharPath(newName)
+ if err := os.Rename(oldPath, newPath); err != nil {
+ sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
+ g.showMenu(sess)
+ return
+ }
+
+ p, _ := g.AccountStore.LoadCharacter(newName)
+ if p != nil {
+ p.Name = newName
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ for i, c := range acc.Characters {
+ if c == oldName {
+ acc.Characters[i] = newName
+ break
+ }
+ }
+ g.AccountStore.SaveAccount(acc)
+ sess.Account.Characters = acc.Characters
+
+ sess.PendingChar = ""
+ sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName))
+ g.showMenu(sess)
+}
+
+func (g *Game) showDeleteConfirm(sess *net.Session) {
+ sess.State = net.StateDeleteChar
+ sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar))
+}
+
+func (g *Game) handleDeleteChar(sess *net.Session, input string) {
+ if sess.PendingChar == "" {
+ name := strings.TrimSpace(input)
+ if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
+ sess.PendingChar = sess.Account.Characters[idx-1]
+ } else {
+ sess.PendingChar = name
+ }
+ found := false
+ for _, c := range sess.Account.Characters {
+ if c == sess.PendingChar {
+ found = true
+ break
+ }
+ }
+ if !found {
+ sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
+ }
+ g.showDeleteConfirm(sess)
+ return
+ }
+
+ input = strings.TrimSpace(input)
+ expected := "DELETE " + sess.PendingChar
+ if strings.ToUpper(input) != strings.ToUpper(expected) {
+ sess.WriteLine("Delete cancelled.")
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
+ }
+
+ charPath := g.AccountStore.CharPath(sess.PendingChar)
+ if err := os.Remove(charPath); err != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
+ }
+
+ acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ var newChars []string
+ for _, c := range acc.Characters {
+ if c != sess.PendingChar {
+ newChars = append(newChars, c)
+ }
+ }
+ acc.Characters = newChars
+ g.AccountStore.SaveAccount(acc)
+ sess.Account.Characters = newChars
+
+ sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar))
+ sess.PendingChar = ""
+ g.showMenu(sess)
+}
+
+func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
+ input = strings.TrimSpace(input)
+ expected := "PURGE " + sess.Account.Name
+ if strings.ToUpper(input) != strings.ToUpper(expected) {
+ sess.WriteLine("Purge cancelled.")
+ g.showMenu(sess)
+ return
+ }
+
+ for _, name := range sess.Account.Characters {
+ os.Remove(g.AccountStore.CharPath(name))
+ }
+
+ os.Remove(g.AccountStore.AccountPath(sess.Account.Name))
+
+ sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name))
+ sess.Conn.Close()
+}
diff --git a/internal/game/tick.go b/internal/game/tick.go
new file mode 100644
index 0000000..9595668
--- /dev/null
+++ b/internal/game/tick.go
@@ -0,0 +1,141 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) DisconnectTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ if !sess.Disconnecting {
+ continue
+ }
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ g.Hub.Remove(sess)
+ continue
+ }
+ if combat.GetCombat(p.Name) != nil {
+ continue
+ }
+ sess.DisconnectTicks--
+ if sess.DisconnectTicks <= 0 {
+ g.AccountStore.SaveCharacter(p)
+ g.Hub.HardRemove(sess)
+ }
+ }
+}
+
+func (g *Game) RegenTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ continue
+ }
+ if p.HP <= 0 || p.HP >= p.MaxHP() {
+ p.RegenerateTick = 0
+ continue
+ }
+ p.RegenerateTick--
+ if p.RegenerateTick <= 0 {
+ p.HP++
+ if p.HP >= p.MaxHP() {
+ p.HP = p.MaxHP()
+ p.RegenerateTick = 0
+ } else {
+ p.RegenerateTick = 100
+ }
+ }
+ }
+}
+
+func (g *Game) WanderTick() {
+ if g.Hub == nil {
+ return
+ }
+
+ type moveEvent struct {
+ name string
+ fromRoom int
+ toRoom int
+ level int
+ }
+
+ var moves []moveEvent
+
+ // Mob wandering
+ for _, inst := range g.MobStore.AllInstances() {
+ if inst.HP <= 0 || len(inst.WanderRooms) == 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
+ }
+ }
+
+ // Object wandering (fishing spots, etc.)
+ g.World.TickObjWander()
+ objMoves := g.World.FlushObjMoves()
+ for _, om := range objMoves {
+ for _, sess := range g.Hub.PlayersInRoom(om.FromRoom) {
+ if p, ok := sess.Player.(*player.Player); ok && p.Action != nil {
+ if p.Action.TargetID == om.DefID {
+ g.CancelAction(p)
+ }
+ }
+ }
+ moves = append(moves, moveEvent{
+ name: "a " + om.Name,
+ fromRoom: om.FromRoom,
+ toRoom: om.ToRoom,
+ level: 0,
+ })
+ }
+
+ for _, m := range moves {
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok {
+ continue
+ }
+ if p.RoomID == m.fromRoom && p.Toggles["mobleave"] {
+ if m.level > 0 {
+ sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves.", m.name, m.level))
+ } else {
+ sess.WriteLine(fmt.Sprintf("\n%s moves away.", m.name))
+ }
+ }
+ if p.RoomID == m.toRoom && p.Toggles["mobenter"] {
+ if m.level > 0 {
+ sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters.", m.name, m.level))
+ } else {
+ sess.WriteLine(fmt.Sprintf("\n%s drifts in.", m.name))
+ }
+ }
+ }
+ }
+}
diff --git a/internal/game/utils.go b/internal/game/utils.go
new file mode 100644
index 0000000..0559f4f
--- /dev/null
+++ b/internal/game/utils.go
@@ -0,0 +1,114 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+var EquipSlots = []object.EquipSlot{
+ object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
+ object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
+ object.SlotMainHand, object.SlotOffHand, object.SlotRing,
+}
+
+type itemMatch struct {
+ ID string
+ Name string
+ Slot int
+}
+
+type xpGain struct {
+ Skill string
+ XP int
+}
+
+type deathDrop struct {
+ itemID string
+ quantity int
+ totalVal int
+ isEquip bool
+ equipSlot object.EquipSlot
+ invSlot int
+}
+
+func plural(n int) string {
+ if n == 1 {
+ return ""
+ }
+ return "s"
+}
+
+func parseIndex(s string) (int, error) {
+ var idx int
+ _, err := fmt.Sscanf(s, "%d", &idx)
+ return idx, err
+}
+
+func mobDisplayName(m *world.MobInstance, definite bool) string {
+ if m.Unique {
+ return m.Name
+ }
+ if definite {
+ return "the " + m.Name
+ }
+ return "a " + m.Name
+}
+
+func mobCombatLevel(m *world.MobInstance) int {
+ return int(0.25*float64(m.Attack+m.Strength+m.Defense+m.MaxHP) + 0.5)
+}
+
+func uniqueItemNames(matches []itemMatch) []string {
+ seen := make(map[string]bool)
+ var out []string
+ for _, m := range matches {
+ if !seen[m.Name] {
+ seen[m.Name] = true
+ out = append(out, m.Name)
+ }
+ }
+ return out
+}
+
+func randInt(max int) int {
+ if max <= 0 {
+ return 0
+ }
+ return rand.Intn(max)
+}
+
+func innerPickupReport(sess *net.Session, picked []string) {
+ for i, name := range picked {
+ if i == len(picked)-1 {
+ sess.WriteLine(name)
+ } else if i == len(picked)-2 {
+ sess.Write(name + " and ")
+ } else {
+ sess.Write(name + ", ")
+ }
+ }
+}
+
+func actionDesc(p *player.Player) string {
+ if p.Action == nil {
+ return ""
+ }
+ target := p.Action.TargetName
+ if idx, ok := p.Action.Data["instance_idx"]; ok {
+ target += fmt.Sprintf(" [%d]", idx)
+ }
+ switch p.Action.Type {
+ case "gather":
+ return "mining a " + target
+ case "talk":
+ return "talking to " + target
+ case "use":
+ return "using a " + target
+ }
+ return ""
+}
diff --git a/internal/net/server.go b/internal/net/server.go
index e5761a2..8fb975f 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -25,6 +25,7 @@ const (
StatePurgeAccount
StateGame
StateChangeDescription
+ StateTalk
)
type Session struct {
diff --git a/internal/object/item.go b/internal/object/item.go
index 84e43b1..170e07f 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -31,13 +31,15 @@ type ItemDef struct {
Name string `yaml:"name"`
Aliases []string `yaml:"aliases"`
Description string `yaml:"description"`
- Value int `yaml:"value"` // credits
+ Value int `yaml:"value"`
Stackable bool `yaml:"stackable"`
EquipSlot EquipSlot `yaml:"equip_slot"`
WeaponType WeaponType `yaml:"weapon_type"`
Stats ItemStats `yaml:"stats"`
- Speed int `yaml:"speed"` // ticks between attacks
- Toolbelt bool `yaml:"toolbelt"` // can go on toolbelt
+ Speed int `yaml:"speed"`
+ Toolbelt bool `yaml:"toolbelt"`
+ ToolType string `yaml:"tool_type"`
+ ToolSpeed int `yaml:"tool_speed"`
}
type ItemStats struct {
diff --git a/internal/object/object.go b/internal/object/object.go
index 3b8741a..edee3ab 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -1,8 +1,8 @@
package object
type ObjectDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Behavior string `yaml:"behavior"`
- Props map[string]any `yaml:"props"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ BehaviorID string `yaml:"behavior"`
+ Props map[string]any `yaml:"props"`
}
diff --git a/internal/player/player.go b/internal/player/player.go
index ad56923..d6e450a 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -1,5 +1,6 @@
package player
+import "thirdcollapse/internal/action"
import "thirdcollapse/internal/object"
type SkillName string
@@ -83,10 +84,10 @@ type InventorySlot struct {
type Player struct {
Name string `yaml:"name"`
- Skills map[SkillName]int `yaml:"skills"` // xp
- Inventory map[int]*InventorySlot `yaml:"inventory"` // slot 0-27 -> item
- Equipment map[object.EquipSlot]string `yaml:"equipment"` // slot -> item_id
- Toolbelt []string `yaml:"toolbelt"` // item_ids
+ 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"`
@@ -94,6 +95,7 @@ type Player struct {
AttackStyle AttackStyle `yaml:"attack_style"`
Toggles map[string]bool `yaml:"toggles"`
RegenerateTick int
+ Action *action.Action `yaml:"-"`
}
func (p *Player) InvSlot(i int) *InventorySlot {
@@ -189,3 +191,41 @@ func (p *Player) StartRegen() {
p.RegenerateTick = 100
}
}
+
+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 {
+ return true
+ }
+ }
+ return false
+}
+
+func (p *Player) RemoveItem(itemID string, qty int) bool {
+ remaining := qty
+ for i, slot := range p.Inventory {
+ if slot == nil || slot.ItemID != itemID {
+ continue
+ }
+ if slot.Quantity <= remaining {
+ remaining -= slot.Quantity
+ delete(p.Inventory, i)
+ } else {
+ slot.Quantity -= remaining
+ remaining = 0
+ }
+ if remaining <= 0 {
+ return true
+ }
+ }
+ return remaining <= 0
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index ebf97cf..9dfec59 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -8,24 +8,20 @@ import (
"strings"
"sync"
+ "thirdcollapse/internal/action"
"gopkg.in/yaml.v3"
)
-type LootEntry struct {
- ItemID string `yaml:"item_id"`
- Weight int `yaml:"weight"`
- Quantity int `yaml:"quantity"`
-}
-
type DropTable struct {
- Remains string `yaml:"remains"`
- Loot []LootEntry `yaml:"loot"`
+ Remains string `yaml:"remains"`
+ Loot []action.DropEntry `yaml:"loot"`
}
type MobDef struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
+ BehaviorID string `yaml:"behavior"`
IdleDescriptions []string `yaml:"idle_descriptions"`
CombatDescriptions []string `yaml:"combat_descriptions"`
Attack int `yaml:"attack"`
@@ -37,33 +33,32 @@ type MobDef struct {
Protected bool `yaml:"protected"`
Unique bool `yaml:"unique"`
RespawnTicks int `yaml:"respawn_ticks"`
- Wander int `yaml:"wander"`
- WanderTick int `yaml:"wander_tick"`
- WanderChance float64 `yaml:"wander_chance"`
+ WanderRooms []int `yaml:"wander_rooms"`
+ WanderInterval int `yaml:"wander_interval"`
Drops DropTable `yaml:"drops"`
}
type MobInstance struct {
- InstanceID string
- DefID string
- Name string
- HP int
- MaxHP int
- Attack int
- Strength int
- Defense int
- Speed int
- Aggressive bool
- Protected bool
- Unique bool
- RespawnTicks int
- RoomID int
- HomeRoomID int
- Drops DropTable
- IdleDescription string
- Wander int
- WanderTick int
- WanderChance float64
+ InstanceID string
+ DefID string
+ Name string
+ BehaviorID string
+ HP int
+ MaxHP int
+ Attack int
+ Strength int
+ Defense int
+ Speed int
+ Aggressive bool
+ Protected bool
+ Unique bool
+ RespawnTicks int
+ RoomID int
+ HomeRoomID int
+ Drops DropTable
+ IdleDescription string
+ WanderRooms []int
+ WanderInterval int
WanderTickCounter int
regenerateTick int
}
@@ -147,44 +142,12 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) {
return &def, nil
}
-func (s *MobStore) SpawnMob(defID string, roomID int, instanceID string) (*MobInstance, error) {
- def, err := s.LoadDef(defID)
- if err != nil {
- return nil, err
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- inst := &MobInstance{
- InstanceID: instanceID,
- DefID: defID,
- Name: def.Name,
- HP: def.HP,
- MaxHP: def.HP,
- Attack: def.Attack,
- Strength: def.Strength,
- Defense: def.Defense,
- Speed: def.Speed,
- Aggressive: def.Aggressive,
- RespawnTicks: def.RespawnTicks,
- RoomID: roomID,
- Drops: def.Drops,
- }
- s.instances[instanceID] = inst
- return inst, nil
-}
-
func (s *MobStore) GetInstance(id string) *MobInstance {
s.mu.Lock()
defer s.mu.Unlock()
return s.instances[id]
}
-func (s *MobStore) RemoveInstance(id string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- delete(s.instances, id)
-}
-
func (s *MobStore) AllInstances() []*MobInstance {
s.mu.Lock()
defer s.mu.Unlock()
@@ -235,9 +198,8 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) {
return
}
inst.IdleDescription = pickIdleDescription(def.IdleDescriptions)
- inst.Wander = def.Wander
- inst.WanderTick = def.WanderTick
- inst.WanderChance = def.WanderChance
+ inst.WanderRooms = def.WanderRooms
+ inst.WanderInterval = def.WanderInterval
inst.WanderTickCounter = 0
}
@@ -269,6 +231,7 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
InstanceID: instID,
DefID: defID,
Name: dw.def.Name,
+ BehaviorID: dw.def.BehaviorID,
HP: dw.def.HP,
MaxHP: dw.def.HP,
Attack: dw.def.Attack,
@@ -279,9 +242,8 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
Protected: dw.def.Protected,
Unique: dw.def.Unique,
RespawnTicks: dw.def.RespawnTicks,
- Wander: dw.def.Wander,
- WanderTick: dw.def.WanderTick,
- WanderChance: dw.def.WanderChance,
+ WanderRooms: dw.def.WanderRooms,
+ WanderInterval: dw.def.WanderInterval,
RoomID: roomID,
HomeRoomID: roomID,
Drops: dw.def.Drops,
diff --git a/internal/world/room.go b/internal/world/room.go
index a92a428..6c2d0e3 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -1,5 +1,7 @@
package world
+import "gopkg.in/yaml.v3"
+
type ExitDir string
const (
@@ -41,13 +43,50 @@ type SpawnDef struct {
RespawnTicks int `yaml:"respawn_ticks"`
}
+type ExitDef struct {
+ Room int `yaml:"room"`
+ Condition *ExitCondition `yaml:"condition"`
+ BlockedMessage string `yaml:"blocked_message"`
+}
+
+func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var n int
+ if err := value.Decode(&n); err != nil {
+ return err
+ }
+ e.Room = n
+ return nil
+ }
+ type raw ExitDef
+ return value.Decode((*raw)(e))
+}
+
+type ExitCondition struct {
+ Flag string `yaml:"flag"`
+ Value any `yaml:"value"`
+ Not bool `yaml:"not"`
+}
+
type Room struct {
- ID int `yaml:"id"`
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- MapSymbol string `yaml:"map_symbol"`
- Exits map[ExitDir]int `yaml:"exits"`
- Objects []string `yaml:"objects"`
- Spawns []SpawnDef `yaml:"spawns"`
- Mobs []string `yaml:"mobs"`
+ ID int `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ MapSymbol string `yaml:"map_symbol"`
+ Exits map[ExitDir]ExitDef `yaml:"exits"`
+ Objects []RoomObject `yaml:"objects"`
+ Spawns []SpawnDef `yaml:"spawns"`
+ Mobs []string `yaml:"mobs"`
+ OnEnter []EnterStep `yaml:"on_enter"`
+}
+
+type EnterStep struct {
+ Message string `yaml:"message"`
+ Condition *ExitCondition `yaml:"condition"`
+}
+
+type RoomObject struct {
+ ID string `yaml:"id"`
+ WanderRooms []int `yaml:"wander_rooms"`
+ WanderInterval int `yaml:"wander_interval"`
}
diff --git a/internal/world/world.go b/internal/world/world.go
index 1429081..71fd518 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -2,8 +2,10 @@ package world
import (
"fmt"
+ "math/rand"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
@@ -37,6 +39,177 @@ type World struct {
mu sync.Mutex
groundItems map[int][]*groundEntry
seeded map[int]bool
+ objStates map[string]*ObjState
+ objMoves []ObjMove
+}
+
+type ObjMove struct {
+ DefID string
+ Name string
+ FromRoom int
+ ToRoom int
+}
+
+type ObjState struct {
+ Depleted bool
+ DepleteTimer int
+ DefID string
+ Name string
+ Index int
+ RoomID int
+ JustRespawned bool
+ WanderRooms []int
+ WanderInterval int
+ WanderCounter int
+}
+
+func (w *World) ObjStateKey(roomID int, defID string, index int) string {
+ return fmt.Sprintf("%d:%s:%d", roomID, defID, index)
+}
+
+func (w *World) objStateKey(roomID int, defID string, index int) string {
+ return w.ObjStateKey(roomID, defID, index)
+}
+
+func (w *World) EnsureObjectStates(roomID int, defIDs []string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.objStates == nil {
+ w.objStates = make(map[string]*ObjState)
+ }
+ counts := make(map[string]int)
+ for _, defID := range defIDs {
+ idx := counts[defID]
+ counts[defID]++
+ key := w.objStateKey(roomID, defID, idx)
+ if _, exists := w.objStates[key]; !exists {
+ w.objStates[key] = &ObjState{
+ DefID: defID,
+ Name: defID,
+ Index: idx,
+ RoomID: roomID,
+ }
+ }
+ }
+}
+
+func (w *World) GetObjState(roomID int, defID string, index int) *ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.objStates == nil {
+ return nil
+ }
+ return w.objStates[w.objStateKey(roomID, defID, index)]
+}
+
+func (w *World) GetObjStateByKey(key string) *ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if w.objStates == nil {
+ return nil
+ }
+ return w.objStates[key]
+}
+
+func (w *World) AllObjInstances(roomID int) []ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []ObjState
+ for _, st := range w.objStates {
+ if st.RoomID == roomID {
+ out = append(out, *st)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].DefID != out[j].DefID {
+ return out[i].DefID < out[j].DefID
+ }
+ return out[i].Index < out[j].Index
+ })
+ return out
+}
+
+func (w *World) FindObjInstances(roomID int, name string) []ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []ObjState
+ lower := strings.ToLower(name)
+ for key, st := range w.objStates {
+ if st.RoomID != roomID {
+ continue
+ }
+ if !wordMatchesObj(lower, st.DefID, st.Name) {
+ continue
+ }
+ out = append(out, ObjState{
+ DefID: st.DefID,
+ Name: st.Name,
+ Index: st.Index,
+ RoomID: st.RoomID,
+ Depleted: st.Depleted,
+ DepleteTimer: st.DepleteTimer,
+ })
+ _ = key
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].DefID != out[j].DefID {
+ return out[i].DefID < out[j].DefID
+ }
+ return out[i].Index < out[j].Index
+ })
+ return out
+}
+
+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
+ }
+ }
+ // Check defID both as whole and split by underscores
+ if strings.HasPrefix(strings.ToLower(defID), lower) {
+ return true
+ }
+ for _, part := range strings.Split(defID, "_") {
+ if strings.HasPrefix(strings.ToLower(part), lower) {
+ return true
+ }
+ }
+ return false
+}
+
+func (w *World) SetObjName(roomID int, defID string, name string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for key, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID {
+ st.Name = name
+ }
+ _ = key
+ }
+}
+
+func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for key, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID {
+ st.WanderRooms = rooms
+ st.WanderInterval = interval
+ }
+ _ = key
+ }
+}
+
+func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ key := w.objStateKey(roomID, defID, index)
+ if st, ok := w.objStates[key]; ok {
+ st.Depleted = true
+ st.DepleteTimer = delay
+ }
}
func New(dataDir string) *World {
@@ -44,6 +217,7 @@ func New(dataDir string) *World {
dataDir: dataDir,
groundItems: make(map[int][]*groundEntry),
seeded: make(map[int]bool),
+ objStates: make(map[string]*ObjState),
}
}
@@ -59,10 +233,7 @@ func (w *World) LoadRoom(id int) (*Room, error) {
}
room.ID = id
if room.Exits == nil {
- room.Exits = make(map[ExitDir]int)
- }
- if room.Objects == nil {
- room.Objects = make([]string, 0)
+ room.Exits = make(map[ExitDir]ExitDef)
}
if room.Spawns == nil {
room.Spawns = make([]SpawnDef, 0)
@@ -261,4 +432,75 @@ func (w *World) Tick() {
}
}
}
+
+ for _, st := range w.objStates {
+ if st.Depleted && st.DepleteTimer > 0 {
+ st.DepleteTimer--
+ if st.DepleteTimer <= 0 {
+ st.Depleted = false
+ st.JustRespawned = true
+ }
+ }
+ }
+}
+
+func (w *World) FlushObjRespawns() []ObjState {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var out []ObjState
+ for _, st := range w.objStates {
+ if st.JustRespawned {
+ out = append(out, *st)
+ st.JustRespawned = false
+ }
+ }
+ return out
+}
+
+func (w *World) TickObjWander() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var moves []struct {
+ st *ObjState
+ toRoom int
+ }
+ for _, st := range w.objStates {
+ if len(st.WanderRooms) == 0 || st.WanderInterval <= 0 {
+ continue
+ }
+ st.WanderCounter++
+ if st.WanderCounter >= st.WanderInterval {
+ st.WanderCounter = 0
+ toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))]
+ if toRoom == st.RoomID {
+ continue
+ }
+ moves = append(moves, struct {
+ st *ObjState
+ toRoom int
+ }{st, toRoom})
+ }
+ }
+ for _, m := range moves {
+ oldKey := w.objStateKey(m.st.RoomID, m.st.DefID, m.st.Index)
+ fromRoom := m.st.RoomID
+ m.st.RoomID = m.toRoom
+ newKey := w.objStateKey(m.st.RoomID, m.st.DefID, m.st.Index)
+ delete(w.objStates, oldKey)
+ w.objStates[newKey] = m.st
+ w.objMoves = append(w.objMoves, ObjMove{
+ DefID: m.st.DefID,
+ Name: m.st.Name,
+ FromRoom: fromRoom,
+ ToRoom: m.toRoom,
+ })
+ }
+}
+
+func (w *World) FlushObjMoves() []ObjMove {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ out := w.objMoves
+ w.objMoves = nil
+ return out
}