diff options
| author | historia <[not public]> | 2026-06-09 05:59:20 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-09 05:59:20 -0400 |
| commit | 9eb5ab1818b6b19501fd7209db1987c9e06cc919 (patch) | |
| tree | 76e046b0bf611dfe939a8c8f6507467de2e9e20f /internal/game | |
| download | thehouseoficarus-9eb5ab1818b6b19501fd7209db1987c9e06cc919.tar.gz | |
first commit
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/game.go | 1569 | ||||
| -rw-r--r-- | internal/game/help.go | 88 |
2 files changed, 1657 insertions, 0 deletions
diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..67434d3 --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,1569 @@ +package game + +import ( + "fmt" + "os" + "sort" + "strings" + + "thirdcollapse/internal/combat" + "thirdcollapse/internal/engine" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +type Game struct { + World *world.World + ObjectStore *object.ObjectStore + ItemStore *object.ItemStore + AccountStore *player.AccountStore + MobStore *world.MobStore + Hub *net.Hub + Ticks *engine.Engine + dataDir string +} + +func New(dataDir string) *Game { + return &Game{ + World: world.New(dataDir), + ObjectStore: object.NewObjectStore(dataDir), + ItemStore: object.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(dataDir), + MobStore: world.NewMobStore(dataDir), + Ticks: engine.New(), + dataDir: dataDir, + } +} + +func (g *Game) SetHub(hub *net.Hub) { + g.Hub = hub +} + +func (g *Game) HandleSession(sess *net.Session, input string) { + switch sess.State { + case net.StateAccountName: + g.handleAccountName(sess, input) + case net.StatePassword: + g.handlePassword(sess, input) + case net.StateNewAccountPass: + g.handleNewAccountPass(sess, input) + case net.StateNewAccountConfirm: + g.handleNewAccountConfirm(sess, input) + case net.StateMenu: + g.handleMenu(sess, input) + case net.StateNewCharName: + g.handleNewCharName(sess, input) + case net.StateRenameAccount: + g.handleRenameAccount(sess, input) + case net.StateRenameChar: + g.handleRenameChar(sess, input) + case net.StateRenameCharName: + g.handleRenameCharName(sess, input) + case net.StateDeleteChar: + g.handleDeleteChar(sess, input) + case net.StatePurgeAccount: + g.handlePurgeAccount(sess, input) + case net.StateGame: + g.handleGameCommand(sess, input) + } +} + +func (g *Game) handleAccountName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if name == "" { + sess.Write("Account name: ") + return + } + actualName, err := g.AccountStore.FindAccount(name) + if err == nil { + sess.Account = &net.AccountEntry{Name: actualName} + sess.State = net.StatePassword + sess.Write("Password: ") + } else { + sess.Account = &net.AccountEntry{Name: name} + sess.State = net.StateNewAccountPass + sess.Write("Account not found. Enter a password to create it (or press enter to cancel): ") + } +} + +func (g *Game) handlePassword(sess *net.Session, input string) { + if input == "" { + sess.Write("Password: ") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine("Error loading account.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if !player.CheckPassword(input, acc.PasswordHash) { + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Characters: acc.Characters, + } + g.showMenu(sess) +} + +func (g *Game) handleNewAccountPass(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.PendingPass = input + sess.State = net.StateNewAccountConfirm + sess.Write("Confirm password: ") +} + +func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if input != sess.PendingPass { + sess.WriteLine("Passwords do not match.") + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + hash, err := player.HashPassword(input) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + acc := &player.Account{ + Name: sess.Account.Name, + PasswordHash: hash, + } + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + } + sess.PendingPass = "" + g.showMenu(sess) +} + +func (g *Game) handleRenameAccount(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + if newName == "" { + sess.Write("New account name: ") + return + } + oldName := sess.Account.Name + if newName == oldName { + sess.WriteLine("That's already your account name.") + g.showMenu(sess) + return + } + if g.AccountStore.AccountExists(newName) { + sess.WriteLine("An account with that name already exists.") + sess.Write("New account name: ") + return + } + + // Rename the account file + oldPath := g.AccountStore.AccountPath(oldName) + newPath := g.AccountStore.AccountPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + g.showMenu(sess) + return + } + + sess.Account.Name = newName + sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) + g.showMenu(sess) +} + +func (g *Game) handleRenameChar(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + + // Verify the character exists on this account + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + g.showMenu(sess) + return + } + + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) + sess.Write("New name: ") +} + +func (g *Game) handleRenameCharName(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + oldName := sess.PendingChar + if newName == "" { + sess.Write("New name: ") + return + } + if newName == oldName { + sess.WriteLine("That's already the character's name.") + g.showMenu(sess) + return + } + if g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") + return + } + + // Rename character file + oldPath := g.AccountStore.CharPath(oldName) + newPath := g.AccountStore.CharPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } + + // Load and update the character's internal name + p, _ := g.AccountStore.LoadCharacter(newName) + if p != nil { + p.Name = newName + g.AccountStore.SaveCharacter(p) + } + + // Update account character list + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + for i, c := range acc.Characters { + if c == oldName { + acc.Characters[i] = newName + break + } + } + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + sess.PendingChar = "" + sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + g.showMenu(sess) +} + +func (g *Game) showDeleteConfirm(sess *net.Session) { + sess.State = net.StateDeleteChar + sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) +} + +func (g *Game) handleDeleteChar(sess *net.Session, input string) { + // If PendingChar not set, this is the character selection step + if sess.PendingChar == "" { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + g.showDeleteConfirm(sess) + return + } + + // Confirmation step + input = strings.TrimSpace(input) + expected := "DELETE " + sess.PendingChar + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Delete cancelled.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + + // Delete the character file + charPath := g.AccountStore.CharPath(sess.PendingChar) + if err := os.Remove(charPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + + // Remove from account + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + var newChars []string + for _, c := range acc.Characters { + if c != sess.PendingChar { + newChars = append(newChars, c) + } + } + acc.Characters = newChars + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = newChars + + sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) +} + +func (g *Game) handlePurgeAccount(sess *net.Session, input string) { + input = strings.TrimSpace(input) + expected := "PURGE " + sess.Account.Name + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Purge cancelled.") + g.showMenu(sess) + return + } + + // Delete all character files + for _, name := range sess.Account.Characters { + os.Remove(g.AccountStore.CharPath(name)) + } + + // Delete account file + os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) + + sess.WriteLine(fmt.Sprintf("Account %s has been purged.", sess.Account.Name)) + sess.Conn.Close() +} + +func (g *Game) showMenu(sess *net.Session) { + sess.State = net.StateMenu + lines := []string{ + "", + fmt.Sprintf("Welcome, %s!", sess.Account.Name), + "", + "(C)onnect to character", + } + if len(sess.Account.Characters) > 0 { + lines = append(lines, "(L)ist characters") + } + lines = append(lines, + "(N)ew character", + "(R)ename character", + "(D)elete character", + "(P)urge account", + "(A)ccount rename", + "(Q)uit", + "", + "Choice: ", + ) + sess.WriteLines(lines...) +} + +func (g *Game) handleMenu(sess *net.Session, input string) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "": + sess.Write("Choice: ") + case "c": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters on this account.") + sess.Write("\nChoice: ") + return + } + if len(sess.Account.Characters) == 1 { + g.connectCharacter(sess, sess.Account.Characters[0]) + return + } + sess.WriteLine("\nSelect character:") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.State = net.StateNewCharName + sess.PendingChar = "connect" + sess.Write("\nChoice: ") + + case "l": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters on this account.") + } else { + sess.WriteLine("\nCharacters:") + for _, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + } + sess.Write("\nChoice: ") + + case "n": + sess.State = net.StateNewCharName + sess.PendingChar = "" + sess.Write("Character name: ") + + case "a": + sess.State = net.StateRenameAccount + sess.Write("New account name: ") + + case "r": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to rename.") + sess.Write("\nChoice: ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) + sess.Write("New name: ") + return + } + sess.State = net.StateRenameChar + sess.WriteLine("\nRename which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\nChoice: ") + + case "d": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to delete.") + sess.Write("\nChoice: ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + } else { + sess.State = net.StateDeleteChar + sess.WriteLine("\nDelete which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\nChoice: ") + return + } + g.showDeleteConfirm(sess) + + case "p": + sess.State = net.StatePurgeAccount + sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters? Type PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) + sess.Write("\n> ") + + case "q": + sess.WriteLine("Goodbye.") + sess.Conn.Close() + return + + default: + sess.Write("Invalid choice. Choice: ") + } +} + +func (g *Game) handleNewCharName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + + // Coming from "c" menu — selecting a character by number + if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = "" + g.connectCharacter(sess, sess.Account.Characters[idx-1]) + return + } + sess.WriteLine("Invalid choice.") + sess.Write("\nChoice: ") + return + } + + if name == "" { + sess.Write("Character name: ") + return + } + + if g.AccountStore.CharacterExists(name) { + sess.WriteLine("A character with that name already exists.") + sess.Write("Character name: ") + return + } + + // Create new character + p := player.New(name) + p.RoomID = 1 // spawn room + + if err := g.AccountStore.SaveCharacter(p); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + // Add to account + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc.Characters = append(acc.Characters, name) + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + g.connectCharacter(sess, name) +} + +func (g *Game) connectCharacter(sess *net.Session, name string) { + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + sess.Player = p + sess.State = net.StateGame + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.doLook(sess) + sess.Write("\r\n> ") +} + +func (g *Game) handleGameCommand(sess *net.Session, input string) { + if input == "" { + sess.Write("> ") + return + } + + parts := strings.Fields(strings.ToLower(input)) + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "get", "take", "grab", "pick": + if len(args) == 0 { + sess.WriteLine("Get what?") + } else if args[0] == "all" { + g.doGetAll(sess) + } else { + g.doGet(sess, strings.Join(args, " ")) + } + case "drop": + if len(args) == 0 { + sess.WriteLine("Drop what?") + } else { + g.doDrop(sess, strings.Join(args, " ")) + } + case "attack", "kill": + if len(args) == 0 { + sess.WriteLine("Attack what?") + } else { + g.doAttack(sess, strings.Join(args, " ")) + } + case "style": + if len(args) == 0 { + g.doStyle(sess, "") + } else { + g.doStyle(sess, args[0]) + } + case "look", "l": + g.doLook(sess) + case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d": + g.doMove(sess, cmd) + case "say": + if len(args) == 0 { + sess.WriteLine("Say what?") + } else { + g.doSay(sess, strings.Join(parts[1:], " ")) + } + case "sc", "score": + g.doScore(sess) + case "i", "inv", "inventory": + g.doInventory(sess) + case "eq", "equipment": + g.doEquipment(sess) + case "quit": + g.doQuit(sess) + return + case "ticktest": + g.doTickTest(sess) + case "help": + if len(args) == 0 { + g.doHelp(sess, "") + } else { + g.doHelp(sess, strings.Join(args, " ")) + } + default: + sess.WriteLine("Unknown command.") + } + + sess.Write("\r\n> ") +} + +func (g *Game) doMove(sess *net.Session, dir string) { + p := sess.Player.(*player.Player) + exitDir := g.World.ResolveExit(dir) + if exitDir == "" { + sess.WriteLine("Go where?") + return + } + + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You can't move from here.") + return + } + + targetID, ok := room.Exits[exitDir] + if !ok { + sess.WriteLine("You can't go that way.") + return + } + + _, err = g.World.LoadRoom(targetID) + if err != nil { + sess.WriteLine("That path seems blocked.") + return + } + + // Interrupt combat on move + g.stopCombat(p.Name) + + oldRoom := p.RoomID + p.RoomID = targetID + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + + if g.Hub != nil { + // Notify people in old room + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir)) + } + } + g.Hub.EnterRoom(sess, targetID) + // Notify people in new room + for _, other := range g.Hub.PlayersInRoom(targetID) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name)) + } + } + } + + sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) + targetRoom, _ := g.World.LoadRoom(targetID) + if targetRoom != nil { + sess.WriteLine(targetRoom.Name) + } +} + +func (g *Game) doStyle(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + styles := []string{"accurate", "aggressive", "defensive", "balanced"} + if input == "" { + sess.WriteLine("\nCombat styles:") + for _, s := range styles { + marker := " " + if string(p.AttackStyle) == s { + marker = "*" + } + sess.WriteLine(fmt.Sprintf(" %s %s", marker, s)) + } + return + } + + input = strings.ToLower(input) + for _, s := range styles { + if s == input { + p.AttackStyle = player.AttackStyle(s) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s)) + return + } + } + sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) +} + +func (g *Game) doLook(sess *net.Session) { + p := sess.Player.(*player.Player) + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You are in a void.") + return + } + + sess.WriteLines( + "", + room.Name, + room.Description, + "", + ) + + if len(room.Exits) > 0 { + sess.Write("Exits: ") + first := true + for _, dir := range world.ExitOrder { + if _, ok := room.Exits[dir]; ok { + if !first { + sess.Write(", ") + } + sess.Write(string(dir)) + first = false + } + } + sess.WriteLine("") + } + + if len(room.Objects) > 0 { + sess.WriteLine("") + for _, objID := range room.Objects { + def, err := g.ObjectStore.Load(objID) + if err != nil { + sess.WriteLine(fmt.Sprintf(" - %s", objID)) + } else { + sess.WriteLine(fmt.Sprintf(" - %s", def.Name)) + } + } + } + + // Ground items + ground := g.World.GroundItems(p.RoomID) + if len(ground) > 0 { + sess.WriteLine("") + sess.WriteLine("On the ground:") + for itemID, qty := range ground { + def, err := g.ItemStore.Load(itemID) + name := itemID + if err == nil { + name = def.Name + } + if qty > 1 { + sess.WriteLine(fmt.Sprintf(" %d x %s", qty, name)) + } else { + sess.WriteLine(fmt.Sprintf(" %s", name)) + } + } + } + + // Mobs + mobs := g.MobStore.MobsInRoom(p.RoomID) + if len(mobs) > 0 { + sess.WriteLine("") + for _, m := range mobs { + sess.WriteLine(fmt.Sprintf(" %s (level %d)", m.Name, mobCombatLevel(m))) + } + } + + // Show other players + others := g.Hub.PlayersInRoom(p.RoomID) + for _, other := range others { + if other != sess && other.Player != nil { + op := other.Player.(*player.Player) + sess.WriteLine(fmt.Sprintf("\n%s is here.", op.Name)) + } + } +} + +func (g *Game) doSay(sess *net.Session, msg string) { + p := sess.Player.(*player.Player) + roomID := p.RoomID + + for _, other := range g.Hub.PlayersInRoom(roomID) { + if other == sess { + other.WriteLine(fmt.Sprintf("\nYou say: %s", msg)) + } else { + other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg)) + } + } +} + +func (g *Game) doScore(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLines( + "", + fmt.Sprintf("Name: %s", p.Name), + fmt.Sprintf("Combat Level: %d", p.CombatLevel()), + fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), + fmt.Sprintf("Credits: %d", p.Credits), + "", + "Skills:", + ) + for _, s := range player.AllSkills { + level := p.Level(s) + xp := p.Skills[s] + next := player.XPForNextLevel(xp) + sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next)) + } +} + +func (g *Game) doInventory(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Inventory (28 slots):") + + empty := 0 + num := 0 + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + empty++ + continue + } + num++ + def, err := g.ItemStore.Load(slot.ItemID) + name := slot.ItemID + if err == nil { + name = def.Name + } + if slot.Quantity > 1 { + sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) + } else { + sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) + } + } + sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) +} + +func (g *Game) doEquipment(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Equipment:") + + slots := []object.EquipSlot{ + object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, + object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, + object.SlotMainHand, object.SlotOffHand, object.SlotRing, + } + for _, slot := range slots { + itemID, ok := p.Equipment[slot] + if !ok { + sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot)) + continue + } + def, err := g.ItemStore.Load(itemID) + name := itemID + if err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) + } +} + +func (g *Game) doQuit(sess *net.Session) { + p, ok := sess.Player.(*player.Player) + if ok { + g.AccountStore.SaveCharacter(p) + if g.Hub != nil { + g.Hub.LeaveRoom(sess) + } + } + sess.WriteLine("\nGoodbye!") + sess.Conn.Close() +} + +func parseIndex(s string) (int, error) { + var idx int + _, err := fmt.Sscanf(s, "%d", &idx) + if err != nil { + return 0, err + } + return idx, nil +} + +func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { + qty := g.World.RemoveGroundItem(p.RoomID, itemID, 999999) + if qty <= 0 { + return + } + p.Credits += qty + g.AccountStore.SaveCharacter(p) + if qty == 1 { + sess.WriteLine("You pick up 1 credit.") + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits)) + } +} + +func (g *Game) doGetAll(sess *net.Session) { + p := sess.Player.(*player.Player) + ground := g.World.GroundItems(p.RoomID) + if len(ground) == 0 { + sess.WriteLine("There's nothing on the ground to pick up.") + return + } + + var picked []string + for itemID, qty := range ground { + if qty <= 0 { + continue + } + if itemID == "credits" { + taken := g.World.RemoveGroundItem(p.RoomID, itemID, 999999) + p.Credits += taken + if taken == 1 { + picked = append(picked, "1 credit") + } else { + picked = append(picked, fmt.Sprintf("%d credits", taken)) + } + continue + } + + def, _ := g.ItemStore.Load(itemID) + for qty > 0 { + if def != nil && def.Stackable { + stacked := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID { + slot.Quantity += qty + g.World.RemoveGroundItem(p.RoomID, itemID, qty) + picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity)) + stacked = true + qty = 0 + break + } + } + if stacked { + break + } + // No existing stack — take all into one new slot + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + if len(picked) > 0 { + sess.WriteLine("Inventory full. Picked up so far:") + innerPickupReport(sess, picked) + } else { + sess.WriteLine("Your inventory is full.") + } + g.AccountStore.SaveCharacter(p) + return + } + g.World.RemoveGroundItem(p.RoomID, itemID, qty) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + name := itemID + if def != nil { + name = def.Name + } + picked = append(picked, name) + break + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + if len(picked) > 0 { + sess.WriteLine("Inventory full. Picked up so far:") + innerPickupReport(sess, picked) + } else { + sess.WriteLine("Your inventory is full.") + } + g.AccountStore.SaveCharacter(p) + return + } + + take := 1 + g.World.RemoveGroundItem(p.RoomID, itemID, take) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take}) + name := itemID + if def != nil { + name = def.Name + } + picked = append(picked, name) + qty -= take + } + } + + g.AccountStore.SaveCharacter(p) + + if len(picked) == 0 { + sess.WriteLine("Your inventory is full.") + } else { + sess.Write("You pick up: ") + innerPickupReport(sess, picked) + } +} + +func innerPickupReport(sess *net.Session, picked []string) { + for i, name := range picked { + if i == len(picked)-1 { + sess.WriteLine(name) + } else if i == len(picked)-2 { + sess.Write(name + " and ") + } else { + sess.Write(name + ", ") + } + } +} + +func (g *Game) doGet(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + ground := g.World.GroundItems(p.RoomID) + if len(ground) == 0 { + sess.WriteLine("There's nothing on the ground to pick up.") + return + } + + matches := g.findGroundMatches(input, p.RoomID) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + return + } + + if len(matches) > 1 { + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + } + + itemID := matches[0].ID + + // Credits go to the credits field, not inventory + if itemID == "credits" { + g.pickupCredits(sess, p, itemID) + return + } + + def, _ := g.ItemStore.Load(itemID) + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + qty := 1 + if def != nil && def.Stackable { + ground := g.World.GroundItems(p.RoomID) + if gqty, ok := ground[itemID]; ok && gqty > 0 { + qty = gqty + } + } + // Check if we can stack + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID { + slot.Quantity += qty + g.World.RemoveGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + name := def.Name + sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", name, slot.Quantity)) + return + } + } + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + g.World.RemoveGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) +} + +func (g *Game) doDrop(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + + itemID := matches[0].ID + slotIdx := matches[0].Slot + slot := p.InvSlot(slotIdx) + def, _ := g.ItemStore.Load(itemID) + + qty := 1 + name := itemID + if def != nil { + name = def.Name + } + + if slot.Quantity > qty { + slot.Quantity -= qty + } else { + p.SetInvSlot(slotIdx, nil) + } + + g.World.AddGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) +} + +type itemMatch struct { + ID string + Name string + Slot int // inventory slot index, -1 for ground +} + +func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { + ground := g.World.GroundItems(roomID) + var matches []itemMatch + for itemID := range ground { + def, err := g.ItemStore.Load(itemID) + if err != nil { + continue + } + if def.MatchesName(input) { + matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1}) + } + } + return matches +} + +func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { + var matches []itemMatch + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + if def.MatchesName(input) { + matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i}) + } + } + return matches +} + +func uniqueItemNames(matches []itemMatch) []string { + seen := make(map[string]bool) + var out []string + for _, m := range matches { + if !seen[m.Name] { + seen[m.Name] = true + out = append(out, m.Name) + } + } + return out +} + +func (g *Game) doTickTest(sess *net.Session) { + count := 0 + id := g.Ticks.Subscribe(5, func() bool { + count++ + sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count)) + if count >= 3 { + sess.WriteLine("[Tick test complete.]") + return false + } + return true + }) + _ = id + sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.") +} + +func (g *Game) doAttack(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You are already in combat!") + return + } + + mob := g.findMob(input, p.RoomID) + if mob == nil { + sess.WriteLine(fmt.Sprintf("There's no '%s' here to attack.", input)) + return + } + + if mob.HP <= 0 { + sess.WriteLine("That is already dead.") + return + } + + g.startCombat(sess, p, mob) +} + +func (g *Game) findMob(input string, roomID int) *world.MobInstance { + lower := strings.ToLower(input) + mobs := g.MobStore.MobsInRoom(roomID) + for _, m := range mobs { + if strings.ToLower(m.Name) == lower { + return m + } + } + return nil +} + +func (g *Game) seedRoomMobs(roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return + } + if len(room.Mobs) == 0 { + return + } + g.MobStore.SeedMobs(roomID, room.Mobs) +} + +func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + // Find a unique instance ID for this mob + instanceID := g.findMobInstanceID(mob) + + combat.EnterCombat(p.Name, instanceID) + + playerSpeed := g.playerWeaponSpeed(p) + + sess.WriteLine(fmt.Sprintf("\nYou attack the %s!", mob.Name)) + + // Player tick callback + g.Ticks.Subscribe(playerSpeed, func() bool { + cs := combat.GetCombat(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + g.playerAttack(sess, p, currentMob) + if currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) + + // Mob tick callback + g.Ticks.Subscribe(mob.Speed, func() bool { + cs := combat.GetCombat(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + return false + } + combat.RecordMobAttack(p.Name) + g.mobAttack(sess, p, currentMob) + if p.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) +} + +func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) + + equipAtt := 0 + equipStr := 0 + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil { + equipAtt = def.Stats.AttackBonus + equipStr = def.Stats.StrengthBonus + } + } + + attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) + defRoll := combat.DefenseRoll(mob.Defense, 0, 0) + + if combat.HitCheck(attRoll, defRoll) { + maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr) + dmg := combat.RollDamage(maxHit) + + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + g.awardCombatXP(p, dmg) + + sess.WriteLine(fmt.Sprintf(" You hit the %s for %d damage. (%d/%d HP)", mob.Name, dmg, mob.HP, mob.MaxHP)) + } else { + sess.WriteLine(fmt.Sprintf(" You miss the %s.", mob.Name)) + } +} + +func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + + equipDef := 0 + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err == nil { + equipDef += def.Stats.DefenseBonus + } + } + + attRoll := combat.AttackRoll(mob.Attack, 0, 0) + defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) + + if combat.HitCheck(attRoll, defRoll) { + maxHit := combat.MaxHit(mob.Strength, 0, 0) + dmg := combat.RollDamage(maxHit) + + p.HP -= dmg + if p.HP < 0 { + p.HP = 0 + } + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf(" The %s hits you for %d damage. (%d/%d HP)", mob.Name, dmg, p.HP, p.MaxHP())) + } else { + sess.WriteLine(fmt.Sprintf(" The %s misses you.", mob.Name)) + } +} + +func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + combat.LeaveCombat(p.Name) + + if p.HP <= 0 { + sess.WriteLine(fmt.Sprintf("\nOh dear, you are dead!")) + g.dropItemsOnDeath(p) + p.HP = p.MaxHP() + p.RoomID = 1 + g.AccountStore.SaveCharacter(p) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + return + } + + if mob != nil && mob.HP <= 0 { + sess.WriteLine(fmt.Sprintf("\nYou have defeated the %s!", mob.Name)) + + // Always drop remains + if mob.Drops.Remains != "" { + g.World.AddGroundItem(p.RoomID, mob.Drops.Remains, 1) + def, _ := g.ItemStore.Load(mob.Drops.Remains) + name := mob.Drops.Remains + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf(" The %s drops: %s", mob.Name, name)) + } + + // Weighted loot roll — exactly one result + if len(mob.Drops.Loot) > 0 { + totalWeight := 0 + for _, e := range mob.Drops.Loot { + totalWeight += e.Weight + } + roll := randInt(totalWeight) + cumulative := 0 + for _, e := range mob.Drops.Loot { + cumulative += e.Weight + if roll < cumulative { + g.World.AddGroundItem(p.RoomID, e.ItemID, e.Quantity) + def, _ := g.ItemStore.Load(e.ItemID) + name := e.ItemID + if def != nil { + name = def.Name + } + if e.Quantity > 1 { + sess.WriteLine(fmt.Sprintf(" The %s drops: %d x %s", mob.Name, e.Quantity, name)) + } else { + sess.WriteLine(fmt.Sprintf(" The %s drops: %s", mob.Name, name)) + } + break + } + } + } + + // Schedule respawn + instanceID := g.findMobInstanceID(mob) + respawnTicks := mob.RespawnTicks + if respawnTicks <= 0 { + respawnTicks = 30 + } + g.Ticks.Subscribe(respawnTicks, func() bool { + g.respawnMob(instanceID) + return false + }) + } +} + +func (g *Game) awardCombatXP(p *player.Player, dmg int) { + baseXP := dmg * 4 + + switch p.AttackStyle { + case player.Accurate: + p.AddXP(player.Attack, baseXP*3/4) + p.AddXP(player.Hitpoints, baseXP/4) + case player.Aggressive: + p.AddXP(player.Strength, baseXP*3/4) + p.AddXP(player.Hitpoints, baseXP/4) + case player.Defensive: + p.AddXP(player.Defense, baseXP*3/4) + p.AddXP(player.Hitpoints, baseXP/4) + case player.Balanced: + quarter := baseXP / 4 + p.AddXP(player.Attack, quarter) + p.AddXP(player.Strength, quarter) + p.AddXP(player.Defense, quarter) + p.AddXP(player.Hitpoints, quarter) + } + + g.AccountStore.SaveCharacter(p) +} + +type deathDrop struct { + itemID string + quantity int + totalVal int + isEquip bool + equipSlot object.EquipSlot + invSlot int +} + +func (g *Game) dropItemsOnDeath(p *player.Player) { + roomID := p.RoomID + + // Credits always drop on death + if p.Credits > 0 { + g.World.AddGroundItem(roomID, "credits", p.Credits) + p.Credits = 0 + } + + var items []deathDrop + + // Inventory + for slot, inv := range p.Inventory { + if inv == nil || inv.Quantity <= 0 { + continue + } + val := 0 + if def, err := g.ItemStore.Load(inv.ItemID); err == nil { + val = def.Value * inv.Quantity + } + items = append(items, deathDrop{ + itemID: inv.ItemID, + quantity: inv.Quantity, + totalVal: val, + invSlot: slot, + }) + } + + // Equipment + for eqSlot, itemID := range p.Equipment { + val := 0 + if def, err := g.ItemStore.Load(itemID); err == nil { + val = def.Value + } + items = append(items, deathDrop{ + itemID: itemID, + quantity: 1, + totalVal: val, + isEquip: true, + equipSlot: eqSlot, + }) + } + + if len(items) <= 3 { + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].totalVal > items[j].totalVal + }) + + for i := 3; i < len(items); i++ { + it := items[i] + if it.isEquip { + delete(p.Equipment, it.equipSlot) + } else { + p.SetInvSlot(it.invSlot, nil) + } + g.World.AddGroundItem(roomID, it.itemID, it.quantity) + } +} + +func (g *Game) stopCombat(playerName string) { + cs := combat.GetCombat(playerName) + if cs == nil { + return + } + combat.LeaveCombat(playerName) +} + +func (g *Game) respawnMob(instanceID string) { + inst := g.MobStore.GetInstance(instanceID) + if inst == nil { + return + } + inst.HP = inst.MaxHP +} + +func (g *Game) findMobInstanceID(mob *world.MobInstance) string { + return mob.InstanceID +} + +func (g *Game) playerWeaponSpeed(p *player.Player) int { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.Speed > 0 { + return def.Speed + } + } + return 5 // unarmed speed +} + +func mobCombatLevel(m *world.MobInstance) int { + base := 0.25 * float64(m.Defense+m.MaxHP+m.Defense) + base += 0.25 * float64(m.Attack+m.Strength) + return int(base) +} + +func randInt(max int) int { + if max <= 0 { + return 0 + } + return combat.RollDamage(max) - 1 +} diff --git a/internal/game/help.go b/internal/game/help.go new file mode 100644 index 0000000..1e93dcf --- /dev/null +++ b/internal/game/help.go @@ -0,0 +1,88 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "thirdcollapse/internal/net" + "gopkg.in/yaml.v3" +) + +type HelpDef struct { + Name string `yaml:"name"` + Category string `yaml:"category"` + Content string `yaml:"description"` +} + +func LoadHelp(dataDir string) ([]HelpDef, error) { + dir := filepath.Join(dataDir, "help") + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var helps []HelpDef + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + var h HelpDef + if err := yaml.Unmarshal(data, &h); err != nil { + continue + } + helps = append(helps, h) + } + return helps, nil +} + +func (g *Game) doHelp(sess *net.Session, topic string) { + helps, err := LoadHelp(g.dataDir) + if err != nil || len(helps) == 0 { + sess.WriteLine("No help available.") + return + } + + if topic == "" { + // Group by category + cats := make(map[string][]HelpDef) + var catOrder []string + for _, h := range helps { + if _, ok := cats[h.Category]; !ok { + catOrder = append(catOrder, h.Category) + } + cats[h.Category] = append(cats[h.Category], h) + } + + sess.WriteLine("\nCommands:") + for _, cat := range catOrder { + sess.WriteLine(fmt.Sprintf("\n %s:", cat)) + for _, h := range cats[cat] { + sess.WriteLine(fmt.Sprintf(" %-12s - %s", h.Name, firstLine(h.Content))) + } + } + sess.WriteLine("\n Use 'help <command>' for details.") + return + } + + topic = strings.ToLower(topic) + for _, h := range helps { + if strings.ToLower(h.Name) == topic { + sess.WriteLine(fmt.Sprintf("\n %s", h.Content)) + return + } + } + sess.WriteLine(fmt.Sprintf("No help found for '%s'.", topic)) +} + +func firstLine(s string) string { + if idx := strings.Index(s, "\n"); idx >= 0 { + return s[:idx] + } + return s +} |
