aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/action_gather.go12
-rw-r--r--internal/game/action_talk.go4
-rw-r--r--internal/game/action_use.go3
-rw-r--r--internal/game/cmd_drop.go2
-rw-r--r--internal/game/cmd_get.go22
-rw-r--r--internal/game/cmd_look.go6
-rw-r--r--internal/game/cmd_map.go103
-rw-r--r--internal/game/cmd_move.go8
-rw-r--r--internal/game/cmd_wear.go2
-rw-r--r--internal/game/doc.go29
-rw-r--r--internal/game/game.go2
-rw-r--r--internal/game/login_account.go (renamed from internal/game/login.go)226
-rw-r--r--internal/game/login_char.go236
-rw-r--r--internal/game/map.go102
-rw-r--r--internal/game/types.go29
-rw-r--r--internal/game/utils.go35
16 files changed, 427 insertions, 394 deletions
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 7e53e87..201a619 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -32,7 +32,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
msg += fmt.Sprintf(" (%d ticks to respawn)", st.DepleteTimer)
}
sess.WriteLine(msg)
- } else if cfg.SharedDeplete > 0 {
+ } else if cfg.DepleteTimer > 0 {
sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer))
} else {
sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer))
@@ -114,8 +114,8 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
"effective_wait": wait,
"step": 0,
}
- if cfg.SharedDeplete > 0 {
- data["shared_deplete"] = true
+ if cfg.DepleteTimer > 0 {
+ data["deplete_timer"] = true
}
p.Action = &action.Action{
Type: "gather",
@@ -137,7 +137,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
step := p.Action.Data["step"].(int)
wait := p.Action.Data["effective_wait"].(int)
instanceKey := p.Action.Data["instance_key"].(string)
- _, shared := p.Action.Data["shared_deplete"]
+ _, shared := p.Action.Data["deplete_timer"]
if step == 0 {
if cfg.Bait != "" {
@@ -240,7 +240,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
if !shared && drop.Depletes {
- delay := cfg.DepleteDelay
+ delay := cfg.RespawnTimer
if delay <= 0 {
delay = 10
}
@@ -305,7 +305,7 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry
func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, cfg *action.GatherConfig, instanceKey string) {
st.Depleted = true
- st.DepleteTimer = cfg.DepleteDelay
+ st.DepleteTimer = cfg.RespawnTimer
st.SharedTimer = 0
msg := cfg.ExhaustedMessage
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
index f4929e9..4980c5a 100644
--- a/internal/game/action_talk.go
+++ b/internal/game/action_talk.go
@@ -111,7 +111,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
return
}
- idx, err := parseIndex(input)
+ idx, err := parseChoiceIndex(input)
if err != nil || idx <= 0 {
g.CancelAction(p)
sess.State = net.StateGame
@@ -193,7 +193,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) {
g.AccountStore.SaveCharacter(p)
g.World.SeedGroundItems(p.RoomID)
g.seedRoomMobs(p.RoomID)
- g.ensureRoomObjects(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
if g.Hub != nil {
g.Hub.EnterRoom(sess, p.RoomID)
}
diff --git a/internal/game/action_use.go b/internal/game/action_use.go
index 497ceac..c47492f 100644
--- a/internal/game/action_use.go
+++ b/internal/game/action_use.go
@@ -17,7 +17,7 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD
return
}
- for itemID, qty := range cfg.Consume {
+ for itemID := range cfg.Consume {
if !p.HasItem(itemID) {
defName := itemID
if def, err := g.ItemStore.Load(itemID); err == nil {
@@ -26,7 +26,6 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD
sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName))
return
}
- _ = qty
}
if cfg.Success == nil && p.FirstFreeSlot() == -1 {
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index cbe232d..149b86b 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -222,7 +222,7 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) {
sess.WriteLine("\nYou have nothing to drop.")
} else {
sess.Write(fmt.Sprintf("\nYou drop your "))
- innerPickupReport(sess, dropped)
+ formatPickupList(sess, dropped)
}
sess.Write("\r\n> ")
}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index f4c7426..78a9621 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -73,12 +73,10 @@ func (g *Game) doGet(sess *net.Session, input string) {
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 {
+ if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok {
sess.WriteLine("That's not yours!")
return
}
- _ = removed
slot.Quantity += qty
g.AccountStore.SaveCharacter(p)
sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, def.Name, slot.Quantity))
@@ -90,13 +88,11 @@ func (g *Game) doGet(sess *net.Session, input string) {
sess.WriteLine("Your inventory is full.")
return
}
- removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name)
- if !ok {
+ if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok {
sess.WriteLine("That's not yours!")
return
}
- _ = removed
- p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty))
+ p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty))
g.AccountStore.SaveCharacter(p)
if qty == 1 {
sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name))
@@ -112,11 +108,9 @@ func (g *Game) doGet(sess *net.Session, input string) {
if fs == -1 {
break
}
- removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name)
- if !ok {
+ if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name); !ok {
break
}
- _ = removed
p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1})
picked++
}
@@ -318,7 +312,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if freeSlot == -1 {
if len(picked) > 0 {
sess.WriteLine("You can't hold everything but you picked up:")
- innerPickupReport(sess, picked)
+ formatPickupList(sess, picked)
} else {
sess.WriteLine("Your inventory is full!")
}
@@ -329,7 +323,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if !ok {
break
}
- p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty))
+ p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty))
name := itemID
if def != nil {
name = def.Name
@@ -342,7 +336,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if freeSlot == -1 {
if len(picked) > 0 {
sess.WriteLine("You can't hold everything but you picked up:")
- innerPickupReport(sess, picked)
+ formatPickupList(sess, picked)
} else {
sess.WriteLine("Your inventory is full!")
}
@@ -371,7 +365,7 @@ func (g *Game) doGetAll(sess *net.Session) {
sess.WriteLine("Your inventory is full.")
} else {
sess.Write("You picked up: ")
- innerPickupReport(sess, picked)
+ formatPickupList(sess, picked)
}
}
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index e0e98fe..a840579 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -141,7 +141,7 @@ func (g *Game) doLook(sess *net.Session) {
}
var suffix string
if multi && (len(timed) > 0 || len(depleted) > 0) {
- suffix = fmt.Sprintf(" [%s]", intsJoin(freshIdxs))
+ suffix = fmt.Sprintf(" [%s]", joinInts(freshIdxs))
}
qualityTimer := ""
if showTimers && len(instances) > 0 && instances[0].quality > 0 {
@@ -302,7 +302,7 @@ func (g *Game) doLook(sess *net.Session) {
}
line += fmt.Sprintf(" (fighting %s)", name)
}
- } else if desc := actionDesc(op); desc != "" {
+ } else if desc := playerActionDescription(op); desc != "" {
line += ", " + desc
}
sess.WriteLine(line + ".")
@@ -512,7 +512,7 @@ func (g *Game) doExits(sess *net.Session) {
}
}
-func intsJoin(nums []int) string {
+func joinInts(nums []int) string {
var parts []string
for _, n := range nums {
parts = append(parts, strconv.Itoa(n))
diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go
index 7c506f9..e0413d3 100644
--- a/internal/game/cmd_map.go
+++ b/internal/game/cmd_map.go
@@ -1,11 +1,8 @@
package game
import (
- "strings"
-
"thirdcollapse/internal/net"
"thirdcollapse/internal/player"
- "thirdcollapse/internal/world"
)
func (g *Game) doMap(sess *net.Session) {
@@ -38,103 +35,3 @@ func (g *Game) doMap(sess *net.Session) {
sess.WriteLine(line)
}
}
-
-func stripBlankRows(lines []string) []string {
- var out []string
- for _, line := range lines {
- if strings.TrimSpace(line) != "" {
- out = append(out, line)
- }
- }
- return out
-}
-
-func leftTrimCommon(lines []string) []string {
- min := -1
- for _, line := range lines {
- if strings.TrimSpace(line) == "" {
- continue
- }
- n := 0
- for _, r := range line {
- if r == ' ' {
- n++
- } else {
- break
- }
- }
- if min < 0 || n < min {
- min = n
- }
- }
- if min <= 0 {
- return lines
- }
- result := make([]string, len(lines))
- for i, line := range lines {
- if len(line) <= min {
- result[i] = ""
- } else {
- result[i] = line[min:]
- }
- }
- return result
-}
-
-func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string {
- mg := buildGraph(g, roomID)
-
- grid := make([][]rune, mapHeight)
- for i := range grid {
- grid[i] = make([]rune, mapWidth)
- for j := range grid[i] {
- grid[i][j] = ' '
- }
- }
-
- cx := mapWidth / 2
- cy := mapHeight / 2
-
- for pos, rid := range mg.posToRoom {
- gr := cy + pos[1]*2
- gc := cx + pos[0]*2
- if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth {
- continue
- }
- if rid == roomID {
- grid[gr][gc] = '@'
- } else {
- grid[gr][gc] = roomMapSymbol(g, rid)
- }
- }
-
- for pos, rid := range mg.posToRoom {
- x, y := pos[0], pos[1]
-
- if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok {
- if exitsConnect(g, rid, rightID, world.East, world.West) {
- gr := cy + y*2
- gc := cx + x*2 + 1
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- grid[gr][gc] = '─'
- }
- }
- }
-
- if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok {
- if exitsConnect(g, rid, bottomID, world.South, world.North) {
- gr := cy + y*2 + 1
- gc := cx + x*2
- if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
- grid[gr][gc] = '│'
- }
- }
- }
- }
-
- lines := make([]string, mapHeight)
- for i := range grid {
- lines[i] = string(grid[i])
- }
- return lines
-}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index d62701d..9ef7a00 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -62,7 +62,7 @@ func (g *Game) doMove(sess *net.Session, dir string) {
g.World.SeedGroundItems(p.RoomID)
g.seedRoomMobs(p.RoomID)
- g.ensureRoomObjects(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(oldRoom) {
@@ -103,7 +103,7 @@ func (g *Game) seedRoomMobs(roomID int) {
g.MobStore.SeedMobs(roomID, room.Mobs)
}
-func (g *Game) ensureRoomObjects(roomID int) {
+func (g *Game) seedRoomObjects(roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
return
@@ -124,8 +124,8 @@ func (g *Game) ensureRoomObjects(roomID int) {
}
if def.BehaviorID != "" {
cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID)
- if err == nil && cfg.SharedDeplete > 0 {
- g.World.SetObjSharedDeplete(roomID, obj.ID, cfg.SharedDeplete)
+ if err == nil && cfg.DepleteTimer > 0 {
+ g.World.SetObjDepleteTimer(roomID, obj.ID, cfg.DepleteTimer)
}
}
}
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index 4554dfc..7c3515f 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -155,6 +155,6 @@ func (g *Game) doWearAll(sess *net.Session) {
} else {
g.AccountStore.SaveCharacter(p)
sess.Write("You wear: ")
- innerPickupReport(sess, equipped)
+ formatPickupList(sess, equipped)
}
}
diff --git a/internal/game/doc.go b/internal/game/doc.go
new file mode 100644
index 0000000..97814e9
--- /dev/null
+++ b/internal/game/doc.go
@@ -0,0 +1,29 @@
+// Package game is the core session handler, command dispatch, login flow,
+// and action system for the MUD.
+//
+// The Game struct holds all world state: room/world access, item/object
+// stores, mob instances, behavior definitions, the player hub, and the
+// tick engine. HandleSession routes incoming input through the login
+// state machine into the in-game command dispatch.
+//
+// Commands are dispatched via handleGameCommand in game.go, which
+// resolves account aliases before switching on the command word.
+// Most commands cancel the player's ongoing action (gathering, etc.).
+//
+// Action system: StartAction normalizes verbs, resolves object/mob
+// targets, loads behaviors, and routes to type-specific handlers.
+// Actions that take time (gather, use, burn, stoke) tick via
+// AdvanceActions, called each 600ms tick.
+//
+// Tick handlers: DisconnectTick, RegenTick, WanderTick, WoodcuttingTick,
+// and FireTick run each game tick to advance world simulation.
+//
+// File organization:
+// cmd_*.go — player command handlers (doLook, doMove, doGet, etc.)
+// action_*.go — action lifecycle (startGather, advanceBurn, talk, toggle, etc.)
+// login.go — account creation, authentication, character management
+// map.go — BFS graph builder, tiny + full-map rendering
+// tick.go — per-tick world updates (disconnect, regen, wander, woodcutting)
+// utils.go — shared types and helper functions
+// help.go — help topic loading and display
+package game
diff --git a/internal/game/game.go b/internal/game/game.go
index 21dee76..cecde2a 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -59,7 +59,7 @@ func (g *Game) SetHub(hub *net.Hub) {
}
func (g *Game) HandleSession(sess *net.Session, input string) {
- input = player.StripControl(input)
+ input = player.StripControlCharacters(input)
switch sess.State {
case net.StateAccountName:
g.handleAccountName(sess, input)
diff --git a/internal/game/login.go b/internal/game/login_account.go
index 3de70bc..7243955 100644
--- a/internal/game/login.go
+++ b/internal/game/login_account.go
@@ -268,91 +268,6 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
}
}
-func (g *Game) handleNewCharName(sess *net.Session, input string) {
- name := strings.TrimSpace(input)
-
- 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("\n> ")
- return
- }
-
- if name == "" {
- sess.Write("Character name: ")
- return
- }
-
- if verr := player.ValidName(name); verr != nil {
- sess.WriteLine(verr.Error())
- sess.Write("Character name: ")
- return
- }
-
- if g.AccountStore.CharacterExists(name) {
- sess.WriteLine("A character with that name already exists.")
- sess.Write("Character name: ")
- return
- }
-
- p := player.New(name)
- p.RoomID = 1
-
- 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
- }
-
- 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) {
- g.charsMu.Lock()
- if existing := g.loggedInChars[name]; existing != nil {
- g.charsMu.Unlock()
- 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 {
- g.charsMu.Unlock()
- 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.charsMu.Unlock()
-
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.ensureRoomObjects(p.RoomID)
-
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
-
- g.doLook(sess)
- sess.Write("\r\n> ")
-}
-
func (g *Game) handleRenameAccount(sess *net.Session, input string) {
newName := strings.TrimSpace(input)
if newName == "" {
@@ -389,147 +304,6 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) {
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
- }
-
- 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 verr := player.ValidName(newName); verr != nil {
- sess.WriteLine(verr.Error())
- 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
- }
-
- 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
- }
-
- p, _ := g.AccountStore.LoadCharacter(newName)
- if p != nil {
- p.Name = newName
- g.AccountStore.SaveCharacter(p)
- }
-
- 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 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
- }
-
- 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
- }
-
- 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
- }
-
- 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
diff --git a/internal/game/login_char.go b/internal/game/login_char.go
new file mode 100644
index 0000000..ba034bb
--- /dev/null
+++ b/internal/game/login_char.go
@@ -0,0 +1,236 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) handleNewCharName(sess *net.Session, input string) {
+ name := strings.TrimSpace(input)
+
+ if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 {
+ if idx, err := parseChoiceIndex(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("\n> ")
+ return
+ }
+
+ if name == "" {
+ sess.Write("Character name: ")
+ return
+ }
+
+ if verr := player.ValidName(name); verr != nil {
+ sess.WriteLine(verr.Error())
+ sess.Write("Character name: ")
+ return
+ }
+
+ if g.AccountStore.CharacterExists(name) {
+ sess.WriteLine("A character with that name already exists.")
+ sess.Write("Character name: ")
+ return
+ }
+
+ p := player.New(name)
+ p.RoomID = 1
+
+ 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
+ }
+
+ 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) {
+ g.charsMu.Lock()
+ if existing := g.loggedInChars[name]; existing != nil {
+ g.charsMu.Unlock()
+ 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 {
+ g.charsMu.Unlock()
+ 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.charsMu.Unlock()
+
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
+
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+
+ g.doLook(sess)
+ sess.Write("\r\n> ")
+}
+
+func (g *Game) handleRenameChar(sess *net.Session, input string) {
+ name := strings.TrimSpace(input)
+ if idx, err := parseChoiceIndex(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))
+ 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 verr := player.ValidName(newName); verr != nil {
+ sess.WriteLine(verr.Error())
+ 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
+ }
+
+ 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
+ }
+
+ p, _ := g.AccountStore.LoadCharacter(newName)
+ if p != nil {
+ p.Name = newName
+ g.AccountStore.SaveCharacter(p)
+ }
+
+ 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 sess.PendingChar == "" {
+ name := strings.TrimSpace(input)
+ if idx, err := parseChoiceIndex(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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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)
+}
diff --git a/internal/game/map.go b/internal/game/map.go
index cd1012b..e7738bd 100644
--- a/internal/game/map.go
+++ b/internal/game/map.go
@@ -1,6 +1,8 @@
package game
import (
+ "strings"
+
"thirdcollapse/internal/world"
)
@@ -189,3 +191,103 @@ func roomMapSymbol(g *Game, roomID int) rune {
}
return 'o'
}
+
+func stripBlankRows(lines []string) []string {
+ var out []string
+ for _, line := range lines {
+ if strings.TrimSpace(line) != "" {
+ out = append(out, line)
+ }
+ }
+ return out
+}
+
+func leftTrimCommon(lines []string) []string {
+ min := -1
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ n := 0
+ for _, r := range line {
+ if r == ' ' {
+ n++
+ } else {
+ break
+ }
+ }
+ if min < 0 || n < min {
+ min = n
+ }
+ }
+ if min <= 0 {
+ return lines
+ }
+ result := make([]string, len(lines))
+ for i, line := range lines {
+ if len(line) <= min {
+ result[i] = ""
+ } else {
+ result[i] = line[min:]
+ }
+ }
+ return result
+}
+
+func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string {
+ mg := buildGraph(g, roomID)
+
+ grid := make([][]rune, mapHeight)
+ for i := range grid {
+ grid[i] = make([]rune, mapWidth)
+ for j := range grid[i] {
+ grid[i][j] = ' '
+ }
+ }
+
+ cx := mapWidth / 2
+ cy := mapHeight / 2
+
+ for pos, rid := range mg.posToRoom {
+ gr := cy + pos[1]*2
+ gc := cx + pos[0]*2
+ if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth {
+ continue
+ }
+ if rid == roomID {
+ grid[gr][gc] = '@'
+ } else {
+ grid[gr][gc] = roomMapSymbol(g, rid)
+ }
+ }
+
+ for pos, rid := range mg.posToRoom {
+ x, y := pos[0], pos[1]
+
+ if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok {
+ if exitsConnect(g, rid, rightID, world.East, world.West) {
+ gr := cy + y*2
+ gc := cx + x*2 + 1
+ if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
+ grid[gr][gc] = '─'
+ }
+ }
+ }
+
+ if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok {
+ if exitsConnect(g, rid, bottomID, world.South, world.North) {
+ gr := cy + y*2 + 1
+ gc := cx + x*2
+ if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth {
+ grid[gr][gc] = '│'
+ }
+ }
+ }
+ }
+
+ lines := make([]string, mapHeight)
+ for i := range grid {
+ lines[i] = string(grid[i])
+ }
+ return lines
+}
diff --git a/internal/game/types.go b/internal/game/types.go
new file mode 100644
index 0000000..faed9e1
--- /dev/null
+++ b/internal/game/types.go
@@ -0,0 +1,29 @@
+package game
+
+import "thirdcollapse/internal/object"
+
+var EquipSlots = []object.EquipSlot{
+ object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
+ object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
+ object.SlotMainHand, object.SlotOffHand, object.SlotRing,
+}
+
+type itemMatch struct {
+ ID string
+ Name string
+ Slot int
+}
+
+type xpGain struct {
+ Skill string
+ XP int
+}
+
+type deathDrop struct {
+ itemID string
+ quantity int
+ totalVal int
+ isEquip bool
+ equipSlot object.EquipSlot
+ invSlot int
+}
diff --git a/internal/game/utils.go b/internal/game/utils.go
index 969eba4..e80de24 100644
--- a/internal/game/utils.go
+++ b/internal/game/utils.go
@@ -7,37 +7,10 @@ import (
"strings"
"thirdcollapse/internal/net"
- "thirdcollapse/internal/object"
"thirdcollapse/internal/player"
"thirdcollapse/internal/world"
)
-var EquipSlots = []object.EquipSlot{
- object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs,
- object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo,
- object.SlotMainHand, object.SlotOffHand, object.SlotRing,
-}
-
-type itemMatch struct {
- ID string
- Name string
- Slot int
-}
-
-type xpGain struct {
- Skill string
- XP int
-}
-
-type deathDrop struct {
- itemID string
- quantity int
- totalVal int
- isEquip bool
- equipSlot object.EquipSlot
- invSlot int
-}
-
func plural(n int) string {
if n == 1 {
return ""
@@ -55,7 +28,7 @@ func parseQty(input string) (int, string) {
return 0, input
}
-func parseIndex(s string) (int, error) {
+func parseChoiceIndex(s string) (int, error) {
var idx int
_, err := fmt.Sscanf(s, "%d", &idx)
return idx, err
@@ -94,7 +67,7 @@ func randInt(max int) int {
return rand.Intn(max)
}
-func innerPickupReport(sess *net.Session, picked []string) {
+func formatPickupList(sess *net.Session, picked []string) {
for i, name := range picked {
if i == len(picked)-1 {
sess.WriteLine(name)
@@ -106,7 +79,7 @@ func innerPickupReport(sess *net.Session, picked []string) {
}
}
-func actionDesc(p *player.Player) string {
+func playerActionDescription(p *player.Player) string {
if p.Action == nil {
return ""
}
@@ -129,7 +102,7 @@ func actionDesc(p *player.Player) string {
return ""
}
-func (g *Game) newInvSlot(itemID string, qty int) *player.InventorySlot {
+func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot {
slot := &player.InventorySlot{ItemID: itemID, Quantity: qty}
if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 {
slot.Quality = def.Quality