aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-09 17:15:13 -0400
committerhistoria <[not public]>2026-06-09 17:15:13 -0400
commit9edc568e16741d443b67fbd23b0d91790085ced9 (patch)
treed54be40531ba124dcf8299f9e94bfba4ece273ae /internal
parent56be6a3f45830594225a765b406816e6179ccde1 (diff)
downloadthehouseoficarus-9edc568e16741d443b67fbd23b0d91790085ced9.tar.gz
combat, mobs, parsing, toggles, hard disconnect handling
Diffstat (limited to 'internal')
-rw-r--r--internal/combat/state.go28
-rw-r--r--internal/game/game.go892
-rw-r--r--internal/net/server.go37
-rw-r--r--internal/object/item.go4
-rw-r--r--internal/player/player.go62
-rw-r--r--internal/world/mob.go185
-rw-r--r--internal/world/world.go82
7 files changed, 1143 insertions, 147 deletions
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 = ""
+ }
+ }
}
}
}