diff options
| author | historia <[not public]> | 2026-06-10 01:48:28 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-10 01:48:28 -0400 |
| commit | a226d72e51eecb768b13600303f73483118d9104 (patch) | |
| tree | 458d15ef049732c15dacc591a830d3beddbedfde /internal/game | |
| parent | 6a0f3d7a252de4b1741cfe5e1c561412c602becc (diff) | |
| download | thehouseoficarus-a226d72e51eecb768b13600303f73483118d9104.tar.gz | |
feat: implemented janky object interaction model
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/action.go | 765 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 471 | ||||
| -rw-r--r-- | internal/game/cmd_description.go | 34 | ||||
| -rw-r--r-- | internal/game/cmd_drop.go | 48 | ||||
| -rw-r--r-- | internal/game/cmd_get.go | 251 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 403 | ||||
| -rw-r--r-- | internal/game/cmd_misc.go | 117 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 122 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 53 | ||||
| -rw-r--r-- | internal/game/cmd_ticktest.go | 21 | ||||
| -rw-r--r-- | internal/game/cmd_toggle.go | 54 | ||||
| -rw-r--r-- | internal/game/game.go | 2177 | ||||
| -rw-r--r-- | internal/game/session.go | 500 | ||||
| -rw-r--r-- | internal/game/tick.go | 141 | ||||
| -rw-r--r-- | internal/game/utils.go | 114 |
15 files changed, 3134 insertions, 2137 deletions
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 "" +} |
