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