diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | cmd/mud/main.go | 6 | ||||
| -rw-r--r-- | data/help/attack.yaml | 10 | ||||
| -rw-r--r-- | data/help/combat.yaml | 5 | ||||
| -rw-r--r-- | data/help/description.yaml | 11 | ||||
| -rw-r--r-- | data/help/exits.yaml | 9 | ||||
| -rw-r--r-- | data/help/look.yaml | 12 | ||||
| -rw-r--r-- | data/help/movement.yaml | 6 | ||||
| -rw-r--r-- | data/help/quit.yaml | 11 | ||||
| -rw-r--r-- | data/help/toggle.yaml | 17 | ||||
| -rw-r--r-- | data/mobs/man.yaml | 17 | ||||
| -rw-r--r-- | data/mobs/newbie_trainer.yaml | 20 | ||||
| -rw-r--r-- | data/players/accounts/Default.yaml | 4 | ||||
| -rw-r--r-- | data/players/characters/guy4.yaml (renamed from data/players/characters/Default.yaml) | 13 | ||||
| -rw-r--r-- | data/rooms/1.yaml | 1 | ||||
| -rw-r--r-- | internal/combat/state.go | 28 | ||||
| -rw-r--r-- | internal/game/game.go | 892 | ||||
| -rw-r--r-- | internal/net/server.go | 37 | ||||
| -rw-r--r-- | internal/object/item.go | 4 | ||||
| -rw-r--r-- | internal/player/player.go | 62 | ||||
| -rw-r--r-- | internal/world/mob.go | 185 | ||||
| -rw-r--r-- | internal/world/world.go | 82 |
22 files changed, 1273 insertions, 161 deletions
@@ -1,2 +1,4 @@ +/data/player/accounts/* +/data/player/characters/* TODO.md tc diff --git a/cmd/mud/main.go b/cmd/mud/main.go index d04bd9b..1c1e1eb 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -8,6 +8,7 @@ import ( "strconv" "syscall" + "thirdcollapse/internal/combat" "thirdcollapse/internal/game" "thirdcollapse/internal/net" ) @@ -33,6 +34,11 @@ func main() { g.Ticks.Subscribe(1, func() bool { g.World.Tick() + g.MobStore.Tick() + combat.TickCombat() + g.RegenTick() + g.DisconnectTick() + g.WanderTick() return true }) diff --git a/data/help/attack.yaml b/data/help/attack.yaml index 5c9f7a2..8c0bec3 100644 --- a/data/help/attack.yaml +++ b/data/help/attack.yaml @@ -4,6 +4,12 @@ description: | Attack a mob in the current room. Usage: attack <mob>, kill <mob>, fight <mob> + attack 1.man, kill 2.man - Enter combat with the named mob. Attacks occur at weapon speed on the - 600ms tick. Use "style" to change combat style before attacking. + Enter combat with the named mob. If multiple mobs with different names + match your input, you'll be asked "Which one?" — type a more specific + name. Prefix with a number to target a specific instance of the same + name (e.g. "attack 1.man"). Plain "attack man" targets the first. + + Attacks occur at weapon speed on the 600ms tick. Use "style" to change + combat style. You are locked in place for 15 ticks when combat starts. diff --git a/data/help/combat.yaml b/data/help/combat.yaml index c8a2b16..e2ccef6 100644 --- a/data/help/combat.yaml +++ b/data/help/combat.yaml @@ -8,4 +8,7 @@ description: | - Attack, Strength, and Defense rolls determine hit/miss - Combat triangle: Melee > Ranged > Technology > Melee - 4 XP per point of damage, distributed by combat style - - Movement interrupts combat immediately + - You are locked in place for 15 ticks when combat starts + - Damaged mobs show current HP in look (damaged listed first) + - Mobs regenerate 1 HP every 100 ticks (60 seconds) + - Players also regenerate 1 HP every 100 ticks diff --git a/data/help/description.yaml b/data/help/description.yaml new file mode 100644 index 0000000..ab6afa1 --- /dev/null +++ b/data/help/description.yaml @@ -0,0 +1,11 @@ +name: "description" +category: "Character" +description: | + View or change your character description. + + Usage: description, desc + + Shows your current description (if any) and prompts you to enter a new + one. Other players can see your description by looking at you with + "look <name>". Press enter without typing anything to keep your + current description. diff --git a/data/help/exits.yaml b/data/help/exits.yaml new file mode 100644 index 0000000..673e789 --- /dev/null +++ b/data/help/exits.yaml @@ -0,0 +1,9 @@ +name: "exits" +category: "Exploration" +description: | + List exits from the current room. + + Usage: exits + + Shows each exit and the name of the room it leads to. Also toggleable + via "toggle exits" to show destinations inline in the look output. diff --git a/data/help/look.yaml b/data/help/look.yaml index 30b1d27..17faa45 100644 --- a/data/help/look.yaml +++ b/data/help/look.yaml @@ -1,9 +1,15 @@ name: "look" category: "Exploration" description: | - Look around the current room. + Look around the current room or examine a specific target. Usage: look, l + look <target> - Shows the room name, description, exits, objects, mobs, other players, - and items on the ground. + Without a target, shows the room name, description, exits, objects, + mobs, other players, and ground items. Damaged mobs show current HP + (e.g. "man (level 1) [3/7hp]"). + + With a target, shows details about a mob, object, item (ground or + inventory), or another player. Looking at items shows their value. + Looking at players shows their stats, equipment, and description. diff --git a/data/help/movement.yaml b/data/help/movement.yaml index d40f192..9b4f81c 100644 --- a/data/help/movement.yaml +++ b/data/help/movement.yaml @@ -6,4 +6,8 @@ description: | Usage: north, south, east, west, up, down Aliases: n, s, e, w, u, d - Movement is instant. Combat is interrupted if you leave the room. + You cannot move for 15 ticks after combat starts. + Combat is interrupted if you leave the room. + + With "description" toggled on, moving shows the full room description + instead of just the room name. Use "toggle" to change this setting. diff --git a/data/help/quit.yaml b/data/help/quit.yaml index 286281a..c41771b 100644 --- a/data/help/quit.yaml +++ b/data/help/quit.yaml @@ -1,8 +1,15 @@ name: "quit" category: "Character" description: | - Quit the game and disconnect. + Rest and return to the account menu. Usage: quit - Saves your character and closes the connection. + Cannot be used during combat. Starts a 10-tick rest sequence: + 10 ticks: "You sit down to rest..." + 6 ticks: "You catch your breath..." + 3 ticks: "You close your eyes..." + 0 ticks: Returns to account menu + + The rest is cancelled if you take any action or if combat starts. + Saves your character when the timer completes. diff --git a/data/help/toggle.yaml b/data/help/toggle.yaml new file mode 100644 index 0000000..44a2b33 --- /dev/null +++ b/data/help/toggle.yaml @@ -0,0 +1,17 @@ +name: "toggle" +category: "General" +description: | + Toggle character settings on or off. + + Usage: toggle - List all toggles and their current status + toggle <name> - Toggle a setting on or off + + Toggles: + description - Long room descriptions when moving between rooms + tinymap - Mini-map display (not yet implemented) + xpdrops - Show XP gained in combat hit messages + exits - Show exit destinations in look output + mobenter - Notify when a mob enters the room + mobleave - Notify when a mob leaves the room + mobspawn - Notify when a mob spawns in the area + reserve - Show full reserved item details in look diff --git a/data/mobs/man.yaml b/data/mobs/man.yaml index 074e1f9..79de657 100644 --- a/data/mobs/man.yaml +++ b/data/mobs/man.yaml @@ -1,5 +1,19 @@ id: "man" name: "man" +description: "A shabby-looking man loitering in the town square." +combat_descriptions: + - "is engaged in a fight to the death with %s" + - "is getting pummelled by %s" + - "is locked in combat with %s" + - "trades blows with %s" + - "circles warily around %s" +idle_descriptions: + - "scribbles something in a small notebook" + - "gazes skyward at the clouds" + - "leans against a wall, looking bored" + - "scratches his head thoughtfully" + - "stares off into the distance" + - "adjusts his tunic and stretches" attack: 1 strength: 1 defense: 1 @@ -7,6 +21,9 @@ hp: 7 speed: 5 aggressive: false respawn_ticks: 30 +wander: 3 +wander_tick: 10 +wander_chance: 0.25 drops: remains: "bones" loot: diff --git a/data/mobs/newbie_trainer.yaml b/data/mobs/newbie_trainer.yaml new file mode 100644 index 0000000..c518d42 --- /dev/null +++ b/data/mobs/newbie_trainer.yaml @@ -0,0 +1,20 @@ +id: "newbie_trainer" +name: "Newbie Trainer" +aliases: ["trainer", "newbie", "instructor"] +description: "A friendly-looking instructor ready to help new adventurers." +unique: true +protected: true +attack: 1 +strength: 1 +defense: 1 +hp: 50 +speed: 5 +aggressive: false +respawn_ticks: 30 +idle_descriptions: + - "reviews a training manual" + - "adjusts a practice dummy" + - "demonstrates a sword stance to no one in particular" + - "offers a friendly nod to passersby" + - "polishes a wooden shield" + - "checks a stopwatch and jots something down" diff --git a/data/players/accounts/Default.yaml b/data/players/accounts/Default.yaml deleted file mode 100644 index 32ae393..0000000 --- a/data/players/accounts/Default.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: Default -password_hash: sha256:mfnZDVUFEvzM4AUQ4Kgnrw:f82f8f7fa0dbb1238bf41d6dc63c30b2fbf67a4cebcdbbaba3572422545e2fe3 -characters: - - Default diff --git a/data/players/characters/Default.yaml b/data/players/characters/guy4.yaml index 83142ea..4141899 100644 --- a/data/players/characters/Default.yaml +++ b/data/players/characters/guy4.yaml @@ -1,4 +1,4 @@ -name: Default +name: guy4 skills: agility: 0 alchemy: 0 @@ -26,4 +26,15 @@ toolbelt: [] room_id: 1 hp: 10 credits: 0 +description: "" attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml index 7d1f7c5..97615a6 100644 --- a/data/rooms/1.yaml +++ b/data/rooms/1.yaml @@ -12,5 +12,6 @@ spawns: quantity: 1 respawn_ticks: 10 mobs: + - "newbie_trainer" - "man" - "man" diff --git a/internal/combat/state.go b/internal/combat/state.go index b0e7dfe..0d8de45 100644 --- a/internal/combat/state.go +++ b/internal/combat/state.go @@ -5,9 +5,10 @@ import "sync" type State struct { PlayerName string MobID string - MobAttacks int // attacks mob has made (3-hit flee rule) - PlayerDamage int // total damage player dealt this combat + MobAttacks int // attacks mob has made (3-hit flee rule) + PlayerDamage int // total damage player dealt this combat Active bool + LockedTicks int // ticks remaining before player can move } var ( @@ -20,9 +21,10 @@ func EnterCombat(playerName, mobID string) { mu.Lock() defer mu.Unlock() combatants[playerName] = &State{ - PlayerName: playerName, - MobID: mobID, - Active: true, + PlayerName: playerName, + MobID: mobID, + Active: true, + LockedTicks: 15, } mobTargets[mobID] = playerName } @@ -51,6 +53,12 @@ func IsMobInCombat(mobID string) bool { return ok } +func GetMobTarget(mobID string) string { + mu.Lock() + defer mu.Unlock() + return mobTargets[mobID] +} + func RecordMobAttack(playerName string) { mu.Lock() defer mu.Unlock() @@ -77,6 +85,16 @@ func RecordPlayerDamage(playerName string, dmg int) { } } +func TickCombat() { + mu.Lock() + defer mu.Unlock() + for _, state := range combatants { + if state.LockedTicks > 0 { + state.LockedTicks-- + } + } +} + func GetTotalDamage(playerName string) int { mu.Lock() defer mu.Unlock() diff --git a/internal/game/game.go b/internal/game/game.go index 67434d3..0e1b82d 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -2,8 +2,10 @@ package game import ( "fmt" + "math/rand" "os" "sort" + "strconv" "strings" "thirdcollapse/internal/combat" @@ -23,6 +25,9 @@ type Game struct { 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 + combatPadWidth int } func New(dataDir string) *Game { @@ -34,11 +39,18 @@ func New(dataDir string) *Game { MobStore: world.NewMobStore(dataDir), Ticks: engine.New(), dataDir: dataDir, + restTimers: make(map[string]uint64), + loggedInChars: make(map[string]*net.Session), } } func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub + hub.OnRemove(func(sess *net.Session) { + if p, ok := sess.Player.(*player.Player); ok { + delete(g.loggedInChars, p.Name) + } + }) } func (g *Game) HandleSession(sess *net.Session, input string) { @@ -67,6 +79,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handlePurgeAccount(sess, input) case net.StateGame: g.handleGameCommand(sess, input) + case net.StateChangeDescription: + g.handleDescriptionChange(sess, input) } } @@ -536,6 +550,13 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { } 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)) @@ -546,6 +567,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) { sess.Player = p sess.State = net.StateGame + g.loggedInChars[name] = sess g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) @@ -563,7 +585,11 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { sess.Write("> ") return } - + + if p, ok := sess.Player.(*player.Player); ok { + g.cancelRest(p.Name) + } + parts := strings.Fields(strings.ToLower(input)) cmd := parts[0] args := parts[1:] @@ -588,6 +614,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { sess.WriteLine("Attack what?") } else { g.doAttack(sess, strings.Join(args, " ")) + return } case "style": if len(args) == 0 { @@ -596,7 +623,11 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doStyle(sess, args[0]) } case "look", "l": - g.doLook(sess) + if len(args) == 0 { + g.doLook(sess) + } else { + g.doLookTarget(sess, strings.Join(args, " ")) + } case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d": g.doMove(sess, cmd) case "say": @@ -614,6 +645,12 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { case "quit": g.doQuit(sess) return + case "description", "desc": + g.doDescription(sess) + case "toggle": + g.doToggle(sess, strings.Join(args, " ")) + case "exits": + g.doExits(sess) case "ticktest": g.doTickTest(sess) case "help": @@ -629,6 +666,51 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { 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) @@ -655,6 +737,12 @@ func (g *Game) doMove(sess *net.Session, dir string) { 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) @@ -682,9 +770,13 @@ func (g *Game) doMove(sess *net.Session, dir string) { } sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) - targetRoom, _ := g.World.LoadRoom(targetID) - if targetRoom != nil { - sess.WriteLine(targetRoom.Name) + if p.Toggles["description"] { + g.doLook(sess) + } else { + targetRoom, _ := g.World.LoadRoom(targetID) + if targetRoom != nil { + sess.WriteLine(targetRoom.Name) + } } } @@ -728,61 +820,102 @@ func (g *Game) doLook(sess *net.Session) { "", room.Name, room.Description, - "", ) - if len(room.Exits) > 0 { - 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 + // 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 } - } - sess.WriteLine("") - } - - if len(room.Objects) > 0 { + return mobs[i].InstanceID < mobs[j].InstanceID + }) sess.WriteLine("") - for _, objID := range room.Objects { - def, err := g.ObjectStore.Load(objID) - if err != nil { - sess.WriteLine(fmt.Sprintf(" - %s", objID)) - } else { - sess.WriteLine(fmt.Sprintf(" - %s", def.Name)) + 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.GroundItems(p.RoomID) + ground := g.World.GroundItemsDetailed(p.RoomID) if len(ground) > 0 { sess.WriteLine("") sess.WriteLine("On the ground:") - for itemID, qty := range ground { - def, err := g.ItemStore.Load(itemID) - name := itemID + for _, info := range ground { + def, err := g.ItemStore.Load(info.ItemID) + name := info.ItemID if err == nil { name = def.Name } - if qty > 1 { - sess.WriteLine(fmt.Sprintf(" %d x %s", qty, name)) + line := "" + if info.Quantity > 1 { + line = fmt.Sprintf(" %d x %s", info.Quantity, name) } else { - sess.WriteLine(fmt.Sprintf(" %s", name)) + 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) } } - // Mobs - mobs := g.MobStore.MobsInRoom(p.RoomID) - if len(mobs) > 0 { + // Exits + if len(room.Exits) > 0 { sess.WriteLine("") - for _, m := range mobs { - sess.WriteLine(fmt.Sprintf(" %s (level %d)", m.Name, mobCombatLevel(m))) + 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("") } } @@ -791,7 +924,13 @@ func (g *Game) doLook(sess *net.Session) { for _, other := range others { if other != sess && other.Player != nil { op := other.Player.(*player.Player) - sess.WriteLine(fmt.Sprintf("\n%s is here.", op.Name)) + 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 + ".") } } } @@ -881,16 +1020,55 @@ func (g *Game) doEquipment(sess *net.Session) { } } +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, ok := sess.Player.(*player.Player) - if ok { - g.AccountStore.SaveCharacter(p) - if g.Hub != nil { - g.Hub.LeaveRoom(sess) + 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 "" } - sess.WriteLine("\nGoodbye!") - sess.Conn.Close() + return "s" } func parseIndex(s string) (int, error) { @@ -903,7 +1081,11 @@ func parseIndex(s string) (int, error) { } func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { - qty := g.World.RemoveGroundItem(p.RoomID, itemID, 999999) + qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } if qty <= 0 { return } @@ -930,7 +1112,10 @@ func (g *Game) doGetAll(sess *net.Session) { continue } if itemID == "credits" { - taken := g.World.RemoveGroundItem(p.RoomID, itemID, 999999) + 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") @@ -940,15 +1125,20 @@ func (g *Game) doGetAll(sess *net.Session) { continue } - def, _ := g.ItemStore.Load(itemID) + 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 - g.World.RemoveGroundItem(p.RoomID, itemID, qty) picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity)) stacked = true qty = 0 @@ -958,7 +1148,6 @@ func (g *Game) doGetAll(sess *net.Session) { if stacked { break } - // No existing stack — take all into one new slot freeSlot := p.FirstFreeSlot() if freeSlot == -1 { if len(picked) > 0 { @@ -970,7 +1159,10 @@ func (g *Game) doGetAll(sess *net.Session) { g.AccountStore.SaveCharacter(p) return } - g.World.RemoveGroundItem(p.RoomID, itemID, qty) + _, 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 { @@ -993,7 +1185,10 @@ func (g *Game) doGetAll(sess *net.Session) { } take := 1 - g.World.RemoveGroundItem(p.RoomID, itemID, take) + _, 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 { @@ -1014,6 +1209,218 @@ func (g *Game) doGetAll(sess *net.Session) { } } +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)) + } + 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 { @@ -1079,8 +1486,13 @@ func (g *Game) doGet(sess *net.Session, input string) { 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.World.RemoveGroundItem(p.RoomID, itemID, qty) g.AccountStore.SaveCharacter(p) name := def.Name sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", name, slot.Quantity)) @@ -1089,8 +1501,13 @@ func (g *Game) doGet(sess *net.Session, input string) { } } + 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.World.RemoveGroundItem(p.RoomID, itemID, qty) g.AccountStore.SaveCharacter(p) name := itemID if def != nil { @@ -1213,9 +1630,8 @@ func (g *Game) doAttack(sess *net.Session, input string) { return } - mob := g.findMob(input, p.RoomID) + mob := g.findMob(sess, input, p.RoomID) if mob == nil { - sess.WriteLine(fmt.Sprintf("There's no '%s' here to attack.", input)) return } @@ -1224,18 +1640,80 @@ func (g *Game) doAttack(sess *net.Session, input string) { 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(input string, roomID int) *world.MobInstance { +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 { - if strings.ToLower(m.Name) == lower { - return m + q := m.MatchQuality(name) + if q == world.MatchExact { + exact = append(exact, m) + } else if q == world.MatchPrefix { + prefix = append(prefix, m) } } - return nil + + // 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) { @@ -1250,14 +1728,30 @@ func (g *Game) seedRoomMobs(roomID int) { } func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - // Find a unique instance ID for this mob + g.cancelRest(p.Name) + instanceID := g.findMobInstanceID(mob) combat.EnterCombat(p.Name, instanceID) playerSpeed := g.playerWeaponSpeed(p) - sess.WriteLine(fmt.Sprintf("\nYou attack the %s!", mob.Name)) + 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 { @@ -1322,11 +1816,34 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI if mob.HP < 0 { mob.HP = 0 } - g.awardCombatXP(p, dmg) - - sess.WriteLine(fmt.Sprintf(" You hit the %s for %d damage. (%d/%d HP)", mob.Name, dmg, mob.HP, mob.MaxHP)) + 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 the %s.", mob.Name)) + sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))) } } @@ -1352,15 +1869,174 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst if p.HP < 0 { p.HP = 0 } + p.StartRegen() g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf(" The %s hits you for %d damage. (%d/%d HP)", mob.Name, dmg, p.HP, p.MaxHP())) + 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 { - sess.WriteLine(fmt.Sprintf(" The %s misses you.", mob.Name)) + 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 { @@ -1377,17 +2053,28 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if mob != nil && mob.HP <= 0 { - sess.WriteLine(fmt.Sprintf("\nYou have defeated the %s!", mob.Name)) - + 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.AddGroundItem(p.RoomID, mob.Drops.Remains, 1) + 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 } - sess.WriteLine(fmt.Sprintf(" The %s drops: %s", mob.Name, 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 @@ -1401,16 +2088,20 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst for _, e := range mob.Drops.Loot { cumulative += e.Weight if roll < cumulative { - g.World.AddGroundItem(p.RoomID, e.ItemID, e.Quantity) + 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(" The %s drops: %d x %s", mob.Name, e.Quantity, name)) + sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, e.Quantity, name)) } else { - sess.WriteLine(fmt.Sprintf(" The %s drops: %s", mob.Name, name)) + sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name)) } break } @@ -1430,28 +2121,32 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } -func (g *Game) awardCombatXP(p *player.Player, dmg int) { +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: - p.AddXP(player.Attack, baseXP*3/4) - p.AddXP(player.Hitpoints, baseXP/4) + gains = []xpGain{{player.Attack, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} case player.Aggressive: - p.AddXP(player.Strength, baseXP*3/4) - p.AddXP(player.Hitpoints, baseXP/4) + gains = []xpGain{{player.Strength, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} case player.Defensive: - p.AddXP(player.Defense, baseXP*3/4) - p.AddXP(player.Hitpoints, baseXP/4) + gains = []xpGain{{player.Defense, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} case player.Balanced: quarter := baseXP / 4 - p.AddXP(player.Attack, quarter) - p.AddXP(player.Strength, quarter) - p.AddXP(player.Defense, quarter) - p.AddXP(player.Hitpoints, quarter) + 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 { @@ -1538,7 +2233,18 @@ func (g *Game) respawnMob(instanceID string) { 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 { @@ -1555,6 +2261,16 @@ func (g *Game) playerWeaponSpeed(p *player.Player) int { 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) diff --git a/internal/net/server.go b/internal/net/server.go index 2a10a9a..fa0b509 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -24,6 +24,7 @@ const ( StateDeleteChar StatePurgeAccount StateGame + StateChangeDescription ) type Session struct { @@ -32,8 +33,10 @@ type Session struct { State SessionState Account *AccountEntry Player interface{} // *player.Player once character is selected - PendingChar string // char being renamed/deleted - PendingPass string // first password during signup + PendingChar string // char being renamed/deleted + PendingPass string // first password during signup + Disconnecting bool + DisconnectTicks int } type AccountEntry struct { @@ -48,9 +51,9 @@ type Server struct { } type Hub struct { - sessions map[*Session]bool - // roomID -> sessions - rooms map[int]map[*Session]bool + sessions map[*Session]bool + rooms map[int]map[*Session]bool + onRemove func(*Session) } func NewHub() *Hub { @@ -60,15 +63,31 @@ func NewHub() *Hub { } } +func (h *Hub) OnRemove(cb func(*Session)) { + h.onRemove = cb +} + func (h *Hub) Add(s *Session) { h.sessions[s] = true } func (h *Hub) Remove(s *Session) { + if s.Player != nil && s.State == StateGame && !s.Disconnecting { + s.Disconnecting = true + s.DisconnectTicks = 10 + return + } + h.HardRemove(s) +} + +func (h *Hub) HardRemove(s *Session) { delete(h.sessions, s) for _, room := range h.rooms { delete(room, s) } + if h.onRemove != nil { + h.onRemove(s) + } } func (h *Hub) EnterRoom(s *Session, roomID int) { @@ -85,6 +104,14 @@ func (h *Hub) LeaveRoom(s *Session) { } } +func (h *Hub) AllSessions() []*Session { + var out []*Session + for s := range h.sessions { + out = append(out, s) + } + return out +} + func (h *Hub) PlayersInRoom(roomID int) []*Session { var out []*Session if room, ok := h.rooms[roomID]; ok { diff --git a/internal/object/item.go b/internal/object/item.go index 67a2998..84e43b1 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -53,8 +53,8 @@ func (d *ItemDef) MatchesName(input string) bool { if strings.ToLower(d.Name) == lower { return true } - for _, a := range d.Aliases { - if strings.ToLower(a) == lower { + for _, word := range strings.Fields(d.Name) { + if strings.HasPrefix(strings.ToLower(word), lower) { return true } } diff --git a/internal/player/player.go b/internal/player/player.go index 33689c5..581fc04 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -33,6 +33,29 @@ var AllSkills = []SkillName{ Alchemy, Thieving, Agility, Construction, Scavenging, Hunter, } +var SkillAbbr = map[SkillName]string{ + Attack: "atk", + Strength: "str", + Defense: "def", + Hitpoints: "hp", + Ranged: "rng", + Science: "sci", + Technology: "tec", + Fishing: "fis", + Cooking: "cok", + Woodcutting: "wct", + Mining: "min", + Smithing: "smt", + Crafting: "cft", + Fletching: "flt", + Alchemy: "alc", + Thieving: "thv", + Agility: "agl", + Construction: "con", + Scavenging: "scv", + Hunter: "hnt", +} + type AttackStyle string const ( @@ -57,15 +80,18 @@ type InventorySlot struct { } type Player struct { - Name string `yaml:"name"` - Skills map[SkillName]int `yaml:"skills"` // xp - Inventory map[int]*InventorySlot `yaml:"inventory"` // slot 0-27 -> item - Equipment map[object.EquipSlot]string `yaml:"equipment"` // slot -> item_id - Toolbelt []string `yaml:"toolbelt"` // item_ids - RoomID int `yaml:"room_id"` - HP int `yaml:"hp"` - Credits int `yaml:"credits"` - AttackStyle AttackStyle `yaml:"attack_style"` + Name string `yaml:"name"` + Skills map[SkillName]int `yaml:"skills"` // xp + Inventory map[int]*InventorySlot `yaml:"inventory"` // slot 0-27 -> item + Equipment map[object.EquipSlot]string `yaml:"equipment"` // slot -> item_id + Toolbelt []string `yaml:"toolbelt"` // item_ids + RoomID int `yaml:"room_id"` + HP int `yaml:"hp"` + Credits int `yaml:"credits"` + Description string `yaml:"description"` + AttackStyle AttackStyle `yaml:"attack_style"` + Toggles map[string]bool `yaml:"toggles"` + RegenerateTick int } func (p *Player) InvSlot(i int) *InventorySlot { @@ -106,7 +132,17 @@ func New(name string) *Player { Skills: make(map[SkillName]int), Equipment: make(map[object.EquipSlot]string), AttackStyle: Accurate, - RoomID: 0, + Toggles: map[string]bool{ + "description": true, + "tinymap": true, + "xpdrops": true, + "exits": true, + "mobenter": true, + "mobleave": true, + "mobspawn": true, + "reserve": true, + }, + RoomID: 0, } for _, s := range AllSkills { p.Skills[s] = 0 @@ -145,3 +181,9 @@ func (p *Player) CombatLevel() int { func (p *Player) MaxHP() int { return p.Level(Hitpoints) } + +func (p *Player) StartRegen() { + if p.RegenerateTick == 0 { + p.RegenerateTick = 100 + } +} diff --git a/internal/world/mob.go b/internal/world/mob.go index 601cdc1..ebf97cf 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -2,8 +2,10 @@ package world import ( "fmt" + "math/rand" "os" "path/filepath" + "strings" "sync" "gopkg.in/yaml.v3" @@ -21,32 +23,89 @@ type DropTable struct { } type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Attack int `yaml:"attack"` - Strength int `yaml:"strength"` - Defense int `yaml:"defense"` - HP int `yaml:"hp"` - Speed int `yaml:"speed"` - Aggressive bool `yaml:"aggressive"` - RespawnTicks int `yaml:"respawn_ticks"` - Drops DropTable `yaml:"drops"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + IdleDescriptions []string `yaml:"idle_descriptions"` + CombatDescriptions []string `yaml:"combat_descriptions"` + Attack int `yaml:"attack"` + Strength int `yaml:"strength"` + Defense int `yaml:"defense"` + HP int `yaml:"hp"` + Speed int `yaml:"speed"` + Aggressive bool `yaml:"aggressive"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + RespawnTicks int `yaml:"respawn_ticks"` + Wander int `yaml:"wander"` + WanderTick int `yaml:"wander_tick"` + WanderChance float64 `yaml:"wander_chance"` + Drops DropTable `yaml:"drops"` } type MobInstance struct { - InstanceID string - DefID string - Name string - HP int - MaxHP int - Attack int - Strength int - Defense int - Speed int - Aggressive bool - RespawnTicks int - RoomID int - Drops DropTable + InstanceID string + DefID string + Name string + HP int + MaxHP int + Attack int + Strength int + Defense int + Speed int + Aggressive bool + Protected bool + Unique bool + RespawnTicks int + RoomID int + HomeRoomID int + Drops DropTable + IdleDescription string + Wander int + WanderTick int + WanderChance float64 + WanderTickCounter int + regenerateTick int +} + +const ( + MatchNone = 0 + MatchPrefix = 1 + MatchExact = 2 +) + +func WordPrefixMatch(input, name string) bool { + lower := strings.ToLower(input) + for _, word := range strings.Fields(name) { + if strings.HasPrefix(strings.ToLower(word), lower) { + return true + } + } + return false +} + +func (m *MobInstance) MatchQuality(input string) int { + lower := strings.ToLower(input) + if strings.ToLower(m.Name) == lower { + return MatchExact + } + if WordPrefixMatch(input, m.Name) { + return MatchPrefix + } + return MatchNone +} + +func pickIdleDescription(descriptions []string) string { + if len(descriptions) == 0 { + return "" + } + return descriptions[rand.Intn(len(descriptions))] +} + +func (m *MobInstance) StartRegen() { + if m.regenerateTick == 0 { + m.regenerateTick = 100 + } } type MobStore struct { @@ -126,6 +185,16 @@ func (s *MobStore) RemoveInstance(id string) { delete(s.instances, id) } +func (s *MobStore) AllInstances() []*MobInstance { + s.mu.Lock() + defer s.mu.Unlock() + var out []*MobInstance + for _, inst := range s.instances { + out = append(out, inst) + } + return out +} + func (s *MobStore) MobsInRoom(roomID int) []*MobInstance { s.mu.Lock() defer s.mu.Unlock() @@ -138,6 +207,40 @@ func (s *MobStore) MobsInRoom(roomID int) []*MobInstance { return out } +func (s *MobStore) Tick() { + s.mu.Lock() + defer s.mu.Unlock() + + for _, inst := range s.instances { + if inst.HP <= 0 || inst.HP >= inst.MaxHP { + inst.regenerateTick = 0 + continue + } + inst.regenerateTick-- + if inst.regenerateTick <= 0 { + inst.HP++ + if inst.HP >= inst.MaxHP { + inst.HP = inst.MaxHP + inst.regenerateTick = 0 + } else { + inst.regenerateTick = 100 + } + } + } +} + +func (s *MobStore) RollIdleDescription(inst *MobInstance) { + def, err := s.LoadDef(inst.DefID) + if err != nil { + return + } + inst.IdleDescription = pickIdleDescription(def.IdleDescriptions) + inst.Wander = def.Wander + inst.WanderTick = def.WanderTick + inst.WanderChance = def.WanderChance + inst.WanderTickCounter = 0 +} + func (s *MobStore) SeedMobs(roomID int, mobIDs []string) { type defWrapper struct { def *MobDef @@ -162,20 +265,28 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) { _ = inst continue } - s.instances[instID] = &MobInstance{ - InstanceID: instID, - DefID: defID, - Name: dw.def.Name, - HP: dw.def.HP, - MaxHP: dw.def.HP, - Attack: dw.def.Attack, - Strength: dw.def.Strength, - Defense: dw.def.Defense, - Speed: dw.def.Speed, - Aggressive: dw.def.Aggressive, - RespawnTicks: dw.def.RespawnTicks, - RoomID: roomID, - Drops: dw.def.Drops, + inst := &MobInstance{ + InstanceID: instID, + DefID: defID, + Name: dw.def.Name, + HP: dw.def.HP, + MaxHP: dw.def.HP, + Attack: dw.def.Attack, + Strength: dw.def.Strength, + Defense: dw.def.Defense, + Speed: dw.def.Speed, + Aggressive: dw.def.Aggressive, + Protected: dw.def.Protected, + Unique: dw.def.Unique, + RespawnTicks: dw.def.RespawnTicks, + Wander: dw.def.Wander, + WanderTick: dw.def.WanderTick, + WanderChance: dw.def.WanderChance, + RoomID: roomID, + HomeRoomID: roomID, + Drops: dw.def.Drops, } + inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions) + s.instances[instID] = inst } } diff --git a/internal/world/world.go b/internal/world/world.go index 265a2f9..1429081 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -11,6 +11,14 @@ import ( ) const DropDespawnTicks = 1000 +const ReserveTicks = 100 + +type GroundItemInfo struct { + ItemID string + Quantity int + ReservedFor string + ReserveTimer int +} type groundEntry struct { itemID string @@ -20,6 +28,8 @@ type groundEntry struct { respawnQty int respawnDelay int despawnTimer int // >0 = counting down to despawn (dropped items) + reservedFor string + reserveTimer int // >0 = counting down reservation } type World struct { @@ -75,6 +85,27 @@ func (w *World) ResolveExit(input string) ExitDir { return "" } +func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo { + w.mu.Lock() + defer w.mu.Unlock() + var out []GroundItemInfo + for _, e := range w.groundItems[roomID] { + if e.quantity <= 0 { + continue + } + info := GroundItemInfo{ + ItemID: e.itemID, + Quantity: e.quantity, + } + if e.reserveTimer > 0 && e.reservedFor != "" { + info.ReservedFor = e.reservedFor + info.ReserveTimer = e.reserveTimer + } + out = append(out, info) + } + return out +} + func (w *World) GroundItems(roomID int) map[string]int { w.mu.Lock() defer w.mu.Unlock() @@ -87,6 +118,19 @@ func (w *World) GroundItems(roomID int) map[string]int { return out } +func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner string) { + w.mu.Lock() + defer w.mu.Unlock() + e := &groundEntry{ + itemID: itemID, + quantity: qty, + despawnTimer: DropDespawnTicks, + reservedFor: owner, + reserveTimer: ReserveTicks, + } + w.groundItems[roomID] = append(w.groundItems[roomID], e) +} + func (w *World) AddGroundItem(roomID int, itemID string, qty int) { w.mu.Lock() defer w.mu.Unlock() @@ -98,6 +142,38 @@ func (w *World) AddGroundItem(roomID int, itemID string, qty int) { w.groundItems[roomID] = append(w.groundItems[roomID], e) } +func (w *World) RemoveReservedGroundItem(roomID int, itemID string, qty int, owner string) (int, bool) { + w.mu.Lock() + defer w.mu.Unlock() + removed := 0 + remaining := qty + for _, e := range w.groundItems[roomID] { + if remaining <= 0 { + break + } + if !strings.EqualFold(e.itemID, itemID) { + continue + } + if e.quantity <= 0 { + continue + } + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { + return removed, false + } + take := e.quantity + if take > remaining { + take = remaining + } + e.quantity -= take + removed += take + remaining -= take + if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { + e.respawnTimer = e.respawnDelay + } + } + return removed, true +} + func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { w.mu.Lock() defer w.mu.Unlock() @@ -177,6 +253,12 @@ func (w *World) Tick() { e.quantity = 0 } } + if e.reserveTimer > 0 { + e.reserveTimer-- + if e.reserveTimer <= 0 { + e.reservedFor = "" + } + } } } } |
