aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-09 05:59:20 -0400
committerhistoria <[not public]>2026-06-09 05:59:20 -0400
commit9eb5ab1818b6b19501fd7209db1987c9e06cc919 (patch)
tree76e046b0bf611dfe939a8c8f6507467de2e9e20f /internal/game
downloadthehouseoficarus-9eb5ab1818b6b19501fd7209db1987c9e06cc919.tar.gz
first commit
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/game.go1569
-rw-r--r--internal/game/help.go88
2 files changed, 1657 insertions, 0 deletions
diff --git a/internal/game/game.go b/internal/game/game.go
new file mode 100644
index 0000000..67434d3
--- /dev/null
+++ b/internal/game/game.go
@@ -0,0 +1,1569 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/engine"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
+ "thirdcollapse/internal/player"
+ "thirdcollapse/internal/world"
+)
+
+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
+}
+
+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,
+ }
+}
+
+func (g *Game) SetHub(hub *net.Hub) {
+ g.Hub = hub
+}
+
+func (g *Game) HandleSession(sess *net.Session, input string) {
+ switch sess.State {
+ case net.StateAccountName:
+ g.handleAccountName(sess, input)
+ case net.StatePassword:
+ g.handlePassword(sess, input)
+ case net.StateNewAccountPass:
+ g.handleNewAccountPass(sess, input)
+ case net.StateNewAccountConfirm:
+ g.handleNewAccountConfirm(sess, input)
+ case net.StateMenu:
+ g.handleMenu(sess, input)
+ case net.StateNewCharName:
+ g.handleNewCharName(sess, input)
+ case net.StateRenameAccount:
+ g.handleRenameAccount(sess, input)
+ case net.StateRenameChar:
+ g.handleRenameChar(sess, input)
+ case net.StateRenameCharName:
+ g.handleRenameCharName(sess, input)
+ case net.StateDeleteChar:
+ g.handleDeleteChar(sess, input)
+ case net.StatePurgeAccount:
+ g.handlePurgeAccount(sess, input)
+ case net.StateGame:
+ g.handleGameCommand(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("Account not found. Enter a password to create it (or press enter to cancel): ")
+ }
+}
+
+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.", sess.Account.Name))
+ sess.Conn.Close()
+}
+
+func (g *Game) showMenu(sess *net.Session) {
+ sess.State = net.StateMenu
+ lines := []string{
+ "",
+ fmt.Sprintf("Welcome, %s!", sess.Account.Name),
+ "",
+ "(C)onnect to character",
+ }
+ if len(sess.Account.Characters) > 0 {
+ lines = append(lines, "(L)ist characters")
+ }
+ lines = append(lines,
+ "(N)ew character",
+ "(R)ename character",
+ "(D)elete character",
+ "(P)urge account",
+ "(A)ccount rename",
+ "(Q)uit",
+ "",
+ "Choice: ",
+ )
+ sess.WriteLines(lines...)
+}
+
+func (g *Game) handleMenu(sess *net.Session, input string) {
+ switch strings.ToLower(strings.TrimSpace(input)) {
+ case "":
+ sess.Write("Choice: ")
+ case "c":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nNo characters on this account.")
+ sess.Write("\nChoice: ")
+ 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("\nNo characters on this account.")
+ } else {
+ sess.WriteLine("\nCharacters:")
+ for _, name := range sess.Account.Characters {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
+ }
+ }
+ sess.Write("\nChoice: ")
+
+ case "n":
+ sess.State = net.StateNewCharName
+ sess.PendingChar = ""
+ sess.Write("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("\nChoice: ")
+
+ case "d":
+ if len(sess.Account.Characters) == 0 {
+ sess.WriteLine("\nNo characters to delete.")
+ sess.Write("\nChoice: ")
+ 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("\nChoice: ")
+ return
+ }
+ g.showDeleteConfirm(sess)
+
+ case "p":
+ sess.State = net.StatePurgeAccount
+ sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters? Type PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name))
+ sess.Write("\n> ")
+
+ case "q":
+ sess.WriteLine("Goodbye.")
+ sess.Conn.Close()
+ return
+
+ default:
+ sess.Write("Invalid choice. Choice: ")
+ }
+}
+
+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) {
+ 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.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("> ")
+ return
+ }
+
+ parts := strings.Fields(strings.ToLower(input))
+ cmd := parts[0]
+ args := parts[1:]
+
+ switch cmd {
+ case "get", "take", "grab", "pick":
+ if len(args) == 0 {
+ sess.WriteLine("Get what?")
+ } else if args[0] == "all" {
+ g.doGetAll(sess)
+ } else {
+ g.doGet(sess, strings.Join(args, " "))
+ }
+ case "drop":
+ if len(args) == 0 {
+ sess.WriteLine("Drop what?")
+ } else {
+ g.doDrop(sess, strings.Join(args, " "))
+ }
+ case "attack", "kill":
+ if len(args) == 0 {
+ sess.WriteLine("Attack what?")
+ } else {
+ g.doAttack(sess, strings.Join(args, " "))
+ }
+ case "style":
+ if len(args) == 0 {
+ g.doStyle(sess, "")
+ } else {
+ g.doStyle(sess, args[0])
+ }
+ case "look", "l":
+ g.doLook(sess)
+ case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d":
+ g.doMove(sess, cmd)
+ case "say":
+ if len(args) == 0 {
+ sess.WriteLine("Say what?")
+ } else {
+ g.doSay(sess, strings.Join(parts[1:], " "))
+ }
+ case "sc", "score":
+ g.doScore(sess)
+ case "i", "inv", "inventory":
+ g.doInventory(sess)
+ case "eq", "equipment":
+ g.doEquipment(sess)
+ case "quit":
+ g.doQuit(sess)
+ return
+ case "ticktest":
+ g.doTickTest(sess)
+ case "help":
+ if len(args) == 0 {
+ g.doHelp(sess, "")
+ } else {
+ g.doHelp(sess, strings.Join(args, " "))
+ }
+ default:
+ sess.WriteLine("Unknown command.")
+ }
+
+ sess.Write("\r\n> ")
+}
+
+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
+ }
+
+ // 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))
+ 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,
+ "",
+ )
+
+ 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
+ }
+ }
+ sess.WriteLine("")
+ }
+
+ if len(room.Objects) > 0 {
+ 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))
+ }
+ }
+ }
+
+ // Ground items
+ ground := g.World.GroundItems(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
+ if err == nil {
+ name = def.Name
+ }
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf(" %d x %s", qty, name))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" %s", name))
+ }
+ }
+ }
+
+ // Mobs
+ mobs := g.MobStore.MobsInRoom(p.RoomID)
+ if len(mobs) > 0 {
+ sess.WriteLine("")
+ for _, m := range mobs {
+ sess.WriteLine(fmt.Sprintf(" %s (level %d)", m.Name, mobCombatLevel(m)))
+ }
+ }
+
+ // Show other players
+ others := g.Hub.PlayersInRoom(p.RoomID)
+ 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))
+ }
+ }
+}
+
+func (g *Game) doSay(sess *net.Session, msg string) {
+ p := sess.Player.(*player.Player)
+ roomID := p.RoomID
+
+ for _, other := range g.Hub.PlayersInRoom(roomID) {
+ if other == sess {
+ other.WriteLine(fmt.Sprintf("\nYou say: %s", msg))
+ } else {
+ other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg))
+ }
+ }
+}
+
+func (g *Game) doScore(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLines(
+ "",
+ fmt.Sprintf("Name: %s", p.Name),
+ fmt.Sprintf("Combat Level: %d", p.CombatLevel()),
+ fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()),
+ fmt.Sprintf("Credits: %d", p.Credits),
+ "",
+ "Skills:",
+ )
+ for _, s := range player.AllSkills {
+ level := p.Level(s)
+ xp := p.Skills[s]
+ next := player.XPForNextLevel(xp)
+ sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next))
+ }
+}
+
+func (g *Game) doInventory(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLine("")
+ sess.WriteLine("Inventory (28 slots):")
+
+ empty := 0
+ num := 0
+ for i := 0; i < 28; i++ {
+ slot := p.InvSlot(i)
+ if slot == nil {
+ empty++
+ continue
+ }
+ num++
+ def, err := g.ItemStore.Load(slot.ItemID)
+ name := slot.ItemID
+ if err == nil {
+ name = def.Name
+ }
+ if slot.Quantity > 1 {
+ sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name))
+ }
+ }
+ sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty))
+}
+
+func (g *Game) doEquipment(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ sess.WriteLine("")
+ sess.WriteLine("Equipment:")
+
+ 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) doQuit(sess *net.Session) {
+ p, ok := sess.Player.(*player.Player)
+ if ok {
+ g.AccountStore.SaveCharacter(p)
+ if g.Hub != nil {
+ g.Hub.LeaveRoom(sess)
+ }
+ }
+ sess.WriteLine("\nGoodbye!")
+ sess.Conn.Close()
+}
+
+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 := g.World.RemoveGroundItem(p.RoomID, itemID, 999999)
+ 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 := g.World.RemoveGroundItem(p.RoomID, itemID, 999999)
+ 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 {
+ 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
+ break
+ }
+ }
+ if stacked {
+ break
+ }
+ // No existing stack — take all into one new slot
+ 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
+ }
+ g.World.RemoveGroundItem(p.RoomID, itemID, qty)
+ 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
+ g.World.RemoveGroundItem(p.RoomID, itemID, take)
+ 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 innerPickupReport(sess *net.Session, picked []string) {
+ for i, name := range picked {
+ if i == len(picked)-1 {
+ sess.WriteLine(name)
+ } else if i == len(picked)-2 {
+ sess.Write(name + " and ")
+ } else {
+ sess.Write(name + ", ")
+ }
+ }
+}
+
+func (g *Game) doGet(sess *net.Session, input string) {
+ p := sess.Player.(*player.Player)
+ ground := g.World.GroundItems(p.RoomID)
+ if len(ground) == 0 {
+ sess.WriteLine("There's nothing on the ground to pick up.")
+ return
+ }
+
+ matches := g.findGroundMatches(input, p.RoomID)
+ if len(matches) == 0 {
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+ return
+ }
+
+ if len(matches) > 1 {
+ unique := uniqueItemNames(matches)
+ if len(unique) > 1 {
+ sess.WriteLine("Which one?")
+ for _, name := range unique {
+ sess.WriteLine(fmt.Sprintf(" - %s", name))
+ }
+ return
+ }
+ }
+
+ itemID := matches[0].ID
+
+ // 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 {
+ 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))
+ return
+ }
+ }
+ }
+
+ 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 {
+ 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(input, p.RoomID)
+ if mob == nil {
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here to attack.", input))
+ return
+ }
+
+ if mob.HP <= 0 {
+ sess.WriteLine("That is already dead.")
+ return
+ }
+
+ g.startCombat(sess, p, mob)
+}
+
+func (g *Game) findMob(input string, roomID int) *world.MobInstance {
+ lower := strings.ToLower(input)
+ mobs := g.MobStore.MobsInRoom(roomID)
+ for _, m := range mobs {
+ if strings.ToLower(m.Name) == lower {
+ return m
+ }
+ }
+ return nil
+}
+
+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) {
+ // Find a unique instance ID for this mob
+ instanceID := g.findMobInstanceID(mob)
+
+ combat.EnterCombat(p.Name, instanceID)
+
+ playerSpeed := g.playerWeaponSpeed(p)
+
+ sess.WriteLine(fmt.Sprintf("\nYou attack the %s!", mob.Name))
+
+ // 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
+ }
+ 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))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" You miss the %s.", mob.Name))
+ }
+}
+
+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
+ }
+ 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()))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" The %s misses you.", mob.Name))
+ }
+}
+
+func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ 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 the %s!", mob.Name))
+
+ // Always drop remains
+ if mob.Drops.Remains != "" {
+ g.World.AddGroundItem(p.RoomID, mob.Drops.Remains, 1)
+ 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))
+ }
+
+ // 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.AddGroundItem(p.RoomID, e.ItemID, e.Quantity)
+ def, _ := g.ItemStore.Load(e.ItemID)
+ name := e.ItemID
+ if def != nil {
+ name = def.Name
+ }
+ if e.Quantity > 1 {
+ sess.WriteLine(fmt.Sprintf(" The %s drops: %d x %s", mob.Name, e.Quantity, name))
+ } else {
+ sess.WriteLine(fmt.Sprintf(" The %s drops: %s", mob.Name, 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
+ })
+ }
+}
+
+func (g *Game) awardCombatXP(p *player.Player, dmg int) {
+ baseXP := dmg * 4
+
+ switch p.AttackStyle {
+ case player.Accurate:
+ p.AddXP(player.Attack, baseXP*3/4)
+ p.AddXP(player.Hitpoints, baseXP/4)
+ case player.Aggressive:
+ p.AddXP(player.Strength, baseXP*3/4)
+ p.AddXP(player.Hitpoints, baseXP/4)
+ case player.Defensive:
+ p.AddXP(player.Defense, baseXP*3/4)
+ p.AddXP(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)
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}
+
+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
+ }
+ inst.HP = inst.MaxHP
+}
+
+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 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
+}
diff --git a/internal/game/help.go b/internal/game/help.go
new file mode 100644
index 0000000..1e93dcf
--- /dev/null
+++ b/internal/game/help.go
@@ -0,0 +1,88 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "gopkg.in/yaml.v3"
+)
+
+type HelpDef struct {
+ Name string `yaml:"name"`
+ Category string `yaml:"category"`
+ Content string `yaml:"description"`
+}
+
+func LoadHelp(dataDir string) ([]HelpDef, error) {
+ dir := filepath.Join(dataDir, "help")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ var helps []HelpDef
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ continue
+ }
+ var h HelpDef
+ if err := yaml.Unmarshal(data, &h); err != nil {
+ continue
+ }
+ helps = append(helps, h)
+ }
+ return helps, nil
+}
+
+func (g *Game) doHelp(sess *net.Session, topic string) {
+ helps, err := LoadHelp(g.dataDir)
+ if err != nil || len(helps) == 0 {
+ sess.WriteLine("No help available.")
+ return
+ }
+
+ if topic == "" {
+ // Group by category
+ cats := make(map[string][]HelpDef)
+ var catOrder []string
+ for _, h := range helps {
+ if _, ok := cats[h.Category]; !ok {
+ catOrder = append(catOrder, h.Category)
+ }
+ cats[h.Category] = append(cats[h.Category], h)
+ }
+
+ sess.WriteLine("\nCommands:")
+ for _, cat := range catOrder {
+ sess.WriteLine(fmt.Sprintf("\n %s:", cat))
+ for _, h := range cats[cat] {
+ sess.WriteLine(fmt.Sprintf(" %-12s - %s", h.Name, firstLine(h.Content)))
+ }
+ }
+ sess.WriteLine("\n Use 'help <command>' for details.")
+ return
+ }
+
+ topic = strings.ToLower(topic)
+ for _, h := range helps {
+ if strings.ToLower(h.Name) == topic {
+ sess.WriteLine(fmt.Sprintf("\n %s", h.Content))
+ return
+ }
+ }
+ sess.WriteLine(fmt.Sprintf("No help found for '%s'.", topic))
+}
+
+func firstLine(s string) string {
+ if idx := strings.Index(s, "\n"); idx >= 0 {
+ return s[:idx]
+ }
+ return s
+}