aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cmd/registry.go48
-rw-r--r--internal/combat/formulas.go52
-rw-r--r--internal/combat/state.go89
-rw-r--r--internal/engine/tick.go105
-rw-r--r--internal/game/game.go1569
-rw-r--r--internal/game/help.go88
-rw-r--r--internal/net/server.go166
-rw-r--r--internal/object/item.go62
-rw-r--r--internal/object/item_store.go38
-rw-r--r--internal/object/object.go8
-rw-r--r--internal/object/store.go38
-rw-r--r--internal/player/account.go7
-rw-r--r--internal/player/password.go32
-rw-r--r--internal/player/player.go147
-rw-r--r--internal/player/store.go136
-rw-r--r--internal/player/xp.go41
-rw-r--r--internal/world/mob.go181
-rw-r--r--internal/world/room.go53
-rw-r--r--internal/world/world.go182
19 files changed, 3042 insertions, 0 deletions
diff --git a/internal/cmd/registry.go b/internal/cmd/registry.go
new file mode 100644
index 0000000..e056469
--- /dev/null
+++ b/internal/cmd/registry.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+ "fmt"
+ "strings"
+)
+
+type Handler func(args []string)
+
+type Command struct {
+ Name string
+ Aliases []string
+ Handler Handler
+ MinArgs int
+}
+
+type Registry struct {
+ cmds map[string]Command
+}
+
+func NewRegistry() *Registry {
+ return &Registry{cmds: make(map[string]Command)}
+}
+
+func (r *Registry) Register(cmd Command) {
+ r.cmds[cmd.Name] = cmd
+ for _, a := range cmd.Aliases {
+ r.cmds[a] = cmd
+ }
+}
+
+func (r *Registry) Dispatch(input string) error {
+ parts := strings.Fields(input)
+ if len(parts) == 0 {
+ return nil
+ }
+ name := strings.ToLower(parts[0])
+ cmd, ok := r.cmds[name]
+ if !ok {
+ return fmt.Errorf("unknown command: %s", name)
+ }
+ args := parts[1:]
+ if len(args) < cmd.MinArgs {
+ return fmt.Errorf("usage: %s requires at least %d arguments", cmd.Name, cmd.MinArgs)
+ }
+ cmd.Handler(args)
+ return nil
+}
diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go
new file mode 100644
index 0000000..0b3248b
--- /dev/null
+++ b/internal/combat/formulas.go
@@ -0,0 +1,52 @@
+package combat
+
+import "math/rand"
+
+func AttackRoll(attackLevel int, styleBonus int, equipBonus int) int {
+ return (attackLevel + styleBonus + 8) * (equipBonus + 64)
+}
+
+func DefenseRoll(defenseLevel int, styleBonus int, equipBonus int) int {
+ return (defenseLevel + styleBonus + 8) * (equipBonus + 64)
+}
+
+func HitCheck(attackRoll, defenseRoll int) bool {
+ if attackRoll > defenseRoll {
+ return true
+ }
+ if attackRoll < defenseRoll {
+ return false
+ }
+ return rand.Intn(2) == 1
+}
+
+func MaxHit(strengthLevel int, styleBonus int, equipBonus int) int {
+ effective := (strengthLevel + styleBonus + 8) * (equipBonus + 64)
+ hit := effective / 512
+ if hit < 1 {
+ hit = 1
+ }
+ return hit
+}
+
+func RollDamage(maxHit int) int {
+ if maxHit <= 0 {
+ return 0
+ }
+ return 1 + rand.Intn(maxHit)
+}
+
+func AttackStyleBonus(style string) (attack, strength, defense int) {
+ switch style {
+ case "accurate":
+ return 3, 0, 0
+ case "aggressive":
+ return 0, 3, 0
+ case "defensive":
+ return 0, 0, 3
+ case "balanced":
+ return 1, 1, 1
+ default:
+ return 0, 0, 0
+ }
+}
diff --git a/internal/combat/state.go b/internal/combat/state.go
new file mode 100644
index 0000000..b0e7dfe
--- /dev/null
+++ b/internal/combat/state.go
@@ -0,0 +1,89 @@
+package combat
+
+import "sync"
+
+type State struct {
+ PlayerName string
+ MobID string
+ MobAttacks int // attacks mob has made (3-hit flee rule)
+ PlayerDamage int // total damage player dealt this combat
+ Active bool
+}
+
+var (
+ mu sync.Mutex
+ combatants = make(map[string]*State) // player name -> state
+ mobTargets = make(map[string]string) // mob ID -> player name
+)
+
+func EnterCombat(playerName, mobID string) {
+ mu.Lock()
+ defer mu.Unlock()
+ combatants[playerName] = &State{
+ PlayerName: playerName,
+ MobID: mobID,
+ Active: true,
+ }
+ mobTargets[mobID] = playerName
+}
+
+func LeaveCombat(playerName string) {
+ mu.Lock()
+ defer mu.Unlock()
+ state, ok := combatants[playerName]
+ if !ok {
+ return
+ }
+ delete(mobTargets, state.MobID)
+ delete(combatants, playerName)
+}
+
+func GetCombat(playerName string) *State {
+ mu.Lock()
+ defer mu.Unlock()
+ return combatants[playerName]
+}
+
+func IsMobInCombat(mobID string) bool {
+ mu.Lock()
+ defer mu.Unlock()
+ _, ok := mobTargets[mobID]
+ return ok
+}
+
+func RecordMobAttack(playerName string) {
+ mu.Lock()
+ defer mu.Unlock()
+ if state, ok := combatants[playerName]; ok {
+ state.MobAttacks++
+ }
+}
+
+func CanFlee(playerName string) bool {
+ mu.Lock()
+ defer mu.Unlock()
+ state, ok := combatants[playerName]
+ if !ok {
+ return true
+ }
+ return state.MobAttacks >= 3
+}
+
+func RecordPlayerDamage(playerName string, dmg int) {
+ mu.Lock()
+ defer mu.Unlock()
+ if state, ok := combatants[playerName]; ok {
+ state.PlayerDamage += dmg
+ }
+}
+
+func GetTotalDamage(playerName string) int {
+ mu.Lock()
+ defer mu.Unlock()
+ if state, ok := combatants[playerName]; ok {
+ d := state.PlayerDamage
+ state.PlayerDamage = 0
+ return d
+ }
+ return 0
+}
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
new file mode 100644
index 0000000..d6d470a
--- /dev/null
+++ b/internal/engine/tick.go
@@ -0,0 +1,105 @@
+package engine
+
+import (
+ "sync"
+ "time"
+)
+
+const TickDuration = 600 * time.Millisecond
+
+type Callback func() bool
+
+type subscriber struct {
+ id uint64
+ interval int
+ callback Callback
+ ticks int
+}
+
+type Engine struct {
+ mu sync.Mutex
+ subscribers map[uint64]*subscriber
+ nextID uint64
+ ticker *time.Ticker
+ running bool
+ stopCh chan struct{}
+}
+
+func New() *Engine {
+ return &Engine{
+ subscribers: make(map[uint64]*subscriber),
+ }
+}
+
+func (e *Engine) Subscribe(interval int, cb Callback) uint64 {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.nextID++
+ e.subscribers[e.nextID] = &subscriber{
+ id: e.nextID,
+ interval: interval,
+ callback: cb,
+ }
+ return e.nextID
+}
+
+func (e *Engine) Unsubscribe(id uint64) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ delete(e.subscribers, id)
+}
+
+func (e *Engine) Start() {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.running {
+ return
+ }
+ e.running = true
+ e.ticker = time.NewTicker(TickDuration)
+ e.stopCh = make(chan struct{})
+
+ go func() {
+ for {
+ select {
+ case <-e.ticker.C:
+ e.processTick()
+ case <-e.stopCh:
+ return
+ }
+ }
+ }()
+}
+
+func (e *Engine) Stop() {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.ticker != nil {
+ e.ticker.Stop()
+ }
+ e.running = false
+ if e.stopCh != nil {
+ close(e.stopCh)
+ }
+}
+
+func (e *Engine) processTick() {
+ e.mu.Lock()
+ snapshot := make(map[uint64]*subscriber, len(e.subscribers))
+ for id, sub := range e.subscribers {
+ snapshot[id] = sub
+ }
+ e.mu.Unlock()
+
+ for id, sub := range snapshot {
+ sub.ticks++
+ if sub.ticks >= sub.interval {
+ sub.ticks = 0
+ if !sub.callback() {
+ e.mu.Lock()
+ delete(e.subscribers, id)
+ e.mu.Unlock()
+ }
+ }
+ }
+}
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
+}
diff --git a/internal/net/server.go b/internal/net/server.go
new file mode 100644
index 0000000..2a10a9a
--- /dev/null
+++ b/internal/net/server.go
@@ -0,0 +1,166 @@
+package net
+
+import (
+ "bufio"
+ "fmt"
+ "log"
+ "net"
+ "strings"
+)
+
+type SessionState int
+
+const (
+ StateAccountName SessionState = iota
+ StatePassword
+ StateNewAccountPass
+ StateNewAccountConfirm
+ StateMenu
+ StateNewCharName
+ StateNewCharConfirm
+ StateRenameAccount
+ StateRenameChar
+ StateRenameCharName
+ StateDeleteChar
+ StatePurgeAccount
+ StateGame
+)
+
+type Session struct {
+ Conn net.Conn
+ Reader *bufio.Reader
+ State SessionState
+ Account *AccountEntry
+ Player interface{} // *player.Player once character is selected
+ PendingChar string // char being renamed/deleted
+ PendingPass string // first password during signup
+}
+
+type AccountEntry struct {
+ Name string
+ PasswordHash string
+ Characters []string
+}
+
+type Server struct {
+ listener net.Listener
+ hub *Hub
+}
+
+type Hub struct {
+ sessions map[*Session]bool
+ // roomID -> sessions
+ rooms map[int]map[*Session]bool
+}
+
+func NewHub() *Hub {
+ return &Hub{
+ sessions: make(map[*Session]bool),
+ rooms: make(map[int]map[*Session]bool),
+ }
+}
+
+func (h *Hub) Add(s *Session) {
+ h.sessions[s] = true
+}
+
+func (h *Hub) Remove(s *Session) {
+ delete(h.sessions, s)
+ for _, room := range h.rooms {
+ delete(room, s)
+ }
+}
+
+func (h *Hub) EnterRoom(s *Session, roomID int) {
+ h.LeaveRoom(s)
+ if h.rooms[roomID] == nil {
+ h.rooms[roomID] = make(map[*Session]bool)
+ }
+ h.rooms[roomID][s] = true
+}
+
+func (h *Hub) LeaveRoom(s *Session) {
+ for _, room := range h.rooms {
+ delete(room, s)
+ }
+}
+
+func (h *Hub) PlayersInRoom(roomID int) []*Session {
+ var out []*Session
+ if room, ok := h.rooms[roomID]; ok {
+ for s := range room {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func NewServer(addr string) (*Server, error) {
+ l, err := net.Listen("tcp", addr)
+ if err != nil {
+ return nil, err
+ }
+ return &Server{listener: l, hub: NewHub()}, nil
+}
+
+func (s *Server) Hub() *Hub {
+ return s.hub
+}
+
+func (s *Server) ListenAndServe(handler func(*Session, string)) error {
+ for {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ return err
+ }
+ session := &Session{
+ Conn: conn,
+ Reader: bufio.NewReader(conn),
+ State: StateAccountName,
+ }
+ s.hub.Add(session)
+ go s.handleSession(session, handler)
+ }
+}
+
+func (s *Server) handleSession(session *Session, handler func(*Session, string)) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("session panic: %v", r)
+ }
+ s.hub.Remove(session)
+ session.Conn.Close()
+ }()
+
+ _, _ = session.Conn.Write([]byte("\033[2J\033[H")) // clear screen
+ session.Write("Welcome to Third Collapse!\r\n")
+ session.Write("Account name: ")
+
+ for {
+ line, err := session.Reader.ReadString('\n')
+ if err != nil {
+ log.Printf("session read error: %v", err)
+ return
+ }
+ line = strings.TrimSpace(line)
+ handler(session, line)
+ }
+}
+
+func (sess *Session) Write(msg string) {
+ _, _ = sess.Conn.Write([]byte(msg))
+}
+
+func (sess *Session) WriteLine(msg string) {
+ _, _ = sess.Conn.Write([]byte(msg + "\r\n"))
+}
+
+func (sess *Session) WriteLines(lines ...string) {
+ for _, l := range lines {
+ sess.WriteLine(l)
+ }
+}
+
+func (sess *Session) Writef(format string, args ...interface{}) {
+ sess.Write(fmt.Sprintf(format, args...))
+}
diff --git a/internal/object/item.go b/internal/object/item.go
new file mode 100644
index 0000000..67a2998
--- /dev/null
+++ b/internal/object/item.go
@@ -0,0 +1,62 @@
+package object
+
+import "strings"
+
+type EquipSlot string
+
+const (
+ SlotHead EquipSlot = "head"
+ SlotNeck EquipSlot = "neck"
+ SlotTorso EquipSlot = "torso"
+ SlotLegs EquipSlot = "legs"
+ SlotHands EquipSlot = "hands"
+ SlotFeet EquipSlot = "feet"
+ SlotBack EquipSlot = "back"
+ SlotAmmo EquipSlot = "ammo"
+ SlotMainHand EquipSlot = "main_hand"
+ SlotOffHand EquipSlot = "off_hand"
+ SlotRing EquipSlot = "ring"
+)
+
+type WeaponType string
+
+const (
+ WeaponMelee WeaponType = "melee"
+ WeaponRanged WeaponType = "ranged"
+ WeaponTechnology WeaponType = "technology"
+)
+
+type ItemDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases"`
+ Description string `yaml:"description"`
+ Value int `yaml:"value"` // credits
+ Stackable bool `yaml:"stackable"`
+ EquipSlot EquipSlot `yaml:"equip_slot"`
+ WeaponType WeaponType `yaml:"weapon_type"`
+ Stats ItemStats `yaml:"stats"`
+ Speed int `yaml:"speed"` // ticks between attacks
+ Toolbelt bool `yaml:"toolbelt"` // can go on toolbelt
+}
+
+type ItemStats struct {
+ AttackBonus int `yaml:"attack_bonus"`
+ StrengthBonus int `yaml:"strength_bonus"`
+ DefenseBonus int `yaml:"defense_bonus"`
+ ScienceBonus int `yaml:"science_bonus"`
+ TechnologyBonus int `yaml:"technology_bonus"`
+}
+
+func (d *ItemDef) MatchesName(input string) bool {
+ lower := strings.ToLower(input)
+ if strings.ToLower(d.Name) == lower {
+ return true
+ }
+ for _, a := range d.Aliases {
+ if strings.ToLower(a) == lower {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/object/item_store.go b/internal/object/item_store.go
new file mode 100644
index 0000000..6d62143
--- /dev/null
+++ b/internal/object/item_store.go
@@ -0,0 +1,38 @@
+package object
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+type ItemStore struct {
+ dataDir string
+ cache map[string]*ItemDef
+}
+
+func NewItemStore(dataDir string) *ItemStore {
+ return &ItemStore{
+ dataDir: dataDir,
+ cache: make(map[string]*ItemDef),
+ }
+}
+
+func (s *ItemStore) Load(id string) (*ItemDef, error) {
+ if def, ok := s.cache[id]; ok {
+ return def, nil
+ }
+ path := filepath.Join(s.dataDir, "items", id+".yaml")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read item %s: %w", id, err)
+ }
+ var def ItemDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse item %s: %w", id, err)
+ }
+ s.cache[id] = &def
+ return &def, nil
+}
diff --git a/internal/object/object.go b/internal/object/object.go
new file mode 100644
index 0000000..3b8741a
--- /dev/null
+++ b/internal/object/object.go
@@ -0,0 +1,8 @@
+package object
+
+type ObjectDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Behavior string `yaml:"behavior"`
+ Props map[string]any `yaml:"props"`
+}
diff --git a/internal/object/store.go b/internal/object/store.go
new file mode 100644
index 0000000..a884601
--- /dev/null
+++ b/internal/object/store.go
@@ -0,0 +1,38 @@
+package object
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+type ObjectStore struct {
+ dataDir string
+ cache map[string]*ObjectDef
+}
+
+func NewObjectStore(dataDir string) *ObjectStore {
+ return &ObjectStore{
+ dataDir: dataDir,
+ cache: make(map[string]*ObjectDef),
+ }
+}
+
+func (s *ObjectStore) Load(id string) (*ObjectDef, error) {
+ if def, ok := s.cache[id]; ok {
+ return def, nil
+ }
+ path := filepath.Join(s.dataDir, "objects", fmt.Sprintf("%s.yaml", id))
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read object %s: %w", id, err)
+ }
+ var def ObjectDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse object %s: %w", id, err)
+ }
+ s.cache[id] = &def
+ return &def, nil
+}
diff --git a/internal/player/account.go b/internal/player/account.go
new file mode 100644
index 0000000..5072009
--- /dev/null
+++ b/internal/player/account.go
@@ -0,0 +1,7 @@
+package player
+
+type Account struct {
+ Name string `yaml:"name"`
+ PasswordHash string `yaml:"password_hash"`
+ Characters []string `yaml:"characters"`
+}
diff --git a/internal/player/password.go b/internal/player/password.go
new file mode 100644
index 0000000..17e4218
--- /dev/null
+++ b/internal/player/password.go
@@ -0,0 +1,32 @@
+package player
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "strings"
+)
+
+func HashPassword(password string) (string, error) {
+ salt := make([]byte, 16)
+ if _, err := rand.Read(salt); err != nil {
+ return "", err
+ }
+ hash := sha256.Sum256(append(salt, []byte(password)...))
+ return fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), hash), nil
+}
+
+func CheckPassword(password, stored string) bool {
+ parts := strings.SplitN(stored, ":", 3)
+ if len(parts) != 3 || parts[0] != "sha256" {
+ return false
+ }
+ salt, err := base64.RawStdEncoding.DecodeString(parts[1])
+ if err != nil {
+ return false
+ }
+ hash := sha256.Sum256(append(salt, []byte(password)...))
+ expected := fmt.Sprintf("%x", hash)
+ return parts[2] == expected
+}
diff --git a/internal/player/player.go b/internal/player/player.go
new file mode 100644
index 0000000..33689c5
--- /dev/null
+++ b/internal/player/player.go
@@ -0,0 +1,147 @@
+package player
+
+import "thirdcollapse/internal/object"
+
+type SkillName string
+
+const (
+ Attack SkillName = "attack"
+ Strength SkillName = "strength"
+ Defense SkillName = "defense"
+ Hitpoints SkillName = "hitpoints"
+ Ranged SkillName = "ranged"
+ Science SkillName = "science"
+ Technology SkillName = "technology"
+ Fishing SkillName = "fishing"
+ Cooking SkillName = "cooking"
+ Woodcutting SkillName = "woodcutting"
+ Mining SkillName = "mining"
+ Smithing SkillName = "smithing"
+ Crafting SkillName = "crafting"
+ Fletching SkillName = "fletching"
+ Alchemy SkillName = "alchemy"
+ Thieving SkillName = "thieving"
+ Agility SkillName = "agility"
+ Construction SkillName = "construction"
+ Scavenging SkillName = "scavenging"
+ Hunter SkillName = "hunter"
+)
+
+var AllSkills = []SkillName{
+ Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
+ Fishing, Cooking, Woodcutting, Mining, Smithing, Crafting, Fletching,
+ Alchemy, Thieving, Agility, Construction, Scavenging, Hunter,
+}
+
+type AttackStyle string
+
+const (
+ Accurate AttackStyle = "accurate"
+ Aggressive AttackStyle = "aggressive"
+ Defensive AttackStyle = "defensive"
+ Balanced AttackStyle = "balanced"
+)
+
+type Skill struct {
+ Name SkillName
+ XP int
+}
+
+func (s Skill) Level() int {
+ return LevelForXP(s.XP)
+}
+
+type InventorySlot struct {
+ ItemID string `yaml:"item_id"`
+ Quantity int `yaml:"quantity"`
+}
+
+type Player struct {
+ Name string `yaml:"name"`
+ Skills map[SkillName]int `yaml:"skills"` // xp
+ Inventory map[int]*InventorySlot `yaml:"inventory"` // slot 0-27 -> item
+ Equipment map[object.EquipSlot]string `yaml:"equipment"` // slot -> item_id
+ Toolbelt []string `yaml:"toolbelt"` // item_ids
+ RoomID int `yaml:"room_id"`
+ HP int `yaml:"hp"`
+ Credits int `yaml:"credits"`
+ AttackStyle AttackStyle `yaml:"attack_style"`
+}
+
+func (p *Player) InvSlot(i int) *InventorySlot {
+ if p.Inventory == nil {
+ return nil
+ }
+ return p.Inventory[i]
+}
+
+func (p *Player) SetInvSlot(i int, slot *InventorySlot) {
+ if p.Inventory == nil {
+ p.Inventory = make(map[int]*InventorySlot)
+ }
+ if slot == nil {
+ delete(p.Inventory, i)
+ } else {
+ p.Inventory[i] = slot
+ }
+}
+
+func (p *Player) FirstFreeSlot() int {
+ for i := 0; i < 28; i++ {
+ if p.Inventory[i] == nil {
+ return i
+ }
+ }
+ return -1
+}
+
+func (p *Player) FreeSlots() int {
+ used := len(p.Inventory)
+ return 28 - used
+}
+
+func New(name string) *Player {
+ p := &Player{
+ Name: name,
+ Skills: make(map[SkillName]int),
+ Equipment: make(map[object.EquipSlot]string),
+ AttackStyle: Accurate,
+ RoomID: 0,
+ }
+ for _, s := range AllSkills {
+ p.Skills[s] = 0
+ }
+ p.Skills[Hitpoints] = XPForLevel(10)
+ p.HP = p.MaxHP()
+ return p
+}
+
+func (p *Player) Level(s SkillName) int {
+ return LevelForXP(p.Skills[s])
+}
+
+func (p *Player) AddXP(s SkillName, amount int) {
+ if p.Skills == nil {
+ p.Skills = make(map[SkillName]int)
+ }
+ p.Skills[s] += amount
+}
+
+func (p *Player) CombatLevel() int {
+ base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Science))
+ att := float64(p.Level(Attack))
+ str := float64(p.Level(Strength))
+
+ if float64(p.Level(Ranged))*1.5 > att+str {
+ base += 0.375 * float64(p.Level(Ranged))
+ } else {
+ base += 0.25 * (att + str)
+ }
+
+ base += 0.125 * float64(p.Level(Technology))
+ return int(base)
+}
+
+func (p *Player) MaxHP() int {
+ return p.Level(Hitpoints)
+}
diff --git a/internal/player/store.go b/internal/player/store.go
new file mode 100644
index 0000000..c19d9fb
--- /dev/null
+++ b/internal/player/store.go
@@ -0,0 +1,136 @@
+package player
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "thirdcollapse/internal/object"
+ "gopkg.in/yaml.v3"
+)
+
+type AccountStore struct {
+ dataDir string
+}
+
+func NewAccountStore(dataDir string) *AccountStore {
+ return &AccountStore{dataDir: dataDir}
+}
+
+func (s *AccountStore) AccountPath(name string) string {
+ return filepath.Join(s.dataDir, "players", "accounts", name+".yaml")
+}
+
+func (s *AccountStore) LoadAccount(name string) (*Account, error) {
+ path := s.AccountPath(name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("account not found: %s", name)
+ }
+ var acc Account
+ if err := yaml.Unmarshal(data, &acc); err != nil {
+ return nil, fmt.Errorf("parse account: %w", err)
+ }
+ return &acc, nil
+}
+
+func (s *AccountStore) SaveAccount(acc *Account) error {
+ path := s.AccountPath(acc.Name)
+ data, err := yaml.Marshal(acc)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0644)
+}
+
+func (s *AccountStore) AccountExists(name string) bool {
+ _, err := s.FindAccount(name)
+ return err == nil
+}
+
+func (s *AccountStore) FindAccount(name string) (string, error) {
+ path := s.AccountPath(name)
+ if _, err := os.Stat(path); err == nil {
+ return name, nil
+ }
+ dir := filepath.Join(s.dataDir, "players", "accounts")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", fmt.Errorf("account not found: %s", name)
+ }
+ lower := strings.ToLower(name)
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ base := strings.TrimSuffix(e.Name(), ".yaml")
+ if strings.ToLower(base) == lower {
+ return base, nil
+ }
+ }
+ return "", fmt.Errorf("account not found: %s", name)
+}
+
+func (s *AccountStore) CharPath(name string) string {
+ return filepath.Join(s.dataDir, "players", "characters", name+".yaml")
+}
+
+func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
+ path := s.CharPath(name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("character not found: %s", name)
+ }
+ var p Player
+ if err := yaml.Unmarshal(data, &p); err != nil {
+ return nil, fmt.Errorf("parse character: %w", err)
+ }
+ if p.Skills == nil {
+ p.Skills = make(map[SkillName]int)
+ }
+ if p.Equipment == nil {
+ p.Equipment = make(map[object.EquipSlot]string)
+ }
+ if p.Inventory == nil {
+ p.Inventory = make(map[int]*InventorySlot)
+ }
+ return &p, nil
+}
+
+func (s *AccountStore) SaveCharacter(p *Player) error {
+ path := s.CharPath(p.Name)
+ data, err := yaml.Marshal(p)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0644)
+}
+
+func (s *AccountStore) CharacterExists(name string) bool {
+ _, err := s.FindCharacter(name)
+ return err == nil
+}
+
+func (s *AccountStore) FindCharacter(name string) (string, error) {
+ path := s.CharPath(name)
+ if _, err := os.Stat(path); err == nil {
+ return name, nil
+ }
+ dir := filepath.Join(s.dataDir, "players", "characters")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", fmt.Errorf("character not found: %s", name)
+ }
+ lower := strings.ToLower(name)
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ base := strings.TrimSuffix(e.Name(), ".yaml")
+ if strings.ToLower(base) == lower {
+ return base, nil
+ }
+ }
+ return "", fmt.Errorf("character not found: %s", name)
+}
diff --git a/internal/player/xp.go b/internal/player/xp.go
new file mode 100644
index 0000000..ef59a25
--- /dev/null
+++ b/internal/player/xp.go
@@ -0,0 +1,41 @@
+package player
+
+var XPTable = [100]int{
+ 0, 83, 174, 276, 388, 512, 650, 801, 969, 1154,
+ 1358, 1584, 1833, 2107, 2411, 2746, 3115, 3523, 3973, 4470,
+ 5018, 5624, 6291, 7028, 7842, 8740, 9730, 10824, 12031, 13363,
+ 14833, 16456, 18247, 20224, 22406, 24815, 27473, 30408, 33648, 37224,
+ 41171, 45529, 50339, 55649, 61512, 67983, 75127, 83014, 91721, 101333,
+ 111945, 123660, 136594, 150872, 166636, 184040, 203254, 224466, 247886, 273742,
+ 302288, 333804, 368599, 407015, 449428, 496254, 547953, 605032, 668051, 737627,
+ 814445, 899257, 992895, 1096278, 1210421, 1336443, 1475581, 1629200, 1798808, 1986068,
+ 2192818, 2421087, 2673114, 2951373, 3258594, 3597792, 3972294, 4385776, 4842295, 5346332,
+ 5902831, 6517253, 7195629, 7944614, 8771558, 9684577, 10692629, 11805606, 13034431,
+}
+
+func LevelForXP(xp int) int {
+ for level := 1; level < 99; level++ {
+ if xp < XPTable[level] {
+ return level
+ }
+ }
+ return 99
+}
+
+func XPForLevel(level int) int {
+ if level <= 1 {
+ return 0
+ }
+ if level > 99 {
+ level = 99
+ }
+ return XPTable[level-1]
+}
+
+func XPForNextLevel(xp int) int {
+ current := LevelForXP(xp)
+ if current >= 99 {
+ return 0
+ }
+ return XPTable[current] - xp
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
new file mode 100644
index 0000000..601cdc1
--- /dev/null
+++ b/internal/world/mob.go
@@ -0,0 +1,181 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+type LootEntry struct {
+ ItemID string `yaml:"item_id"`
+ Weight int `yaml:"weight"`
+ Quantity int `yaml:"quantity"`
+}
+
+type DropTable struct {
+ Remains string `yaml:"remains"`
+ Loot []LootEntry `yaml:"loot"`
+}
+
+type MobDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Attack int `yaml:"attack"`
+ Strength int `yaml:"strength"`
+ Defense int `yaml:"defense"`
+ HP int `yaml:"hp"`
+ Speed int `yaml:"speed"`
+ Aggressive bool `yaml:"aggressive"`
+ RespawnTicks int `yaml:"respawn_ticks"`
+ Drops DropTable `yaml:"drops"`
+}
+
+type MobInstance struct {
+ InstanceID string
+ DefID string
+ Name string
+ HP int
+ MaxHP int
+ Attack int
+ Strength int
+ Defense int
+ Speed int
+ Aggressive bool
+ RespawnTicks int
+ RoomID int
+ Drops DropTable
+}
+
+type MobStore struct {
+ dataDir string
+ mu sync.Mutex
+ defs map[string]*MobDef
+ instances map[string]*MobInstance
+}
+
+func NewMobStore(dataDir string) *MobStore {
+ return &MobStore{
+ dataDir: dataDir,
+ defs: make(map[string]*MobDef),
+ instances: make(map[string]*MobInstance),
+ }
+}
+
+func (s *MobStore) LoadDef(id string) (*MobDef, error) {
+ s.mu.Lock()
+ if def, ok := s.defs[id]; ok {
+ s.mu.Unlock()
+ return def, nil
+ }
+ s.mu.Unlock()
+
+ path := filepath.Join(s.dataDir, "mobs", id+".yaml")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read mob %s: %w", id, err)
+ }
+ var def MobDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse mob %s: %w", id, err)
+ }
+
+ s.mu.Lock()
+ s.defs[id] = &def
+ s.mu.Unlock()
+ return &def, nil
+}
+
+func (s *MobStore) SpawnMob(defID string, roomID int, instanceID string) (*MobInstance, error) {
+ def, err := s.LoadDef(defID)
+ if err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ inst := &MobInstance{
+ InstanceID: instanceID,
+ DefID: defID,
+ Name: def.Name,
+ HP: def.HP,
+ MaxHP: def.HP,
+ Attack: def.Attack,
+ Strength: def.Strength,
+ Defense: def.Defense,
+ Speed: def.Speed,
+ Aggressive: def.Aggressive,
+ RespawnTicks: def.RespawnTicks,
+ RoomID: roomID,
+ Drops: def.Drops,
+ }
+ s.instances[instanceID] = inst
+ return inst, nil
+}
+
+func (s *MobStore) GetInstance(id string) *MobInstance {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.instances[id]
+}
+
+func (s *MobStore) RemoveInstance(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.instances, id)
+}
+
+func (s *MobStore) MobsInRoom(roomID int) []*MobInstance {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out []*MobInstance
+ for _, inst := range s.instances {
+ if inst.RoomID == roomID && inst.HP > 0 {
+ out = append(out, inst)
+ }
+ }
+ return out
+}
+
+func (s *MobStore) SeedMobs(roomID int, mobIDs []string) {
+ type defWrapper struct {
+ def *MobDef
+ err error
+ }
+ defs := make([]defWrapper, len(mobIDs))
+ for i, defID := range mobIDs {
+ d, err := s.LoadDef(defID)
+ defs[i] = defWrapper{d, err}
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i, dw := range defs {
+ if dw.err != nil {
+ continue
+ }
+ defID := mobIDs[i]
+ instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i)
+ if inst, exists := s.instances[instID]; exists {
+ // Already exists — skip (respawn is handled by timers)
+ _ = inst
+ continue
+ }
+ s.instances[instID] = &MobInstance{
+ InstanceID: instID,
+ DefID: defID,
+ Name: dw.def.Name,
+ HP: dw.def.HP,
+ MaxHP: dw.def.HP,
+ Attack: dw.def.Attack,
+ Strength: dw.def.Strength,
+ Defense: dw.def.Defense,
+ Speed: dw.def.Speed,
+ Aggressive: dw.def.Aggressive,
+ RespawnTicks: dw.def.RespawnTicks,
+ RoomID: roomID,
+ Drops: dw.def.Drops,
+ }
+ }
+}
diff --git a/internal/world/room.go b/internal/world/room.go
new file mode 100644
index 0000000..a92a428
--- /dev/null
+++ b/internal/world/room.go
@@ -0,0 +1,53 @@
+package world
+
+type ExitDir string
+
+const (
+ North ExitDir = "north"
+ South ExitDir = "south"
+ East ExitDir = "east"
+ West ExitDir = "west"
+ Up ExitDir = "up"
+ Down ExitDir = "down"
+)
+
+var ExitAliases = map[string]ExitDir{
+ "n": North,
+ "s": South,
+ "e": East,
+ "w": West,
+ "u": Up,
+ "d": Down,
+}
+
+var OppositeExit = map[ExitDir]ExitDir{
+ North: South,
+ South: North,
+ East: West,
+ West: East,
+ Up: Down,
+ Down: Up,
+}
+
+var ExitOrder = []ExitDir{
+ "northwest", North, "northeast",
+ East, "southeast", South, "southwest",
+ West, Up, Down,
+}
+
+type SpawnDef struct {
+ ItemID string `yaml:"item_id"`
+ Quantity int `yaml:"quantity"`
+ RespawnTicks int `yaml:"respawn_ticks"`
+}
+
+type Room struct {
+ ID int `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ MapSymbol string `yaml:"map_symbol"`
+ Exits map[ExitDir]int `yaml:"exits"`
+ Objects []string `yaml:"objects"`
+ Spawns []SpawnDef `yaml:"spawns"`
+ Mobs []string `yaml:"mobs"`
+}
diff --git a/internal/world/world.go b/internal/world/world.go
new file mode 100644
index 0000000..265a2f9
--- /dev/null
+++ b/internal/world/world.go
@@ -0,0 +1,182 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+const DropDespawnTicks = 1000
+
+type groundEntry struct {
+ itemID string
+ quantity int
+ isSpawn bool
+ respawnTimer int // >0 = counting down to respawn
+ respawnQty int
+ respawnDelay int
+ despawnTimer int // >0 = counting down to despawn (dropped items)
+}
+
+type World struct {
+ dataDir string
+ mu sync.Mutex
+ groundItems map[int][]*groundEntry
+ seeded map[int]bool
+}
+
+func New(dataDir string) *World {
+ return &World{
+ dataDir: dataDir,
+ groundItems: make(map[int][]*groundEntry),
+ seeded: make(map[int]bool),
+ }
+}
+
+func (w *World) LoadRoom(id int) (*Room, error) {
+ path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id))
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read room %d: %w", id, err)
+ }
+ var room Room
+ if err := yaml.Unmarshal(data, &room); err != nil {
+ return nil, fmt.Errorf("parse room %d: %w", id, err)
+ }
+ room.ID = id
+ if room.Exits == nil {
+ room.Exits = make(map[ExitDir]int)
+ }
+ if room.Objects == nil {
+ room.Objects = make([]string, 0)
+ }
+ if room.Spawns == nil {
+ room.Spawns = make([]SpawnDef, 0)
+ }
+ if room.Mobs == nil {
+ room.Mobs = make([]string, 0)
+ }
+ return &room, nil
+}
+
+func (w *World) ResolveExit(input string) ExitDir {
+ if dir, ok := ExitAliases[strings.ToLower(input)]; ok {
+ return dir
+ }
+ canon := ExitDir(strings.ToLower(input))
+ switch canon {
+ case North, South, East, West, Up, Down:
+ return canon
+ }
+ return ""
+}
+
+func (w *World) GroundItems(roomID int) map[string]int {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ out := make(map[string]int)
+ for _, e := range w.groundItems[roomID] {
+ if e.quantity > 0 {
+ out[e.itemID] += e.quantity
+ }
+ }
+ return out
+}
+
+func (w *World) AddGroundItem(roomID int, itemID string, qty int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ e := &groundEntry{
+ itemID: itemID,
+ quantity: qty,
+ despawnTimer: DropDespawnTicks,
+ }
+ w.groundItems[roomID] = append(w.groundItems[roomID], e)
+}
+
+func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ removed := 0
+ remaining := qty
+
+ for _, e := range w.groundItems[roomID] {
+ if remaining <= 0 {
+ break
+ }
+ if !strings.EqualFold(e.itemID, itemID) {
+ continue
+ }
+ if e.quantity <= 0 {
+ continue
+ }
+ take := e.quantity
+ if take > remaining {
+ take = remaining
+ }
+ e.quantity -= take
+ removed += take
+ remaining -= take
+
+ if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 {
+ e.respawnTimer = e.respawnDelay
+ }
+ }
+ return removed
+}
+
+func (w *World) SeedGroundItems(roomID int) {
+ w.mu.Lock()
+ if w.seeded[roomID] {
+ w.mu.Unlock()
+ return
+ }
+ w.seeded[roomID] = true
+ w.mu.Unlock()
+
+ room, err := w.LoadRoom(roomID)
+ if err != nil {
+ return
+ }
+
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ for _, s := range room.Spawns {
+ e := &groundEntry{
+ itemID: s.ItemID,
+ quantity: s.Quantity,
+ isSpawn: true,
+ respawnDelay: s.RespawnTicks,
+ respawnQty: s.Quantity,
+ }
+ w.groundItems[roomID] = append(w.groundItems[roomID], e)
+ }
+}
+
+func (w *World) Tick() {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ for _, entries := range w.groundItems {
+ for _, e := range entries {
+ if e.respawnTimer > 0 {
+ e.respawnTimer--
+ if e.respawnTimer <= 0 {
+ e.quantity = e.respawnQty
+ }
+ }
+ if e.despawnTimer > 0 {
+ e.despawnTimer--
+ if e.despawnTimer <= 0 {
+ e.quantity = 0
+ }
+ }
+ }
+ }
+}