package game import ( "fmt" "math/rand" "os" "sort" "strconv" "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 restTimers map[string]uint64 // player name -> tick subscription ID loggedInChars map[string]*net.Session // character name -> 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, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), } } func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { if p, ok := sess.Player.(*player.Player); ok { delete(g.loggedInChars, p.Name) } }) } func (g *Game) HandleSession(sess *net.Session, input string) { 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) case net.StateChangeDescription: g.handleDescriptionChange(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) { 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("> ") return } if p, ok := sess.Player.(*player.Player); ok { g.cancelRest(p.Name) } parts := strings.Fields(strings.ToLower(input)) cmd := parts[0] args := parts[1:] 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, " ")) return } case "style": if len(args) == 0 { g.doStyle(sess, "") } else { g.doStyle(sess, args[0]) } case "look", "l": if len(args) == 0 { g.doLook(sess) } else { g.doLookTarget(sess, strings.Join(args, " ")) } case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d": g.doMove(sess, cmd) case "say": 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 "description", "desc": g.doDescription(sess) case "toggle": g.doToggle(sess, strings.Join(args, " ")) case "exits": g.doExits(sess) case "ticktest": g.doTickTest(sess) case "help": if len(args) == 0 { g.doHelp(sess, "") } 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)) } 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)) } 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 ") } 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 { 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 }