diff options
59 files changed, 3456 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..946cb89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +TODO.md +tc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11c8a36 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: build run test vet clean + +BINARY=tc +DATA_DIR=$(PWD)/data + +build: + go build -o $(BINARY) ./cmd/mud + +run: build + DATA_DIR=$(DATA_DIR) ./$(BINARY) + +test: + DATA_DIR=$(DATA_DIR) go test ./... -timeout 60s + +vet: + go vet ./... + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/README.md diff --git a/cmd/mud/main.go b/cmd/mud/main.go new file mode 100644 index 0000000..d04bd9b --- /dev/null +++ b/cmd/mud/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "strconv" + "syscall" + + "thirdcollapse/internal/game" + "thirdcollapse/internal/net" +) + +func main() { + dataDir := os.Getenv("DATA_DIR") + if dataDir == "" { + dataDir = "data" + } + + port := 4000 + if len(os.Args) > 1 { + if p, err := strconv.Atoi(os.Args[1]); err == nil && p >= 1 && p <= 65535 { + port = p + } else { + log.Fatalf("invalid port: %s (must be 1-65535)", os.Args[1]) + } + } + + g := game.New(dataDir) + g.Ticks.Start() + defer g.Ticks.Stop() + + g.Ticks.Subscribe(1, func() bool { + g.World.Tick() + return true + }) + + addr := fmt.Sprintf(":%d", port) + server, err := net.NewServer(addr) + if err != nil { + log.Fatalf("failed to start server: %v", err) + } + g.SetHub(server.Hub()) + + log.Printf("Third Collapse listening on :%d", port) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + log.Println("Shutting down...") + g.Ticks.Stop() + os.Exit(0) + }() + + if err := server.ListenAndServe(g.HandleSession); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/data/help/attack.yaml b/data/help/attack.yaml new file mode 100644 index 0000000..5c9f7a2 --- /dev/null +++ b/data/help/attack.yaml @@ -0,0 +1,9 @@ +name: "attack" +category: "Combat" +description: | + Attack a mob in the current room. + + Usage: attack <mob>, kill <mob>, fight <mob> + + Enter combat with the named mob. Attacks occur at weapon speed on the + 600ms tick. Use "style" to change combat style before attacking. diff --git a/data/help/combat.yaml b/data/help/combat.yaml new file mode 100644 index 0000000..c8a2b16 --- /dev/null +++ b/data/help/combat.yaml @@ -0,0 +1,11 @@ +name: "combat" +category: "Combat" +description: | + Combat basics. + + Combat in Third Collapse follows Runescape Classic mechanics: + - 600ms tick engine with per-weapon attack speeds + - Attack, Strength, and Defense rolls determine hit/miss + - Combat triangle: Melee > Ranged > Technology > Melee + - 4 XP per point of damage, distributed by combat style + - Movement interrupts combat immediately diff --git a/data/help/drop.yaml b/data/help/drop.yaml new file mode 100644 index 0000000..b4e1a95 --- /dev/null +++ b/data/help/drop.yaml @@ -0,0 +1,9 @@ +name: "drop" +category: "Items" +description: | + Drop an item from your inventory. + + Usage: drop <item> + + Drops one unit of the named item onto the ground in your current room. + Other players can then pick it up. diff --git a/data/help/equipment.yaml b/data/help/equipment.yaml new file mode 100644 index 0000000..b2224a4 --- /dev/null +++ b/data/help/equipment.yaml @@ -0,0 +1,8 @@ +name: "equipment" +category: "Items" +description: | + View your equipped items. + + Usage: equipment, eq + + Shows what you have equipped in each slot (main hand, head, torso, etc.). diff --git a/data/help/get.yaml b/data/help/get.yaml new file mode 100644 index 0000000..abd416e --- /dev/null +++ b/data/help/get.yaml @@ -0,0 +1,10 @@ +name: "get" +category: "Items" +description: | + Pick up an item from the ground. + + Usage: get <item>, take <item>, grab <item>, pick <item> + get all, take all + + "get all" picks up everything on the ground. Credits are automatically + added to your credit balance and never take inventory space. diff --git a/data/help/inventory.yaml b/data/help/inventory.yaml new file mode 100644 index 0000000..7a60650 --- /dev/null +++ b/data/help/inventory.yaml @@ -0,0 +1,8 @@ +name: "inventory" +category: "Items" +description: | + View your inventory and equipped items. + + Usage: inventory, i, inv + + Shows all 28 inventory slots and any equipped items. diff --git a/data/help/look.yaml b/data/help/look.yaml new file mode 100644 index 0000000..30b1d27 --- /dev/null +++ b/data/help/look.yaml @@ -0,0 +1,9 @@ +name: "look" +category: "Exploration" +description: | + Look around the current room. + + Usage: look, l + + Shows the room name, description, exits, objects, mobs, other players, + and items on the ground. diff --git a/data/help/movement.yaml b/data/help/movement.yaml new file mode 100644 index 0000000..d40f192 --- /dev/null +++ b/data/help/movement.yaml @@ -0,0 +1,9 @@ +name: "movement" +category: "Exploration" +description: | + Move between rooms. + + Usage: north, south, east, west, up, down + Aliases: n, s, e, w, u, d + + Movement is instant. Combat is interrupted if you leave the room. diff --git a/data/help/quit.yaml b/data/help/quit.yaml new file mode 100644 index 0000000..286281a --- /dev/null +++ b/data/help/quit.yaml @@ -0,0 +1,8 @@ +name: "quit" +category: "Character" +description: | + Quit the game and disconnect. + + Usage: quit + + Saves your character and closes the connection. diff --git a/data/help/say.yaml b/data/help/say.yaml new file mode 100644 index 0000000..e3706d9 --- /dev/null +++ b/data/help/say.yaml @@ -0,0 +1,8 @@ +name: "say" +category: "Communication" +description: | + Speak to other players in the same room. + + Usage: say <message> + + Everyone in your current room hears what you say. diff --git a/data/help/score.yaml b/data/help/score.yaml new file mode 100644 index 0000000..9607a9c --- /dev/null +++ b/data/help/score.yaml @@ -0,0 +1,8 @@ +name: "score" +category: "Character" +description: | + View your character's stats and skills. + + Usage: score, sc, / + + Shows your name, combat level, HP, credits, skill levels, and XP. diff --git a/data/help/style.yaml b/data/help/style.yaml new file mode 100644 index 0000000..3016013 --- /dev/null +++ b/data/help/style.yaml @@ -0,0 +1,13 @@ +name: "style" +category: "Combat" +description: | + View or change your combat style. + + Usage: style, style <accurate|aggressive|defensive|balanced> + + Accurate - +3 attack bonus, trains Attack (75%) and Hitpoints (25%) + Aggressive - +3 strength bonus, trains Strength (75%) and Hitpoints (25%) + Defensive - +3 defense bonus, trains Defense (75%) and Hitpoints (25%) + Balanced - +1 all bonuses, trains all combat skills evenly (25% each) + + XP is awarded per hit: 4 XP per point of damage. diff --git a/data/items/bones.yaml b/data/items/bones.yaml new file mode 100644 index 0000000..de868a0 --- /dev/null +++ b/data/items/bones.yaml @@ -0,0 +1,6 @@ +id: "bones" +name: "bones" +aliases: ["bone"] +description: "A pile of bones." +value: 2 +stackable: false diff --git a/data/items/bronze_pickaxe.yaml b/data/items/bronze_pickaxe.yaml new file mode 100644 index 0000000..a64395e --- /dev/null +++ b/data/items/bronze_pickaxe.yaml @@ -0,0 +1,7 @@ +id: "bronze_pickaxe" +name: "bronze pickaxe" +aliases: ["pickaxe", "pick", "bronze"] +description: "A basic bronze pickaxe for mining." +value: 8 +stackable: false +toolbelt: true diff --git a/data/items/bronze_sword.yaml b/data/items/bronze_sword.yaml new file mode 100644 index 0000000..e8ba217 --- /dev/null +++ b/data/items/bronze_sword.yaml @@ -0,0 +1,13 @@ +id: "bronze_sword" +name: "bronze sword" +aliases: ["sword", "bronze"] +description: "A basic bronze short sword." +value: 12 +stackable: false +equip_slot: "main_hand" +weapon_type: "melee" +stats: + attack_bonus: 4 + strength_bonus: 3 + defense_bonus: 0 +speed: 4 diff --git a/data/items/copper_ore.yaml b/data/items/copper_ore.yaml new file mode 100644 index 0000000..5f11316 --- /dev/null +++ b/data/items/copper_ore.yaml @@ -0,0 +1,6 @@ +id: "copper_ore" +name: "copper ore" +aliases: ["copper", "ore"] +description: "A chunk of copper ore." +value: 3 +stackable: false diff --git a/data/items/credits.yaml b/data/items/credits.yaml new file mode 100644 index 0000000..80c109e --- /dev/null +++ b/data/items/credits.yaml @@ -0,0 +1,6 @@ +id: "credits" +name: "credits" +aliases: ["credit", "creds"] +description: "A handful of universal credits." +value: 1 +stackable: true diff --git a/data/items/oak_logs.yaml b/data/items/oak_logs.yaml new file mode 100644 index 0000000..24cd0d0 --- /dev/null +++ b/data/items/oak_logs.yaml @@ -0,0 +1,6 @@ +id: "oak_logs" +name: "oak logs" +aliases: ["logs", "oak", "log"] +description: "Some sturdy oak logs." +value: 4 +stackable: false diff --git a/data/items/tin_ore.yaml b/data/items/tin_ore.yaml new file mode 100644 index 0000000..62a55b4 --- /dev/null +++ b/data/items/tin_ore.yaml @@ -0,0 +1,6 @@ +id: "tin_ore" +name: "tin ore" +aliases: ["tin", "ore"] +description: "A chunk of tin ore." +value: 2 +stackable: false diff --git a/data/mobs/man.yaml b/data/mobs/man.yaml new file mode 100644 index 0000000..074e1f9 --- /dev/null +++ b/data/mobs/man.yaml @@ -0,0 +1,18 @@ +id: "man" +name: "man" +attack: 1 +strength: 1 +defense: 1 +hp: 7 +speed: 5 +aggressive: false +respawn_ticks: 30 +drops: + remains: "bones" + loot: + - item_id: "credits" + weight: 98 + quantity: 10 + - item_id: "credits" + weight: 2 + quantity: 150 diff --git a/data/objects/copper_rock_1.yaml b/data/objects/copper_rock_1.yaml new file mode 100644 index 0000000..257601a --- /dev/null +++ b/data/objects/copper_rock_1.yaml @@ -0,0 +1,8 @@ +id: "copper_rock_1" +name: "copper rock" +behavior: "rock" +props: + skill: "mining" + level: 1 + yields: "copper_ore" + respawn_ticks: 30 diff --git a/data/objects/copper_rock_2.yaml b/data/objects/copper_rock_2.yaml new file mode 100644 index 0000000..5805398 --- /dev/null +++ b/data/objects/copper_rock_2.yaml @@ -0,0 +1,8 @@ +id: "copper_rock_2" +name: "copper rock" +behavior: "rock" +props: + skill: "mining" + level: 1 + yields: "copper_ore" + respawn_ticks: 30 diff --git a/data/objects/lumby_fountain.yaml b/data/objects/lumby_fountain.yaml new file mode 100644 index 0000000..4d877c1 --- /dev/null +++ b/data/objects/lumby_fountain.yaml @@ -0,0 +1,5 @@ +id: "lumby_fountain" +name: "town fountain" +behavior: "none" +props: + description: "Clear water sparkles in the sunlight." diff --git a/data/objects/oak_tree_1.yaml b/data/objects/oak_tree_1.yaml new file mode 100644 index 0000000..a7eb331 --- /dev/null +++ b/data/objects/oak_tree_1.yaml @@ -0,0 +1,8 @@ +id: "oak_tree_1" +name: "oak tree" +behavior: "tree" +props: + skill: "woodcutting" + level: 15 + yields: "oak_logs" + respawn_ticks: 50 diff --git a/data/objects/oak_tree_2.yaml b/data/objects/oak_tree_2.yaml new file mode 100644 index 0000000..7f9f9a4 --- /dev/null +++ b/data/objects/oak_tree_2.yaml @@ -0,0 +1,8 @@ +id: "oak_tree_2" +name: "oak tree" +behavior: "tree" +props: + skill: "woodcutting" + level: 15 + yields: "oak_logs" + respawn_ticks: 50 diff --git a/data/objects/oak_tree_3.yaml b/data/objects/oak_tree_3.yaml new file mode 100644 index 0000000..ac40380 --- /dev/null +++ b/data/objects/oak_tree_3.yaml @@ -0,0 +1,8 @@ +id: "oak_tree_3" +name: "oak tree" +behavior: "tree" +props: + skill: "woodcutting" + level: 15 + yields: "oak_logs" + respawn_ticks: 50 diff --git a/data/players/accounts/Default.yaml b/data/players/accounts/Default.yaml new file mode 100644 index 0000000..32ae393 --- /dev/null +++ b/data/players/accounts/Default.yaml @@ -0,0 +1,4 @@ +name: Default +password_hash: sha256:mfnZDVUFEvzM4AUQ4Kgnrw:f82f8f7fa0dbb1238bf41d6dc63c30b2fbf67a4cebcdbbaba3572422545e2fe3 +characters: + - Default diff --git a/data/players/characters/Default.yaml b/data/players/characters/Default.yaml new file mode 100644 index 0000000..83142ea --- /dev/null +++ b/data/players/characters/Default.yaml @@ -0,0 +1,29 @@ +name: Default +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: {} +equipment: {} +toolbelt: [] +room_id: 1 +hp: 10 +credits: 0 +attack_style: accurate diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml new file mode 100644 index 0000000..7d1f7c5 --- /dev/null +++ b/data/rooms/1.yaml @@ -0,0 +1,16 @@ +id: 1 +name: "Lumbridge Town Square" +description: "A bustling town square in the heart of Lumbridge. Cobblestone paths lead in all directions. A fountain gurgles peacefully in the center." +map_symbol: "S" +exits: + north: 2 + east: 3 +objects: + - "lumby_fountain" +spawns: + - item_id: "copper_ore" + quantity: 1 + respawn_ticks: 10 +mobs: + - "man" + - "man" diff --git a/data/rooms/2.yaml b/data/rooms/2.yaml new file mode 100644 index 0000000..4b1b5f8 --- /dev/null +++ b/data/rooms/2.yaml @@ -0,0 +1,8 @@ +id: 2 +name: "North Lumbridge Road" +description: "A dusty road leading north out of town. To the south you see the town square. A small mine entrance is carved into a hillside to the west." +map_symbol: "R" +exits: + south: 1 + north: 4 + west: 6 diff --git a/data/rooms/3.yaml b/data/rooms/3.yaml new file mode 100644 index 0000000..57a7cb1 --- /dev/null +++ b/data/rooms/3.yaml @@ -0,0 +1,6 @@ +id: 3 +name: "Lumbridge General Store" +description: "A small but well-stocked general store. Shelves line the walls with various goods." +map_symbol: "G" +exits: + west: 1 diff --git a/data/rooms/4.yaml b/data/rooms/4.yaml new file mode 100644 index 0000000..ab25de1 --- /dev/null +++ b/data/rooms/4.yaml @@ -0,0 +1,13 @@ +id: 4 +name: "Lumbridge Forest Edge" +description: "The road ends here at the edge of a dark forest. Towering oak trees loom ahead to the north." +map_symbol: "F" +exits: + south: 2 + north: 5 +objects: + - "oak_tree_1" +spawns: + - item_id: "oak_logs" + quantity: 1 + respawn_ticks: 10 diff --git a/data/rooms/5.yaml b/data/rooms/5.yaml new file mode 100644 index 0000000..097a134 --- /dev/null +++ b/data/rooms/5.yaml @@ -0,0 +1,9 @@ +id: 5 +name: "Deep Forest" +description: "Thick trees surround you on all sides. Shafts of light pierce through the canopy above." +map_symbol: "T" +exits: + south: 4 +objects: + - "oak_tree_2" + - "oak_tree_3" diff --git a/data/rooms/6.yaml b/data/rooms/6.yaml new file mode 100644 index 0000000..7b5d4b4 --- /dev/null +++ b/data/rooms/6.yaml @@ -0,0 +1,16 @@ +id: 6 +name: "Abandoned Mine" +description: "A dark, dusty mine shaft. Veins of copper and tin run through the walls. A single lantern flickers overhead." +map_symbol: "M" +exits: + west: 2 +objects: + - "copper_rock_1" + - "copper_rock_2" +spawns: + - item_id: "copper_ore" + quantity: 1 + respawn_ticks: 10 + - item_id: "tin_ore" + quantity: 1 + respawn_ticks: 10 @@ -0,0 +1,5 @@ +module thirdcollapse + +go 1.25.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 + } + } + } + } +} |
