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 }