aboutsummaryrefslogtreecommitdiff
path: root/internal/game/game.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/game.go')
-rw-r--r--internal/game/game.go2177
1 files changed, 40 insertions, 2137 deletions
diff --git a/internal/game/game.go b/internal/game/game.go
index 528c895..d793516 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -2,13 +2,10 @@ package game
import (
"fmt"
- "math/rand"
- "os"
- "sort"
- "strconv"
"strings"
+ "sync"
- "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/action"
"thirdcollapse/internal/engine"
"thirdcollapse/internal/net"
"thirdcollapse/internal/object"
@@ -17,28 +14,33 @@ import (
)
type Game struct {
- World *world.World
- ObjectStore *object.ObjectStore
- ItemStore *object.ItemStore
- AccountStore *player.AccountStore
- MobStore *world.MobStore
- Hub *net.Hub
- Ticks *engine.Engine
- dataDir string
- restTimers map[string]uint64 // player name -> tick subscription ID
- loggedInChars map[string]*net.Session // character name -> session
+ World *world.World
+ ObjectStore *object.ObjectStore
+ ItemStore *object.ItemStore
+ AccountStore *player.AccountStore
+ MobStore *world.MobStore
+ BehaviorStore *action.Store
+ Hub *net.Hub
+ Ticks *engine.Engine
+ WorldFlags map[string]any
+ dataDir string
+ restTimers map[string]uint64
+ charsMu sync.Mutex
+ loggedInChars map[string]*net.Session
combatPadWidth int
}
func New(dataDir string) *Game {
return &Game{
- World: world.New(dataDir),
- ObjectStore: object.NewObjectStore(dataDir),
- ItemStore: object.NewItemStore(dataDir),
- AccountStore: player.NewAccountStore(dataDir),
- MobStore: world.NewMobStore(dataDir),
- Ticks: engine.New(),
- dataDir: dataDir,
+ World: world.New(dataDir),
+ ObjectStore: object.NewObjectStore(dataDir),
+ ItemStore: object.NewItemStore(dataDir),
+ AccountStore: player.NewAccountStore(dataDir),
+ MobStore: world.NewMobStore(dataDir),
+ BehaviorStore: action.NewStore(dataDir),
+ Ticks: engine.New(),
+ WorldFlags: make(map[string]any),
+ dataDir: dataDir,
restTimers: make(map[string]uint64),
loggedInChars: make(map[string]*net.Session),
}
@@ -48,7 +50,9 @@ func (g *Game) SetHub(hub *net.Hub) {
g.Hub = hub
hub.OnRemove(func(sess *net.Session) {
if p, ok := sess.Player.(*player.Player); ok {
+ g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
+ g.charsMu.Unlock()
}
})
}
@@ -81,508 +85,11 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleGameCommand(sess, input)
case net.StateChangeDescription:
g.handleDescriptionChange(sess, input)
+ case net.StateTalk:
+ g.handleTalkInput(sess, input)
}
}
-func (g *Game) handleAccountName(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
- if name == "" {
- sess.Write("Account name: ")
- return
- }
- actualName, err := g.AccountStore.FindAccount(name)
- if err == nil {
- sess.Account = &net.AccountEntry{Name: actualName}
- sess.State = net.StatePassword
- sess.Write("Password: ")
- } else {
- sess.Account = &net.AccountEntry{Name: name}
- sess.State = net.StateNewAccountPass
- sess.Write("A new visitor to this rock!\nChoose password: ")
- }
-}
-
-func (g *Game) handlePassword(sess *net.Session, input string) {
- if input == "" {
- sess.Write("Password: ")
- return
- }
- acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err != nil {
- sess.WriteLine("Error loading account.")
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- if !player.CheckPassword(input, acc.PasswordHash) {
- sess.WriteLine("Wrong password.")
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- sess.Account = &net.AccountEntry{
- Name: acc.Name,
- PasswordHash: acc.PasswordHash,
- Characters: acc.Characters,
- }
- g.showMenu(sess)
-}
-
-func (g *Game) handleNewAccountPass(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- if input == "" {
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- sess.PendingPass = input
- sess.State = net.StateNewAccountConfirm
- sess.Write("Confirm password: ")
-}
-
-func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- if input == "" {
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
- if input != sess.PendingPass {
- sess.WriteLine("Passwords do not match.")
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- hash, err := player.HashPassword(input)
- if err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- acc := &player.Account{
- Name: sess.Account.Name,
- PasswordHash: hash,
- }
- if err := g.AccountStore.SaveAccount(acc); err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
- sess.Account = nil
- sess.State = net.StateAccountName
- sess.Write("Account name: ")
- return
- }
-
- sess.Account = &net.AccountEntry{
- Name: acc.Name,
- PasswordHash: acc.PasswordHash,
- }
- sess.PendingPass = ""
- g.showMenu(sess)
-}
-
-func (g *Game) handleRenameAccount(sess *net.Session, input string) {
- newName := strings.TrimSpace(input)
- if newName == "" {
- sess.Write("New account name: ")
- return
- }
- oldName := sess.Account.Name
- if newName == oldName {
- sess.WriteLine("That's already your account name.")
- g.showMenu(sess)
- return
- }
- if g.AccountStore.AccountExists(newName) {
- sess.WriteLine("An account with that name already exists.")
- sess.Write("New account name: ")
- return
- }
-
- // Rename the account file
- oldPath := g.AccountStore.AccountPath(oldName)
- newPath := g.AccountStore.AccountPath(newName)
- if err := os.Rename(oldPath, newPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err))
- g.showMenu(sess)
- return
- }
-
- sess.Account.Name = newName
- sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName))
- g.showMenu(sess)
-}
-
-func (g *Game) handleRenameChar(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = sess.Account.Characters[idx-1]
- } else {
- sess.PendingChar = name
- }
-
- // Verify the character exists on this account
- found := false
- for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
- found = true
- break
- }
- }
- if !found {
- sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
- g.showMenu(sess)
- return
- }
-
- sess.State = net.StateRenameCharName
- sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar))
- sess.Write("New name: ")
-}
-
-func (g *Game) handleRenameCharName(sess *net.Session, input string) {
- newName := strings.TrimSpace(input)
- oldName := sess.PendingChar
- if newName == "" {
- sess.Write("New name: ")
- return
- }
- if newName == oldName {
- sess.WriteLine("That's already the character's name.")
- g.showMenu(sess)
- return
- }
- if g.AccountStore.CharacterExists(newName) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("New name: ")
- return
- }
-
- // Rename character file
- oldPath := g.AccountStore.CharPath(oldName)
- newPath := g.AccountStore.CharPath(newName)
- if err := os.Rename(oldPath, newPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
- g.showMenu(sess)
- return
- }
-
- // Load and update the character's internal name
- p, _ := g.AccountStore.LoadCharacter(newName)
- if p != nil {
- p.Name = newName
- g.AccountStore.SaveCharacter(p)
- }
-
- // Update account character list
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- for i, c := range acc.Characters {
- if c == oldName {
- acc.Characters[i] = newName
- break
- }
- }
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
-
- sess.PendingChar = ""
- sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName))
- g.showMenu(sess)
-}
-
-func (g *Game) showDeleteConfirm(sess *net.Session) {
- sess.State = net.StateDeleteChar
- sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar))
-}
-
-func (g *Game) handleDeleteChar(sess *net.Session, input string) {
- // If PendingChar not set, this is the character selection step
- if sess.PendingChar == "" {
- name := strings.TrimSpace(input)
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = sess.Account.Characters[idx-1]
- } else {
- sess.PendingChar = name
- }
- found := false
- for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
- found = true
- break
- }
- }
- if !found {
- sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
- g.showDeleteConfirm(sess)
- return
- }
-
- // Confirmation step
- input = strings.TrimSpace(input)
- expected := "DELETE " + sess.PendingChar
- if strings.ToUpper(input) != strings.ToUpper(expected) {
- sess.WriteLine("Delete cancelled.")
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
-
- // Delete the character file
- charPath := g.AccountStore.CharPath(sess.PendingChar)
- if err := os.Remove(charPath); err != nil {
- sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
- sess.PendingChar = ""
- g.showMenu(sess)
- return
- }
-
- // Remove from account
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- var newChars []string
- for _, c := range acc.Characters {
- if c != sess.PendingChar {
- newChars = append(newChars, c)
- }
- }
- acc.Characters = newChars
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = newChars
-
- sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar))
- sess.PendingChar = ""
- g.showMenu(sess)
-}
-
-func (g *Game) handlePurgeAccount(sess *net.Session, input string) {
- input = strings.TrimSpace(input)
- expected := "PURGE " + sess.Account.Name
- if strings.ToUpper(input) != strings.ToUpper(expected) {
- sess.WriteLine("Purge cancelled.")
- g.showMenu(sess)
- return
- }
-
- // Delete all character files
- for _, name := range sess.Account.Characters {
- os.Remove(g.AccountStore.CharPath(name))
- }
-
- // Delete account file
- os.Remove(g.AccountStore.AccountPath(sess.Account.Name))
-
- sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name))
- sess.Conn.Close()
-}
-
-func (g *Game) showMenu(sess *net.Session) {
- sess.State = net.StateMenu
- lines := []string{
- "",
- fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name),
- "",
- }
- if len(sess.Account.Characters) > 0 {
- lines = append(lines,
- " (C)onnect character to TC",
- "",
- " (L)ist characters",
- " (R)ename character",
- " (D)elete character",
- )
- }
- lines = append(lines,
- " (N)ew character",
- " (P)urge account",
- " (A)ccount rename",
- " (Q)uit",
- "",
- "INPUT: ",
- )
- sess.WriteLines(lines...)
-}
-
-func (g *Game) handleMenu(sess *net.Session, input string) {
- switch strings.ToLower(strings.TrimSpace(input)) {
- case "":
- sess.Write("INPUT: ")
- case "c":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!")
- sess.Write("\nINPUT: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- g.connectCharacter(sess, sess.Account.Characters[0])
- return
- }
- sess.WriteLine("\nSelect character:")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.State = net.StateNewCharName
- sess.PendingChar = "connect"
- sess.Write("\nChoice: ")
-
- case "l":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)")
- } else {
- sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:")
- for _, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" - %s", name))
- }
- }
- sess.Write("\nINPUT: ")
-
- case "n":
- sess.State = net.StateNewCharName
- sess.PendingChar = ""
- sess.Write("What's your character name?: ")
-
- case "a":
- sess.State = net.StateRenameAccount
- sess.Write("New account name: ")
-
- case "r":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to rename.")
- sess.Write("\nChoice: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- sess.PendingChar = sess.Account.Characters[0]
- sess.State = net.StateRenameCharName
- sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar))
- sess.Write("New name: ")
- return
- }
- sess.State = net.StateRenameChar
- sess.WriteLine("\nRename which character?")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.Write("\n> : ")
-
- case "d":
- if len(sess.Account.Characters) == 0 {
- sess.WriteLine("\nNo characters to delete.")
- sess.Write("\nINPUT: ")
- return
- }
- if len(sess.Account.Characters) == 1 {
- sess.PendingChar = sess.Account.Characters[0]
- } else {
- sess.State = net.StateDeleteChar
- sess.WriteLine("\nDelete which character?")
- for i, name := range sess.Account.Characters {
- sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
- }
- sess.Write("\n>: ")
- return
- }
- g.showDeleteConfirm(sess)
-
- case "p":
- sess.State = net.StatePurgeAccount
- sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name))
- sess.Write("\n> ")
-
- case "q":
- sess.WriteLine("Later.")
- sess.Conn.Close()
- return
-
- default:
- sess.Write("SYNTAX ERROR\nINPUT: ")
- }
-}
-
-func (g *Game) handleNewCharName(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
-
- // Coming from "c" menu — selecting a character by number
- if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 {
- if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) {
- sess.PendingChar = ""
- g.connectCharacter(sess, sess.Account.Characters[idx-1])
- return
- }
- sess.WriteLine("Invalid choice.")
- sess.Write("\nChoice: ")
- return
- }
-
- if name == "" {
- sess.Write("Character name: ")
- return
- }
-
- if g.AccountStore.CharacterExists(name) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("Character name: ")
- return
- }
-
- // Create new character
- p := player.New(name)
- p.RoomID = 1 // spawn room
-
- if err := g.AccountStore.SaveCharacter(p); err != nil {
- sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- // Add to account
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
- acc.Characters = append(acc.Characters, name)
- g.AccountStore.SaveAccount(acc)
- sess.Account.Characters = acc.Characters
-
- g.connectCharacter(sess, name)
-}
-
-func (g *Game) connectCharacter(sess *net.Session, name string) {
- if existing := g.loggedInChars[name]; existing != nil {
- sess.WriteLine("This character is logged in elsewhere.")
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- p, err := g.AccountStore.LoadCharacter(name)
- if err != nil {
- sess.WriteLine(fmt.Sprintf("Error loading character: %v", err))
- sess.State = net.StateMenu
- g.showMenu(sess)
- return
- }
-
- sess.Player = p
- sess.State = net.StateGame
- g.loggedInChars[name] = sess
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
-
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
-
- g.doLook(sess)
- sess.Write("\r\n> ")
-}
-
func (g *Game) handleGameCommand(sess *net.Session, input string) {
if input == "" {
sess.Write("> ")
@@ -596,7 +103,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
parts := strings.Fields(strings.ToLower(input))
cmd := parts[0]
args := parts[1:]
-
+
switch cmd {
case "get", "take", "grab", "pick":
if len(args) == 0 {
@@ -662,1627 +169,23 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
} else {
g.doHelp(sess, strings.Join(args, " "))
}
- default:
- sess.WriteLine("Unknown command.")
- }
-
- sess.Write("\r\n> ")
-}
-
-var toggles = []struct {
- Name string
- Description string
-}{
- {"description", "Long room descriptions"},
- {"tinymap", "Mini-map display"},
- {"xpdrops", "XP drop messages in combat"},
- {"exits", "Long exit display in look"},
- {"mobenter", "Messages when mobs enter the room"},
- {"mobleave", "Messages when mobs leave the room"},
- {"mobspawn", "Messages when mobs spawn in the area"},
- {"reserve", "Show full reserved item details"},
-}
-
-func (g *Game) doToggle(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- if input == "" {
- sess.WriteLine("")
- for _, t := range toggles {
- status := "Off"
- if p.Toggles[t.Name] {
- status = "On"
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description))
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- for _, t := range toggles {
- if strings.ToLower(input) == t.Name {
- p.Toggles[t.Name] = !p.Toggles[t.Name]
- status := "Off"
- if p.Toggles[t.Name] {
- status = "On"
- }
- sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status))
- g.AccountStore.SaveCharacter(p)
- return
- }
- }
- sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input))
-}
-
-func (g *Game) doMove(sess *net.Session, dir string) {
- p := sess.Player.(*player.Player)
- exitDir := g.World.ResolveExit(dir)
- if exitDir == "" {
- sess.WriteLine("Go where?")
- return
- }
-
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You can't move from here.")
- return
- }
-
- targetID, ok := room.Exits[exitDir]
- if !ok {
- sess.WriteLine("You can't go that way.")
- return
- }
-
- _, err = g.World.LoadRoom(targetID)
- if err != nil {
- sess.WriteLine("That path seems blocked.")
- return
- }
-
- // Check combat lock
- if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 {
- sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks)))
- return
- }
-
- // Interrupt combat on move
- g.stopCombat(p.Name)
-
- oldRoom := p.RoomID
- p.RoomID = targetID
- g.AccountStore.SaveCharacter(p)
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
-
- if g.Hub != nil {
- // Notify people in old room
- for _, other := range g.Hub.PlayersInRoom(oldRoom) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir))
- }
- }
- g.Hub.EnterRoom(sess, targetID)
- // Notify people in new room
- for _, other := range g.Hub.PlayersInRoom(targetID) {
- if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
- }
- }
- }
-
- sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir))
- if p.Toggles["description"] {
- g.doLook(sess)
- } else {
- targetRoom, _ := g.World.LoadRoom(targetID)
- if targetRoom != nil {
- sess.WriteLine(targetRoom.Name)
- }
- }
-}
-
-func (g *Game) doStyle(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- styles := []string{"accurate", "aggressive", "defensive", "balanced"}
- if input == "" {
- sess.WriteLine("\nCombat styles:")
- for _, s := range styles {
- marker := " "
- if string(p.AttackStyle) == s {
- marker = "*"
- }
- sess.WriteLine(fmt.Sprintf(" %s %s", marker, s))
- }
- return
- }
-
- input = strings.ToLower(input)
- for _, s := range styles {
- if s == input {
- p.AttackStyle = player.AttackStyle(s)
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s))
- return
- }
- }
- sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input))
-}
-
-func (g *Game) doLook(sess *net.Session) {
- p := sess.Player.(*player.Player)
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You are in a void.")
- return
- }
-
- sess.WriteLines(
- "",
- room.Name,
- room.Description,
- )
-
- // Mobs
- mobs := g.MobStore.MobsInRoom(p.RoomID)
- if len(mobs) > 0 {
- sort.Slice(mobs, func(i, j int) bool {
- iDamaged := mobs[i].HP < mobs[i].MaxHP
- jDamaged := mobs[j].HP < mobs[j].MaxHP
- if iDamaged != jDamaged {
- return iDamaged
- }
- return mobs[i].InstanceID < mobs[j].InstanceID
- })
- sess.WriteLine("")
- for _, m := range mobs {
- hp := ""
- if m.HP < m.MaxHP {
- hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP)
- }
- var desc string
- if combat.IsMobInCombat(m.InstanceID) {
- def, err := g.MobStore.LoadDef(m.DefID)
- if err == nil && len(def.CombatDescriptions) > 0 {
- target := combat.GetMobTarget(m.InstanceID)
- pattern := def.CombatDescriptions[rand.Intn(len(def.CombatDescriptions))]
- desc = " " + fmt.Sprintf(pattern, target)
- }
- } else if m.IdleDescription != "" {
- desc = fmt.Sprintf(" %s", m.IdleDescription)
- }
- displayName := m.Name
- if !m.Unique {
- displayName = "A " + m.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc))
- }
- }
-
- // Ground items
- ground := g.World.GroundItemsDetailed(p.RoomID)
- if len(ground) > 0 {
- sess.WriteLine("")
- sess.WriteLine("On the ground:")
- for _, info := range ground {
- def, err := g.ItemStore.Load(info.ItemID)
- name := info.ItemID
- if err == nil {
- name = def.Name
- }
- line := ""
- if info.Quantity > 1 {
- line = fmt.Sprintf(" %d x %s", info.Quantity, name)
- } else {
- line = fmt.Sprintf(" %s", name)
- }
- if info.ReservedFor != "" {
- if p.Toggles["reserve"] {
- line += fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer)
- } else {
- line += " (reserved)"
- }
- }
- sess.WriteLine(line)
- }
- }
-
- // Exits
- if len(room.Exits) > 0 {
- sess.WriteLine("")
- if p.Toggles["exits"] {
- sess.WriteLine("Exits:")
- for _, dir := range world.ExitOrder {
- targetID, ok := room.Exits[dir]
- if !ok {
- continue
- }
- targetRoom, err := g.World.LoadRoom(targetID)
- targetName := fmt.Sprintf("#%d", targetID)
- if err == nil {
- targetName = targetRoom.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
- }
- } else {
- sess.Write("Exits: ")
- first := true
- for _, dir := range world.ExitOrder {
- if _, ok := room.Exits[dir]; ok {
- if !first {
- sess.Write(", ")
- }
- sess.Write(string(dir))
- first = false
- }
- }
- sess.WriteLine("")
- }
- }
-
- // Show other players
- others := g.Hub.PlayersInRoom(p.RoomID)
- for _, other := range others {
- if other != sess && other.Player != nil {
- op := other.Player.(*player.Player)
- line := fmt.Sprintf("\n%s is here", op.Name)
- if cs := combat.GetCombat(op.Name); cs != nil {
- if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 {
- line += fmt.Sprintf(" (fighting %s)", mobDisplayName(mob, false))
- }
- }
- sess.WriteLine(line + ".")
- }
- }
-}
-
-func (g *Game) doSay(sess *net.Session, msg string) {
- p := sess.Player.(*player.Player)
- roomID := p.RoomID
-
- for _, other := range g.Hub.PlayersInRoom(roomID) {
- if other == sess {
- other.WriteLine(fmt.Sprintf("\nYou say: %s", msg))
- } else {
- other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg))
- }
- }
-}
-
-func (g *Game) doScore(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLines(
- "",
- fmt.Sprintf("Name: %s", p.Name),
- fmt.Sprintf("Combat Level: %d", p.CombatLevel()),
- fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()),
- fmt.Sprintf("Credits: %d", p.Credits),
- "",
- "Skills:",
- )
- for _, s := range player.AllSkills {
- level := p.Level(s)
- xp := p.Skills[s]
- next := player.XPForNextLevel(xp)
- sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next))
- }
-}
-
-func (g *Game) doInventory(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- sess.WriteLine("Inventory (28 slots):")
-
- empty := 0
- num := 0
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot == nil {
- empty++
- continue
- }
- num++
- def, err := g.ItemStore.Load(slot.ItemID)
- name := slot.ItemID
- if err == nil {
- name = def.Name
- }
- if slot.Quantity > 1 {
- sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name))
+ case "mine", "chop", "fish", "use", "pull", "push":
+ if len(args) == 0 {
+ sess.WriteLine(fmt.Sprintf("%s what?", cmd))
} else {
- sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name))
- }
- }
- sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty))
-}
-
-func (g *Game) doEquipment(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- sess.WriteLine("Equipment:")
-
- slots := []object.EquipSlot{
- object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
- object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
- object.SlotMainHand, object.SlotOffHand, object.SlotRing,
- }
- for _, slot := range slots {
- itemID, ok := p.Equipment[slot]
- if !ok {
- sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot))
- continue
- }
- def, err := g.ItemStore.Load(itemID)
- name := itemID
- if err == nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
- }
-}
-
-func (g *Game) cancelRest(playerName string) {
- if id, ok := g.restTimers[playerName]; ok {
- g.Ticks.Unsubscribe(id)
- delete(g.restTimers, playerName)
- }
-}
-
-func (g *Game) doQuit(sess *net.Session) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You can't rest during combat!")
- return
- }
-
- g.cancelRest(p.Name)
-
- ticksLeft := 10
- id := g.Ticks.Subscribe(1, func() bool {
- switch ticksLeft {
- case 10:
- sess.WriteLine("You sit down to rest...")
- case 6:
- sess.WriteLine("You catch your breath...")
- case 3:
- sess.WriteLine("You close your eyes...")
- case 0:
- g.cancelRest(p.Name)
- g.AccountStore.SaveCharacter(p)
- delete(g.loggedInChars, p.Name)
- if g.Hub != nil {
- g.Hub.LeaveRoom(sess)
- }
- sess.Player = nil
- sess.State = net.StateMenu
- g.showMenu(sess)
- return false
- }
- ticksLeft--
- return true
- })
- g.restTimers[p.Name] = id
-}
-
-func plural(n int) string {
- if n == 1 {
- return ""
- }
- return "s"
-}
-
-func parseIndex(s string) (int, error) {
- var idx int
- _, err := fmt.Sscanf(s, "%d", &idx)
- if err != nil {
- return 0, err
- }
- return idx, nil
-}
-
-func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
- qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- if qty <= 0 {
- return
- }
- p.Credits += qty
- g.AccountStore.SaveCharacter(p)
- if qty == 1 {
- sess.WriteLine("You pick up 1 credit.")
- } else {
- sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits))
- }
-}
-
-func (g *Game) doGetAll(sess *net.Session) {
- p := sess.Player.(*player.Player)
- ground := g.World.GroundItems(p.RoomID)
- if len(ground) == 0 {
- sess.WriteLine("There's nothing on the ground to pick up.")
- return
- }
-
- var picked []string
- for itemID, qty := range ground {
- if qty <= 0 {
- continue
- }
- if itemID == "credits" {
- taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name)
- if !ok {
- continue
- }
- p.Credits += taken
- if taken == 1 {
- picked = append(picked, "1 credit")
- } else {
- picked = append(picked, fmt.Sprintf("%d credits", taken))
- }
- continue
- }
-
- def, _ := g.ItemStore.Load(itemID)
- for qty > 0 {
- if def != nil && def.Stackable {
- stacked := false
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- qty = 0
- stacked = true
- break
- }
- slot.Quantity += qty
- picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity))
- stacked = true
- qty = 0
- break
- }
- }
- if stacked {
- break
- }
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
- innerPickupReport(sess, picked)
- } else {
- sess.WriteLine("Your inventory is full.")
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- break
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
- name := itemID
- if def != nil {
- name = def.Name
- }
- picked = append(picked, name)
- break
- }
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- if len(picked) > 0 {
- sess.WriteLine("Inventory full. Picked up so far:")
- innerPickupReport(sess, picked)
- } else {
- sess.WriteLine("Your inventory is full.")
- }
- g.AccountStore.SaveCharacter(p)
- return
- }
-
- take := 1
- _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name)
- if !ok {
- break
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take})
- name := itemID
- if def != nil {
- name = def.Name
- }
- picked = append(picked, name)
- qty -= take
- }
- }
-
- g.AccountStore.SaveCharacter(p)
-
- if len(picked) == 0 {
- sess.WriteLine("Your inventory is full.")
- } else {
- sess.Write("You pick up: ")
- innerPickupReport(sess, picked)
- }
-}
-
-func (g *Game) doLookTarget(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
- lower := strings.ToLower(input)
-
- // Check if looking at an exit direction
- if exitDir := g.World.ResolveExit(lower); exitDir != "" {
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil {
- sess.WriteLine("You can't see anything that way.")
- return
- }
- targetID, ok := room.Exits[exitDir]
- if !ok {
- sess.WriteLine("You can't see anything that way.")
- return
- }
- // Seed target room and show its look output
- g.World.SeedGroundItems(targetID)
- g.seedRoomMobs(targetID)
- origRoom := p.RoomID
- p.RoomID = targetID
- g.doLook(sess)
- p.RoomID = origRoom
- return
- }
-
- // Check mobs — prefer exact matches over prefix
- var best *world.MobInstance
- bestQ := world.MatchNone
- for _, m := range g.MobStore.MobsInRoom(p.RoomID) {
- q := m.MatchQuality(lower)
- if q > bestQ {
- bestQ = q
- best = m
- }
- }
- if best != nil {
- sess.WriteLines(
- "",
- fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)),
- )
- if best.IdleDescription != "" {
- sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
- }
- sess.WriteLines(
- "",
- fmt.Sprintf(" Attack: %d", best.Attack),
- fmt.Sprintf(" Strength: %d", best.Strength),
- fmt.Sprintf(" Defense: %d", best.Defense),
- fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP),
- )
- return
- }
-
- // Check room objects
- room, err := g.World.LoadRoom(p.RoomID)
- if err == nil {
- for _, objID := range room.Objects {
- def, err := g.ObjectStore.Load(objID)
- if err != nil {
- continue
- }
- if !world.WordPrefixMatch(lower, def.Name) && !world.WordPrefixMatch(lower, def.ID) {
- continue
- }
- sess.WriteLine("")
- sess.WriteLine(def.Name)
- if desc, ok := def.Props["description"].(string); ok && desc != "" {
- sess.WriteLine(fmt.Sprintf(" %s", desc))
- }
+ g.StartAction(sess, cmd, strings.Join(args, " "))
return
}
- }
-
- // Check ground items
- ground := g.World.GroundItems(p.RoomID)
- for itemID := range ground {
- def, err := g.ItemStore.Load(itemID)
- if err != nil || !def.MatchesName(input) {
- continue
- }
- sess.WriteLines(
- "",
- def.Name,
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
- )
- return
- }
-
- // Check inventory items
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot == nil {
- continue
- }
- def, err := g.ItemStore.Load(slot.ItemID)
- if err != nil || !def.MatchesName(input) {
- continue
- }
- sess.WriteLines(
- "",
- def.Name,
- fmt.Sprintf(" %s", def.Description),
- fmt.Sprintf(" Value: %d credits", def.Value),
- )
- return
- }
-
- // Check other players
- others := g.Hub.PlayersInRoom(p.RoomID)
- for _, other := range others {
- if other == sess || other.Player == nil {
- continue
- }
- op := other.Player.(*player.Player)
- if strings.ToLower(op.Name) != lower {
- continue
- }
- showPlayerInfo(sess, op)
- return
- }
-
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
-}
-
-func showPlayerInfo(sess *net.Session, p *player.Player) {
- sess.WriteLines(
- "",
- p.Name,
- fmt.Sprintf(" Combat Level: %d", p.CombatLevel()),
- fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()),
- "",
- )
-
- // Skills
- for _, s := range player.AllSkills {
- level := p.Level(s)
- xp := p.Skills[s]
- sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level))
- _ = xp
- }
-
- // Equipment
- sess.WriteLine("")
- sess.WriteLine(" Equipment:")
- slots := []object.EquipSlot{
- object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
- object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
- object.SlotMainHand, object.SlotOffHand, object.SlotRing,
- }
- for _, slot := range slots {
- itemID, ok := p.Equipment[slot]
- if !ok {
- continue
- }
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID))
- }
-
- if p.Description != "" {
- sess.WriteLine("")
- sess.WriteLine(fmt.Sprintf(" %s", p.Description))
- }
-}
-
-func (g *Game) doExits(sess *net.Session) {
- p := sess.Player.(*player.Player)
- room, err := g.World.LoadRoom(p.RoomID)
- if err != nil || len(room.Exits) == 0 {
- sess.WriteLine("There are no exits here.")
- return
- }
- for _, dir := range world.ExitOrder {
- targetID, ok := room.Exits[dir]
- if !ok {
- continue
- }
- targetRoom, err := g.World.LoadRoom(targetID)
- targetName := fmt.Sprintf("#%d", targetID)
- if err == nil {
- targetName = targetRoom.Name
- }
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
- }
-}
-
-func (g *Game) doDescription(sess *net.Session) {
- p := sess.Player.(*player.Player)
- sess.WriteLine("")
- if p.Description != "" {
- sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description))
- } else {
- sess.WriteLine("You don't have a description set.")
- }
- sess.WriteLine("")
- sess.Write("Enter a new description (or press enter to keep current): ")
- sess.State = net.StateChangeDescription
-}
-
-func (g *Game) handleDescriptionChange(sess *net.Session, input string) {
- if input != "" {
- p := sess.Player.(*player.Player)
- p.Description = input
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("Description set to: %s", input))
- } else {
- sess.WriteLine("Description left unchanged.")
- }
- sess.State = net.StateGame
- sess.Write("\r\n> ")
-}
-
-func innerPickupReport(sess *net.Session, picked []string) {
- for i, name := range picked {
- if i == len(picked)-1 {
- sess.WriteLine(name)
- } else if i == len(picked)-2 {
- sess.Write(name + " and ")
+ case "talk", "speak", "ask":
+ if len(args) == 0 {
+ sess.WriteLine("Talk to whom?")
} else {
- sess.Write(name + ", ")
- }
- }
-}
-
-func (g *Game) doGet(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
- ground := g.World.GroundItems(p.RoomID)
- if len(ground) == 0 {
- sess.WriteLine("There's nothing on the ground to pick up.")
- return
- }
-
- matches := g.findGroundMatches(input, p.RoomID)
- if len(matches) == 0 {
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
- return
- }
-
- if len(matches) > 1 {
- unique := uniqueItemNames(matches)
- if len(unique) > 1 {
- sess.WriteLine("Which one?")
- for _, name := range unique {
- sess.WriteLine(fmt.Sprintf(" - %s", name))
- }
+ g.StartAction(sess, "talk", strings.Join(args, " "))
return
}
+ default:
+ sess.WriteLine("Unknown command.")
}
- itemID := matches[0].ID
-
- // Credits go to the credits field, not inventory
- if itemID == "credits" {
- g.pickupCredits(sess, p, itemID)
- return
- }
-
- def, _ := g.ItemStore.Load(itemID)
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is full.")
- return
- }
-
- qty := 1
- if def != nil && def.Stackable {
- ground := g.World.GroundItems(p.RoomID)
- if gqty, ok := ground[itemID]; ok && gqty > 0 {
- qty = gqty
- }
- }
- // Check if we can stack
- if def != nil && def.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == itemID {
- removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- _ = removed
- slot.Quantity += qty
- g.AccountStore.SaveCharacter(p)
- name := def.Name
- sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", name, slot.Quantity))
- return
- }
- }
- }
-
- removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
- sess.WriteLine("That's not yours!")
- return
- }
- _ = removed
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty})
- g.AccountStore.SaveCharacter(p)
- name := itemID
- if def != nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
-}
-
-func (g *Game) doDrop(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- matches := g.findInventoryMatches(input, p)
- if len(matches) == 0 {
- sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input))
- return
- }
-
- unique := uniqueItemNames(matches)
- if len(unique) > 1 {
- sess.WriteLine("Which one?")
- for _, name := range unique {
- sess.WriteLine(fmt.Sprintf(" - %s", name))
- }
- return
- }
-
- itemID := matches[0].ID
- slotIdx := matches[0].Slot
- slot := p.InvSlot(slotIdx)
- def, _ := g.ItemStore.Load(itemID)
-
- qty := 1
- name := itemID
- if def != nil {
- name = def.Name
- }
-
- if slot.Quantity > qty {
- slot.Quantity -= qty
- } else {
- p.SetInvSlot(slotIdx, nil)
- }
-
- g.World.AddGroundItem(p.RoomID, itemID, qty)
- g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
-}
-
-type itemMatch struct {
- ID string
- Name string
- Slot int // inventory slot index, -1 for ground
-}
-
-func (g *Game) findGroundMatches(input string, roomID int) []itemMatch {
- ground := g.World.GroundItems(roomID)
- var matches []itemMatch
- for itemID := range ground {
- def, err := g.ItemStore.Load(itemID)
- if err != nil {
- continue
- }
- if def.MatchesName(input) {
- matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1})
- }
- }
- return matches
-}
-
-func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch {
- var matches []itemMatch
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot == nil {
- continue
- }
- def, err := g.ItemStore.Load(slot.ItemID)
- if err != nil {
- continue
- }
- if def.MatchesName(input) {
- matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i})
- }
- }
- return matches
-}
-
-func uniqueItemNames(matches []itemMatch) []string {
- seen := make(map[string]bool)
- var out []string
- for _, m := range matches {
- if !seen[m.Name] {
- seen[m.Name] = true
- out = append(out, m.Name)
- }
- }
- return out
-}
-
-func (g *Game) doTickTest(sess *net.Session) {
- count := 0
- id := g.Ticks.Subscribe(5, func() bool {
- count++
- sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count))
- if count >= 3 {
- sess.WriteLine("[Tick test complete.]")
- return false
- }
- return true
- })
- _ = id
- sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.")
-}
-
-func (g *Game) doAttack(sess *net.Session, input string) {
- p := sess.Player.(*player.Player)
-
- if combat.GetCombat(p.Name) != nil {
- sess.WriteLine("You are already in combat!")
- return
- }
-
- mob := g.findMob(sess, input, p.RoomID)
- if mob == nil {
- return
- }
-
- if mob.HP <= 0 {
- sess.WriteLine("That is already dead.")
- return
- }
-
- if mob.Protected {
- sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true)))
- return
- }
-
- if combat.IsMobInCombat(mob.InstanceID) {
- sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
- return
- }
-
- g.startCombat(sess, p, mob)
-}
-
-func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance {
- lower := strings.ToLower(input)
- mobs := g.MobStore.MobsInRoom(roomID)
-
- idx := -1
- name := lower
- if dotPos := strings.Index(lower, "."); dotPos > 0 {
- if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 {
- idx = n
- name = lower[dotPos+1:]
- }
- }
-
- var exact []*world.MobInstance
- var prefix []*world.MobInstance
- for _, m := range mobs {
- q := m.MatchQuality(name)
- if q == world.MatchExact {
- exact = append(exact, m)
- } else if q == world.MatchPrefix {
- prefix = append(prefix, m)
- }
- }
-
- // Prefer exact matches
- candidates := exact
- if len(candidates) == 0 {
- candidates = prefix
- }
-
- if len(candidates) == 0 {
- sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
- return nil
- }
-
- sort.Slice(candidates, func(i, j int) bool {
- return candidates[i].InstanceID < candidates[j].InstanceID
- })
-
- if idx > 0 {
- if idx-1 < len(candidates) {
- return candidates[idx-1]
- }
- return nil
- }
-
- if len(candidates) == 1 {
- return candidates[0]
- }
-
- // Multiple candidates — different names means ambiguous
- seen := make(map[string]bool)
- for _, m := range candidates {
- seen[mobDisplayName(m, false)] = true
- }
- if len(seen) > 1 {
- sess.WriteLine("Which one?")
- return nil
- }
- // Same name — return the first
- return candidates[0]
-}
-
-func (g *Game) seedRoomMobs(roomID int) {
- room, err := g.World.LoadRoom(roomID)
- if err != nil {
- return
- }
- if len(room.Mobs) == 0 {
- return
- }
- g.MobStore.SeedMobs(roomID, room.Mobs)
-}
-
-func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- g.cancelRest(p.Name)
-
- instanceID := g.findMobInstanceID(mob)
-
- combat.EnterCombat(p.Name, instanceID)
-
- playerSpeed := g.playerWeaponSpeed(p)
-
- attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
- var styleParts []string
- if attBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus))
- }
- if strBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus))
- }
- if defBonus > 0 {
- styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus))
- }
- styleStr := ""
- if len(styleParts) > 0 {
- styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
- }
- sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr))
-
- // Player tick callback
- g.Ticks.Subscribe(playerSpeed, func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- g.playerAttack(sess, p, currentMob)
- if currentMob.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
-
- // Mob tick callback
- g.Ticks.Subscribe(mob.Speed, func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- return false
- }
- combat.RecordMobAttack(p.Name)
- g.mobAttack(sess, p, currentMob)
- if p.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
-}
-
-func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
-
- equipAtt := 0
- equipStr := 0
- if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipAtt = def.Stats.AttackBonus
- equipStr = def.Stats.StrengthBonus
- }
- }
-
- attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
- defRoll := combat.DefenseRoll(mob.Defense, 0, 0)
-
- if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr)
- dmg := combat.RollDamage(maxHit)
-
- mob.HP -= dmg
- if mob.HP < 0 {
- mob.HP = 0
- }
- if mob.HP < mob.MaxHP && mob.HP > 0 {
- mob.StartRegen()
- }
- gains := g.awardCombatXP(p, dmg)
-
- mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg))
- if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w {
- w = w2
- }
- g.combatPadWidth = w
- prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)
- hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP)
- line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)
- if p.Toggles["xpdrops"] && len(gains) > 0 {
- var parts []string
- for _, g := range gains {
- parts = append(parts, fmt.Sprintf("+%dxp %s", g.XP, player.SkillAbbr[g.Skill]))
- }
- line += " (" + strings.Join(parts, ", ") + ")"
- }
- sess.WriteLine(line)
- } else {
- sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))
- }
-}
-
-func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
-
- equipDef := 0
- for _, itemID := range p.Equipment {
- def, err := g.ItemStore.Load(itemID)
- if err == nil {
- equipDef += def.Stats.DefenseBonus
- }
- }
-
- attRoll := combat.AttackRoll(mob.Attack, 0, 0)
- defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef)
-
- if combat.HitCheck(attRoll, defRoll) {
- maxHit := combat.MaxHit(mob.Strength, 0, 0)
- dmg := combat.RollDamage(maxHit)
-
- p.HP -= dmg
- if p.HP < 0 {
- p.HP = 0
- }
- p.StartRegen()
- g.AccountStore.SaveCharacter(p)
-
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- mobName := mobDisplayName(mob, true)
- w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg))
- if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w {
- w = w2
- }
- g.combatPadWidth = w
- prefix := fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)
- hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP())
- sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart))
- } else {
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s misses you.", attacker))
- }
-}
-
-func (g *Game) DisconnectTick() {
- if g.Hub == nil {
- return
- }
- for _, sess := range g.Hub.AllSessions() {
- if !sess.Disconnecting {
- continue
- }
- p, ok := sess.Player.(*player.Player)
- if !ok {
- g.Hub.Remove(sess)
- continue
- }
- if combat.GetCombat(p.Name) != nil {
- continue
- }
- sess.DisconnectTicks--
- if sess.DisconnectTicks <= 0 {
- g.AccountStore.SaveCharacter(p)
- g.Hub.HardRemove(sess)
- }
- }
-}
-
-func (g *Game) RegenTick() {
- if g.Hub == nil {
- return
- }
- for _, sess := range g.Hub.AllSessions() {
- p, ok := sess.Player.(*player.Player)
- if !ok {
- continue
- }
- if p.HP <= 0 || p.HP >= p.MaxHP() {
- p.RegenerateTick = 0
- continue
- }
- p.RegenerateTick--
- if p.RegenerateTick <= 0 {
- p.HP++
- if p.HP >= p.MaxHP() {
- p.HP = p.MaxHP()
- p.RegenerateTick = 0
- } else {
- p.RegenerateTick = 100
- }
- }
- }
-}
-
-func (g *Game) roomsWithinRange(homeID int, maxDist int) map[int]bool {
- reachable := map[int]bool{homeID: true}
- if maxDist <= 0 {
- return reachable
- }
- frontier := []int{homeID}
- dist := map[int]int{homeID: 0}
- for len(frontier) > 0 {
- current := frontier[0]
- frontier = frontier[1:]
- if dist[current] >= maxDist {
- continue
- }
- room, err := g.World.LoadRoom(current)
- if err != nil {
- continue
- }
- for _, targetID := range room.Exits {
- if _, ok := dist[targetID]; ok {
- continue
- }
- dist[targetID] = dist[current] + 1
- reachable[targetID] = true
- frontier = append(frontier, targetID)
- }
- }
- return reachable
-}
-
-func (g *Game) WanderTick() {
- if g.Hub == nil {
- return
- }
-
- type moveEvent struct {
- inst *world.MobInstance
- fromRoom int
- toRoom int
- exitDir world.ExitDir
- }
- var moves []moveEvent
-
- for _, inst := range g.MobStore.AllInstances() {
- if inst.HP <= 0 || inst.Wander <= 0 || inst.WanderTick <= 0 {
- continue
- }
- if combat.IsMobInCombat(inst.InstanceID) {
- continue
- }
- inst.WanderTickCounter++
- if inst.WanderTickCounter >= inst.WanderTick {
- inst.WanderTickCounter = 0
-
- if rand.Float64() < inst.WanderChance {
- room, err := g.World.LoadRoom(inst.RoomID)
- if err != nil || len(room.Exits) == 0 {
- continue
- }
-
- reachable := g.roomsWithinRange(inst.HomeRoomID, inst.Wander)
- var validDirs []world.ExitDir
- for dir, targetID := range room.Exits {
- if reachable[targetID] {
- validDirs = append(validDirs, dir)
- }
- }
- if len(validDirs) > 0 {
- dir := validDirs[rand.Intn(len(validDirs))]
- moves = append(moves, moveEvent{inst, inst.RoomID, room.Exits[dir], dir})
- }
- }
- }
- }
-
- for _, m := range moves {
- m.inst.RoomID = m.toRoom
- for _, sess := range g.Hub.AllSessions() {
- p, ok := sess.Player.(*player.Player)
- if !ok {
- continue
- }
- if p.RoomID == m.fromRoom && p.Toggles["mobleave"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), m.exitDir))
- }
- if p.RoomID == m.toRoom && p.Toggles["mobenter"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters from the %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), world.OppositeExit[m.exitDir]))
- }
- }
- }
-}
-
-func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- g.combatPadWidth = 0
- combat.LeaveCombat(p.Name)
-
- if p.HP <= 0 {
- sess.WriteLine(fmt.Sprintf("\nOh dear, you are dead!"))
- g.dropItemsOnDeath(p)
- p.HP = p.MaxHP()
- p.RoomID = 1
- g.AccountStore.SaveCharacter(p)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- return
- }
-
- if mob != nil && mob.HP <= 0 {
- sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other != sess && other.Player != nil {
- other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob)))
- }
- }
- }
-
- // Always drop remains
- if mob.Drops.Remains != "" {
- g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
- def, _ := g.ItemStore.Load(mob.Drops.Remains)
- name := mob.Drops.Remains
- if def != nil {
- name = def.Name
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
- }
-
- // Weighted loot roll — exactly one result
- if len(mob.Drops.Loot) > 0 {
- totalWeight := 0
- for _, e := range mob.Drops.Loot {
- totalWeight += e.Weight
- }
- roll := randInt(totalWeight)
- cumulative := 0
- for _, e := range mob.Drops.Loot {
- cumulative += e.Weight
- if roll < cumulative {
- g.World.AddReservedGroundItem(p.RoomID, e.ItemID, e.Quantity, p.Name)
- def, _ := g.ItemStore.Load(e.ItemID)
- name := e.ItemID
- if def != nil {
- name = def.Name
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- if e.Quantity > 1 {
- sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, e.Quantity, name))
- } else {
- sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name))
- }
- break
- }
- }
- }
-
- // Schedule respawn
- instanceID := g.findMobInstanceID(mob)
- respawnTicks := mob.RespawnTicks
- if respawnTicks <= 0 {
- respawnTicks = 30
- }
- g.Ticks.Subscribe(respawnTicks, func() bool {
- g.respawnMob(instanceID)
- return false
- })
- }
-}
-
-type xpGain struct {
- Skill player.SkillName
- XP int
-}
-
-func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain {
- baseXP := dmg * 4
- var gains []xpGain
-
- switch p.AttackStyle {
- case player.Accurate:
- gains = []xpGain{{player.Attack, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Aggressive:
- gains = []xpGain{{player.Strength, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Defensive:
- gains = []xpGain{{player.Defense, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}}
- case player.Balanced:
- quarter := baseXP / 4
- gains = []xpGain{{player.Attack, quarter}, {player.Strength, quarter}, {player.Defense, quarter}, {player.Hitpoints, quarter}}
- }
-
- for _, g := range gains {
- p.AddXP(g.Skill, g.XP)
- }
- g.AccountStore.SaveCharacter(p)
- return gains
-}
-
-type deathDrop struct {
- itemID string
- quantity int
- totalVal int
- isEquip bool
- equipSlot object.EquipSlot
- invSlot int
-}
-
-func (g *Game) dropItemsOnDeath(p *player.Player) {
- roomID := p.RoomID
-
- // Credits always drop on death
- if p.Credits > 0 {
- g.World.AddGroundItem(roomID, "credits", p.Credits)
- p.Credits = 0
- }
-
- var items []deathDrop
-
- // Inventory
- for slot, inv := range p.Inventory {
- if inv == nil || inv.Quantity <= 0 {
- continue
- }
- val := 0
- if def, err := g.ItemStore.Load(inv.ItemID); err == nil {
- val = def.Value * inv.Quantity
- }
- items = append(items, deathDrop{
- itemID: inv.ItemID,
- quantity: inv.Quantity,
- totalVal: val,
- invSlot: slot,
- })
- }
-
- // Equipment
- for eqSlot, itemID := range p.Equipment {
- val := 0
- if def, err := g.ItemStore.Load(itemID); err == nil {
- val = def.Value
- }
- items = append(items, deathDrop{
- itemID: itemID,
- quantity: 1,
- totalVal: val,
- isEquip: true,
- equipSlot: eqSlot,
- })
- }
-
- if len(items) <= 3 {
- return
- }
-
- sort.Slice(items, func(i, j int) bool {
- return items[i].totalVal > items[j].totalVal
- })
-
- for i := 3; i < len(items); i++ {
- it := items[i]
- if it.isEquip {
- delete(p.Equipment, it.equipSlot)
- } else {
- p.SetInvSlot(it.invSlot, nil)
- }
- g.World.AddGroundItem(roomID, it.itemID, it.quantity)
- }
-}
-
-func (g *Game) stopCombat(playerName string) {
- cs := combat.GetCombat(playerName)
- if cs == nil {
- return
- }
- combat.LeaveCombat(playerName)
-}
-
-func (g *Game) respawnMob(instanceID string) {
- inst := g.MobStore.GetInstance(instanceID)
- if inst == nil {
- return
- }
- homeRoom := inst.HomeRoomID
- inst.RoomID = homeRoom
- inst.HP = inst.MaxHP
- g.MobStore.RollIdleDescription(inst)
-
- if g.Hub != nil {
- for _, sess := range g.Hub.PlayersInRoom(homeRoom) {
- if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters the area.", mobDisplayName(inst, false), mobCombatLevel(inst)))
- }
- }
- }
-}
-
-func (g *Game) findMobInstanceID(mob *world.MobInstance) string {
- return mob.InstanceID
-}
-
-func (g *Game) playerWeaponSpeed(p *player.Player) int {
- if itemID, ok := p.Equipment[object.SlotMainHand]; ok {
- def, err := g.ItemStore.Load(itemID)
- if err == nil && def.Speed > 0 {
- return def.Speed
- }
- }
- return 5 // unarmed speed
-}
-
-func mobDisplayName(m *world.MobInstance, definite bool) string {
- if m.Unique {
- return m.Name
- }
- if definite {
- return "the " + m.Name
- }
- return "a " + m.Name
-}
-
-func mobCombatLevel(m *world.MobInstance) int {
- base := 0.25 * float64(m.Defense+m.MaxHP+m.Defense)
- base += 0.25 * float64(m.Attack+m.Strength)
- return int(base)
-}
-
-func randInt(max int) int {
- if max <= 0 {
- return 0
- }
- return combat.RollDamage(max) - 1
+ sess.Write("\r\n> ")
}