diff options
| author | historia <[not public]> | 2026-06-14 21:07:26 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-14 21:07:26 -0400 |
| commit | 1aba2bdd23aebed4032333e74aa553c40a31fcbd (patch) | |
| tree | c004f4c2a139df5c3cf4029b2611bdfa09b40f86 | |
| parent | a19e6b6ab01670a5932308c7bd0e0e5eed8cbcad (diff) | |
| download | thehouseoficarus-1aba2bdd23aebed4032333e74aa553c40a31fcbd.tar.gz | |
refactor: added docs, split up some components in game package
51 files changed, 612 insertions, 486 deletions
@@ -21,18 +21,32 @@ Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`. ``` cmd/mud/main.go Entry point — config loading, multi-listener, tick engine -internal/config/ YAML config (telnet, http, https listeners) -internal/net/ Conn interface (tcp + websocket), session state, hub, IAC echo -internal/game/ Login flow, command dispatch, input sanitization, all game logic -internal/action/ Behavior structs (GatherConfig, TalkConfig, etc.), Action, drop tables -internal/player/ Player struct, skills, inventory, XP, name/password validation -internal/world/ Room, MobDef, MobInstance, ObjState, ground items, wandering -internal/combat/ Combat state tracking, OSRS-style combat formulas -internal/object/ ItemDef, ObjectDef, item/object YAML loaders -internal/engine/ Tick scheduler — 600ms interval, subscriber pattern +internal/config/ YAML config (telnet, http, https listeners) + doc.go +internal/net/ Conn interface (tcp + websocket), session state, hub, IAC echo + doc.go +internal/game/ Login flow, command dispatch, input sanitization, all game logic + doc.go +internal/action/ Behavior structs (GatherConfig, TalkConfig, etc.), Action, drop tables + doc.go +internal/player/ Player struct, skills, inventory, XP, name/password validation + doc.go +internal/world/ Room, MobDef, MobInstance, ObjState, ground items, wandering + doc.go +internal/combat/ Combat state tracking, OSRS-style combat formulas + doc.go +internal/object/ ItemDef, ObjectDef, item/object YAML loaders + doc.go +internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + doc.go ``` -`internal/game/` is the largest package and contains most command implementations as `cmd_*.go` and `action_*.go` files. +`internal/game/` is the largest package. Key files: + +| File | Purpose | +|---|---| +| `game.go` | Game struct, HandleSession state machine, command dispatch | +| `login_account.go` | Account auth, creation, password handling, main menu, account rename/purge | +| `login_char.go` | Character creation, connection, rename, delete | +| `cmd_*.go` | Command handlers (see Adding a New Command below) | +| `action.go` | StartAction, CancelAction, AdvanceActions, checkCondition | +| `action_*.go` | Action lifecycle per behavior type (gather, talk, use, toggle, burn) | +| `tick.go` | DisconnectTick, RegenTick, WanderTick, WoodcuttingTick | +| `map.go` | BFS graph builder, tiny map, full map, display utilities (strip, trim) | +| `types.go` | Shared types (EquipSlots, itemMatch, xpGain, deathDrop) | +| `utils.go` | Helper functions (plural, parseQty, parseChoiceIndex, formatPickupList, etc.) | +| `help.go` | Help topic YAML loading and display | ## Key Conventions @@ -44,7 +58,7 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern **Transport-agnostic.** The `Conn` interface (`internal/net/conn.go`) abstracts the transport layer. `tcpConn` wraps a raw `net.Conn` for telnet. `wsConn` wraps `gorilla/websocket.Conn` for the web client. Game code works with `*Session` regardless of transport. -**Input sanitization.** `HandleSession` strips Unicode control characters (ANSI escapes, nulls, etc.) from every input line before dispatch. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header. +**Input sanitization.** `HandleSession` strips Unicode control characters from every input line via `player.StripControlCharacters` before dispatch. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header. **Tick-driven.** The world runs on a 600ms tick. Combat, gathering, regen, wandering, respawns, shared depletion, woodcutting timers all advance per tick. See `cmd/mud/main.go` for the subscription loop. @@ -78,7 +92,7 @@ Actions that interact with objects/mobs route through `StartAction()` in `action 5. Place the object in a room YAML in `data/rooms/` 6. Create any resource items in `data/items/` -Set `xp` on the behavior to award XP on successful gathers. Set `shared_deplete` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion. +Set `xp` on the behavior to award XP on successful gathers. Set `deplete_timer` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion. ## Adding a New Production Skill @@ -134,7 +148,7 @@ Firemaking is implemented as standalone actions (not behavior-YAML-driven) in `i **Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. Rocks don't show despawn timers when not depleted. -**Shared depletion** (higher-tier trees): Set `shared_deplete: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. `WoodcuttingTick` manages this in `internal/game/tick.go`. +**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. `WoodcuttingTick` manages this in `internal/game/tick.go`. ## Data Files @@ -59,7 +59,7 @@ Behaviors are defined in `data/behaviors/<id>.yaml`. Objects reference them with **Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. -**Shared depletion** (higher-tier trees): Set `shared_deplete: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. +**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. ### Bird's Nests @@ -146,7 +146,7 @@ Aliases resolve before built-in commands and support argument passthrough (`alia - [x] `talk / speak / ask <target>` — NPC conversations (objects or mobs) - [x] `pull / push <object>` — toggle interactions (levers, gates) - [x] `alias <name> <cmd> / unalias <name>` — account-wide command aliases -- [x] `toggle [name]` — 9 character settings +- [x] `option / options [name] [value]` — 15 character settings (bool, string, int types) - [x] `description / desc / help [topic] / quit` - [x] `search <item>` — search bird's nests for loot - [x] `burn [item]` — start a fire from logs on ground or in inventory (smart default) diff --git a/WORLDBUILDING.md b/WORLDBUILDING.md index 9585c32..93fa16d 100644 --- a/WORLDBUILDING.md +++ b/WORLDBUILDING.md @@ -292,7 +292,7 @@ drops: weight: 10 depletes: false # gem drops don't deplete the rock message: "You spot a glint of something valuable!" -deplete_delay: 50 # ticks until rock respawns +respawn_timer: 50 # ticks until rock respawns respawn_message: "You see more ore in the rock." respawn_broadcast: "A glint of copper catches your eye from some {name}." ``` @@ -337,10 +337,10 @@ fail_message: "You swing but get no logs." drops: - item_id: oak_logs weight: 100 - depletes: false # depletion is timer-based (shared_deplete) + depletes: false # depletion is timer-based (deplete_timer) message: "You get some oak logs." -deplete_delay: 14 # ticks until tree respawns after being cut down -shared_deplete: 45 # max ticks before next gather depletes (counts down while chopping) +respawn_timer: 14 # ticks until tree respawns after being cut down +deplete_timer: 45 # max ticks before next gather depletes (counts down while chopping) nest_chance: 256 # 1/256 chance for a bird's nest on each successful gather respawn_message: "A new oak sapling grows in its place." respawn_broadcast: "An {name} grows back." @@ -366,21 +366,21 @@ drops: weight: 100 depletes: true # regular tree depletes on first successful gather message: "You get some logs." -deplete_delay: 80 +respawn_timer: 80 respawn_message: "A new tree grows in its place." respawn_broadcast: "A {name} grows back." ``` #### Shared depletion explained -When `shared_deplete > 0`, the tree has a shared despawn timer: -- The timer starts at `shared_deplete` max when the first player begins chopping. +When `deplete_timer > 0`, the tree has a shared despawn timer: +- The timer starts at `deplete_timer` max when the first player begins chopping. - Each tick, if anyone is chopping, the timer counts down. - When the timer reaches 0, the NEXT successful gather depletes the tree. - If no one is chopping and the tree isn't depleted, the timer ticks back UP. - All players chopping the same tree are interrupted when it depletes. -Use `shared_deplete` for trees. Use `depletes: true` on individual drops for rocks. +Use `deplete_timer` for trees. Use `depletes: true` on individual drops for rocks. #### Bird's nests @@ -970,6 +970,6 @@ description: "A dusty corridor. One of the wall stones looks slightly out of pla 8. **Live editing works.** Room, item, mob, object, and behavior YAML files are read from disk on each access. Change a room description or dialog and it takes effect immediately — no restart needed. -9. **Shared depletion vs per-drop depletion.** Use `shared_deplete` for trees — the timer counts down while being chopped and regens when left alone. Use `depletes: true` on individual drops for rocks — they deplete on first successful gather. +9. **Shared depletion vs per-drop depletion.** Use `deplete_timer` for trees — the timer counts down while being chopped and regens when left alone. Use `depletes: true` on individual drops for rocks — they deplete on first successful gather. 10. **Mob wander config goes in the room YAML**, not the mob definition. This lets the same `man` wander differently in different rooms. diff --git a/data/behaviors/chop_magic.yaml b/data/behaviors/chop_magic.yaml index 762d979..ae5ff7e 100644 --- a/data/behaviors/chop_magic.yaml +++ b/data/behaviors/chop_magic.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some magic logs." -deplete_delay: 199 -shared_deplete: 390 +respawn_timer: 199 +deplete_timer: 390 nest_chance: 256 respawn_message: "A new magic sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_mahogany.yaml b/data/behaviors/chop_mahogany.yaml index 61ad6af..61ab47a 100644 --- a/data/behaviors/chop_mahogany.yaml +++ b/data/behaviors/chop_mahogany.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some mahogany logs." -deplete_delay: 14 -shared_deplete: 100 +respawn_timer: 14 +deplete_timer: 100 nest_chance: 256 respawn_message: "A new mahogany sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_maple.yaml b/data/behaviors/chop_maple.yaml index aa4ebea..2c7c21d 100644 --- a/data/behaviors/chop_maple.yaml +++ b/data/behaviors/chop_maple.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some maple logs." -deplete_delay: 59 -shared_deplete: 100 +respawn_timer: 59 +deplete_timer: 100 nest_chance: 256 respawn_message: "A new maple sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_oak.yaml b/data/behaviors/chop_oak.yaml index 6731d24..fcd0f2d 100644 --- a/data/behaviors/chop_oak.yaml +++ b/data/behaviors/chop_oak.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some oak logs." -deplete_delay: 14 -shared_deplete: 45 +respawn_timer: 14 +deplete_timer: 45 nest_chance: 256 respawn_message: "A new oak sapling grows in its place." respawn_broadcast: "An {name} grows back." diff --git a/data/behaviors/chop_redwood.yaml b/data/behaviors/chop_redwood.yaml index e3def4f..a563bd5 100644 --- a/data/behaviors/chop_redwood.yaml +++ b/data/behaviors/chop_redwood.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some redwood logs." -deplete_delay: 199 -shared_deplete: 440 +respawn_timer: 199 +deplete_timer: 440 nest_chance: 256 respawn_message: "A new redwood sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_teak.yaml b/data/behaviors/chop_teak.yaml index ed2f60d..d20aebc 100644 --- a/data/behaviors/chop_teak.yaml +++ b/data/behaviors/chop_teak.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some teak logs." -deplete_delay: 15 -shared_deplete: 50 +respawn_timer: 15 +deplete_timer: 50 nest_chance: 256 respawn_message: "A new teak sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_tree.yaml b/data/behaviors/chop_tree.yaml index 494cc1d..f7c4de2 100644 --- a/data/behaviors/chop_tree.yaml +++ b/data/behaviors/chop_tree.yaml @@ -17,7 +17,7 @@ drops: weight: 100 depletes: true message: "You get some logs." -deplete_delay: 80 +respawn_timer: 80 exhausted_message: "The tree comes crashing down!" respawn_message: "A new tree grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_willow.yaml b/data/behaviors/chop_willow.yaml index f972aa5..10dc05a 100644 --- a/data/behaviors/chop_willow.yaml +++ b/data/behaviors/chop_willow.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some willow logs." -deplete_delay: 14 -shared_deplete: 50 +respawn_timer: 14 +deplete_timer: 50 nest_chance: 256 respawn_message: "A new willow sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_yew.yaml b/data/behaviors/chop_yew.yaml index 48f387d..ec25b4d 100644 --- a/data/behaviors/chop_yew.yaml +++ b/data/behaviors/chop_yew.yaml @@ -16,8 +16,8 @@ drops: weight: 100 depletes: false message: "You get some yew logs." -deplete_delay: 99 -shared_deplete: 190 +respawn_timer: 99 +deplete_timer: 190 nest_chance: 256 respawn_message: "A new yew sapling grows in its place." respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/mine_adamantite.yaml b/data/behaviors/mine_adamantite.yaml index d4a3cda..50b9a60 100644 --- a/data/behaviors/mine_adamantite.yaml +++ b/data/behaviors/mine_adamantite.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some adamantite ore." -deplete_delay: 140 +respawn_timer: 140 respawn_message: "You see more ore in the rock." respawn_broadcast: "A green glint of adamantite catches your eye from some {name}." diff --git a/data/behaviors/mine_clay.yaml b/data/behaviors/mine_clay.yaml index 8798671..73c7e19 100644 --- a/data/behaviors/mine_clay.yaml +++ b/data/behaviors/mine_clay.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to get some clay." -deplete_delay: 50 +respawn_timer: 50 respawn_message: "The clay deposit is ready to dig again." respawn_broadcast: "A deposit of clay softens." diff --git a/data/behaviors/mine_coal.yaml b/data/behaviors/mine_coal.yaml index 036a009..ab1d6a2 100644 --- a/data/behaviors/mine_coal.yaml +++ b/data/behaviors/mine_coal.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some coal." -deplete_delay: 80 +respawn_timer: 80 respawn_message: "You see more coal in the rock." respawn_broadcast: "A dark vein of coal is visible in some {name}." diff --git a/data/behaviors/mine_copper.yaml b/data/behaviors/mine_copper.yaml index b4f27b9..82755e3 100644 --- a/data/behaviors/mine_copper.yaml +++ b/data/behaviors/mine_copper.yaml @@ -21,6 +21,6 @@ drops: weight: 10 depletes: false message: "You spot a glint of something valuable!" -deplete_delay: 50 +respawn_timer: 50 respawn_message: "You see more ore in the rock." respawn_broadcast: "A glint of copper catches your eye from some {name}." diff --git a/data/behaviors/mine_gold.yaml b/data/behaviors/mine_gold.yaml index 731dbec..01a8b33 100644 --- a/data/behaviors/mine_gold.yaml +++ b/data/behaviors/mine_gold.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some gold ore." -deplete_delay: 90 +respawn_timer: 90 respawn_message: "You see more ore in the rock." respawn_broadcast: "A golden glint catches your eye from some {name}." diff --git a/data/behaviors/mine_iron.yaml b/data/behaviors/mine_iron.yaml index fbd5ac1..b7ec4af 100644 --- a/data/behaviors/mine_iron.yaml +++ b/data/behaviors/mine_iron.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some iron ore." -deplete_delay: 60 +respawn_timer: 60 respawn_message: "You see more ore in the rock." respawn_broadcast: "A glint of iron catches your eye from some {name}." diff --git a/data/behaviors/mine_mithril.yaml b/data/behaviors/mine_mithril.yaml index 53ffe7f..26c5f5f 100644 --- a/data/behaviors/mine_mithril.yaml +++ b/data/behaviors/mine_mithril.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some mithril ore." -deplete_delay: 110 +respawn_timer: 110 respawn_message: "You see more ore in the rock." respawn_broadcast: "A blue glint of mithril catches your eye from some {name}." diff --git a/data/behaviors/mine_runite.yaml b/data/behaviors/mine_runite.yaml index 4fbd547..5bd65c7 100644 --- a/data/behaviors/mine_runite.yaml +++ b/data/behaviors/mine_runite.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some runite ore." -deplete_delay: 180 +respawn_timer: 180 respawn_message: "You see more ore in the rock." respawn_broadcast: "A pale blue glint of runite catches your eye from some {name}." diff --git a/data/behaviors/mine_scrap.yaml b/data/behaviors/mine_scrap.yaml index 57e0362..ec889a6 100644 --- a/data/behaviors/mine_scrap.yaml +++ b/data/behaviors/mine_scrap.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You salvage some scrap metal." -deplete_delay: 50 +respawn_timer: 50 respawn_message: "More scrap is revealed in the pile." respawn_broadcast: "The scrap pile looks worth mining again." diff --git a/data/behaviors/mine_silver.yaml b/data/behaviors/mine_silver.yaml index e6652cb..8b21b98 100644 --- a/data/behaviors/mine_silver.yaml +++ b/data/behaviors/mine_silver.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some silver ore." -deplete_delay: 70 +respawn_timer: 70 respawn_message: "You see more ore in the rock." respawn_broadcast: "A silvery glint catches your eye from some {name}." diff --git a/data/behaviors/mine_tin.yaml b/data/behaviors/mine_tin.yaml index b771a44..107c549 100644 --- a/data/behaviors/mine_tin.yaml +++ b/data/behaviors/mine_tin.yaml @@ -16,6 +16,6 @@ drops: weight: 100 depletes: true message: "You manage to mine some tin ore." -deplete_delay: 50 +respawn_timer: 50 respawn_message: "You see more ore in the rock." respawn_broadcast: "A glint of tin catches your eye from some {name}." diff --git a/internal/action/behavior.go b/internal/action/behavior.go index 7abe0ae..e0b8cdf 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -13,10 +13,10 @@ type GatherConfig struct { ExhaustedMessage string `yaml:"exhausted_message"` FailMsg string `yaml:"fail_message"` Drops []DropEntry `yaml:"drops"` - DepleteDelay int `yaml:"deplete_delay"` + RespawnTimer int `yaml:"respawn_timer"` RespawnMsg string `yaml:"respawn_message"` RespawnBroadcast string `yaml:"respawn_broadcast"` - SharedDeplete int `yaml:"shared_deplete"` + DepleteTimer int `yaml:"deplete_timer"` NestChance int `yaml:"nest_chance"` } diff --git a/internal/action/doc.go b/internal/action/doc.go new file mode 100644 index 0000000..c1a898d --- /dev/null +++ b/internal/action/doc.go @@ -0,0 +1,11 @@ +// Package action defines the behavior type system, action state tracking, +// and drop table resolution for the game world. +// +// Behaviors are YAML-driven and come in four types: gather, talk, use, and toggle. +// This package provides configuration structs for each type, the Action struct +// that tracks per-player action state (type, target, tick countdown), and the +// Store for loading behaviors and resolving weighted drop tables. +// +// The SuccessChance function implements the OSRS-style skill check formula. +// Drop table resolution supports nested sub-tables via the ResolveDrop method. +package action diff --git a/internal/combat/doc.go b/internal/combat/doc.go new file mode 100644 index 0000000..758be47 --- /dev/null +++ b/internal/combat/doc.go @@ -0,0 +1,11 @@ +// Package combat implements OSRS-style combat formulas and per-player +// combat state tracking. +// +// Combat uses attack/defense roll formulas, style bonuses, and max-hit +// calculations. The State type tracks active combat between a player and +// a mob instance, with a lock timer preventing immediate disengagement. +// +// Combat state is managed through package-level maps protected by a mutex. +// TickCombat decrements lock timers each tick. EnterCombat and LeaveCombat +// manage the relationship between player names and mob instance IDs. +package combat diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..f35aad3 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,10 @@ +// Package config loads YAML server configuration and provides sensible +// defaults when no config file is present. +// +// Configuration covers three listener types: telnet (raw TCP), HTTP + WebSocket, +// and HTTPS + WebSocket. Each has an enabled flag and port. The game section +// controls display settings like max terminal width. +// +// Call Load(path) to read a YAML file merged with defaults, or Default() +// for a configuration suitable for running with telnet on :4000. +package config diff --git a/internal/engine/doc.go b/internal/engine/doc.go new file mode 100644 index 0000000..0ce25bd --- /dev/null +++ b/internal/engine/doc.go @@ -0,0 +1,11 @@ +// Package engine provides a tick scheduler that drives all time-based +// world simulation. +// +// The Engine runs on a 600ms interval. Subscribers register callbacks with +// a tick interval (1 = every tick, N = every Nth tick). Callbacks return +// true to remain subscribed or false to auto-unsubscribe. +// +// The main game loop subscribes a single callback that advances combat, +// regeneration, wandering, woodcutting depletion, fire decay, and player +// actions all on the same tick beat. +package engine diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 7e53e87..201a619 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -32,7 +32,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje msg += fmt.Sprintf(" (%d ticks to respawn)", st.DepleteTimer) } sess.WriteLine(msg) - } else if cfg.SharedDeplete > 0 { + } else if cfg.DepleteTimer > 0 { sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer)) } else { sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) @@ -114,8 +114,8 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje "effective_wait": wait, "step": 0, } - if cfg.SharedDeplete > 0 { - data["shared_deplete"] = true + if cfg.DepleteTimer > 0 { + data["deplete_timer"] = true } p.Action = &action.Action{ Type: "gather", @@ -137,7 +137,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { step := p.Action.Data["step"].(int) wait := p.Action.Data["effective_wait"].(int) instanceKey := p.Action.Data["instance_key"].(string) - _, shared := p.Action.Data["shared_deplete"] + _, shared := p.Action.Data["deplete_timer"] if step == 0 { if cfg.Bait != "" { @@ -240,7 +240,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } if !shared && drop.Depletes { - delay := cfg.DepleteDelay + delay := cfg.RespawnTimer if delay <= 0 { delay = 10 } @@ -305,7 +305,7 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, cfg *action.GatherConfig, instanceKey string) { st.Depleted = true - st.DepleteTimer = cfg.DepleteDelay + st.DepleteTimer = cfg.RespawnTimer st.SharedTimer = 0 msg := cfg.ExhaustedMessage diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index f4929e9..4980c5a 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -111,7 +111,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { return } - idx, err := parseIndex(input) + idx, err := parseChoiceIndex(input) if err != nil || idx <= 0 { g.CancelAction(p) sess.State = net.StateGame @@ -193,7 +193,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.AccountStore.SaveCharacter(p) g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) - g.ensureRoomObjects(p.RoomID) + g.seedRoomObjects(p.RoomID) if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } diff --git a/internal/game/action_use.go b/internal/game/action_use.go index 497ceac..c47492f 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -17,7 +17,7 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD return } - for itemID, qty := range cfg.Consume { + for itemID := range cfg.Consume { if !p.HasItem(itemID) { defName := itemID if def, err := g.ItemStore.Load(itemID); err == nil { @@ -26,7 +26,6 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) return } - _ = qty } if cfg.Success == nil && p.FirstFreeSlot() == -1 { diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index cbe232d..149b86b 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -222,7 +222,7 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { sess.WriteLine("\nYou have nothing to drop.") } else { sess.Write(fmt.Sprintf("\nYou drop your ")) - innerPickupReport(sess, dropped) + formatPickupList(sess, dropped) } sess.Write("\r\n> ") } diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index f4c7426..78a9621 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -73,12 +73,10 @@ func (g *Game) doGet(sess *net.Session, input string) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == itemID { - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { + if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok { sess.WriteLine("That's not yours!") return } - _ = removed slot.Quantity += qty g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, def.Name, slot.Quantity)) @@ -90,13 +88,11 @@ func (g *Game) doGet(sess *net.Session, input string) { sess.WriteLine("Your inventory is full.") return } - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { + if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name); !ok { sess.WriteLine("That's not yours!") return } - _ = removed - p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty)) + p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) g.AccountStore.SaveCharacter(p) if qty == 1 { sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name)) @@ -112,11 +108,9 @@ func (g *Game) doGet(sess *net.Session, input string) { if fs == -1 { break } - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name) - if !ok { + if _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 1, p.Name); !ok { break } - _ = removed p.SetInvSlot(fs, &player.InventorySlot{ItemID: itemID, Quantity: 1}) picked++ } @@ -318,7 +312,7 @@ func (g *Game) doGetAll(sess *net.Session) { if freeSlot == -1 { if len(picked) > 0 { sess.WriteLine("You can't hold everything but you picked up:") - innerPickupReport(sess, picked) + formatPickupList(sess, picked) } else { sess.WriteLine("Your inventory is full!") } @@ -329,7 +323,7 @@ func (g *Game) doGetAll(sess *net.Session) { if !ok { break } - p.SetInvSlot(freeSlot, g.newInvSlot(itemID, qty)) + p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) name := itemID if def != nil { name = def.Name @@ -342,7 +336,7 @@ func (g *Game) doGetAll(sess *net.Session) { if freeSlot == -1 { if len(picked) > 0 { sess.WriteLine("You can't hold everything but you picked up:") - innerPickupReport(sess, picked) + formatPickupList(sess, picked) } else { sess.WriteLine("Your inventory is full!") } @@ -371,7 +365,7 @@ func (g *Game) doGetAll(sess *net.Session) { sess.WriteLine("Your inventory is full.") } else { sess.Write("You picked up: ") - innerPickupReport(sess, picked) + formatPickupList(sess, picked) } } diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index e0e98fe..a840579 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -141,7 +141,7 @@ func (g *Game) doLook(sess *net.Session) { } var suffix string if multi && (len(timed) > 0 || len(depleted) > 0) { - suffix = fmt.Sprintf(" [%s]", intsJoin(freshIdxs)) + suffix = fmt.Sprintf(" [%s]", joinInts(freshIdxs)) } qualityTimer := "" if showTimers && len(instances) > 0 && instances[0].quality > 0 { @@ -302,7 +302,7 @@ func (g *Game) doLook(sess *net.Session) { } line += fmt.Sprintf(" (fighting %s)", name) } - } else if desc := actionDesc(op); desc != "" { + } else if desc := playerActionDescription(op); desc != "" { line += ", " + desc } sess.WriteLine(line + ".") @@ -512,7 +512,7 @@ func (g *Game) doExits(sess *net.Session) { } } -func intsJoin(nums []int) string { +func joinInts(nums []int) string { var parts []string for _, n := range nums { parts = append(parts, strconv.Itoa(n)) diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go index 7c506f9..e0413d3 100644 --- a/internal/game/cmd_map.go +++ b/internal/game/cmd_map.go @@ -1,11 +1,8 @@ package game import ( - "strings" - "thirdcollapse/internal/net" "thirdcollapse/internal/player" - "thirdcollapse/internal/world" ) func (g *Game) doMap(sess *net.Session) { @@ -38,103 +35,3 @@ func (g *Game) doMap(sess *net.Session) { sess.WriteLine(line) } } - -func stripBlankRows(lines []string) []string { - var out []string - for _, line := range lines { - if strings.TrimSpace(line) != "" { - out = append(out, line) - } - } - return out -} - -func leftTrimCommon(lines []string) []string { - min := -1 - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - n := 0 - for _, r := range line { - if r == ' ' { - n++ - } else { - break - } - } - if min < 0 || n < min { - min = n - } - } - if min <= 0 { - return lines - } - result := make([]string, len(lines)) - for i, line := range lines { - if len(line) <= min { - result[i] = "" - } else { - result[i] = line[min:] - } - } - return result -} - -func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string { - mg := buildGraph(g, roomID) - - grid := make([][]rune, mapHeight) - for i := range grid { - grid[i] = make([]rune, mapWidth) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - cx := mapWidth / 2 - cy := mapHeight / 2 - - for pos, rid := range mg.posToRoom { - gr := cy + pos[1]*2 - gc := cx + pos[0]*2 - if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { - continue - } - if rid == roomID { - grid[gr][gc] = '@' - } else { - grid[gr][gc] = roomMapSymbol(g, rid) - } - } - - for pos, rid := range mg.posToRoom { - x, y := pos[0], pos[1] - - if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok { - if exitsConnect(g, rid, rightID, world.East, world.West) { - gr := cy + y*2 - gc := cx + x*2 + 1 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - grid[gr][gc] = '─' - } - } - } - - if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok { - if exitsConnect(g, rid, bottomID, world.South, world.North) { - gr := cy + y*2 + 1 - gc := cx + x*2 - if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { - grid[gr][gc] = '│' - } - } - } - } - - lines := make([]string, mapHeight) - for i := range grid { - lines[i] = string(grid[i]) - } - return lines -} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index d62701d..9ef7a00 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -62,7 +62,7 @@ func (g *Game) doMove(sess *net.Session, dir string) { g.World.SeedGroundItems(p.RoomID) g.seedRoomMobs(p.RoomID) - g.ensureRoomObjects(p.RoomID) + g.seedRoomObjects(p.RoomID) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(oldRoom) { @@ -103,7 +103,7 @@ func (g *Game) seedRoomMobs(roomID int) { g.MobStore.SeedMobs(roomID, room.Mobs) } -func (g *Game) ensureRoomObjects(roomID int) { +func (g *Game) seedRoomObjects(roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil { return @@ -124,8 +124,8 @@ func (g *Game) ensureRoomObjects(roomID int) { } if def.BehaviorID != "" { cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err == nil && cfg.SharedDeplete > 0 { - g.World.SetObjSharedDeplete(roomID, obj.ID, cfg.SharedDeplete) + if err == nil && cfg.DepleteTimer > 0 { + g.World.SetObjDepleteTimer(roomID, obj.ID, cfg.DepleteTimer) } } } diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 4554dfc..7c3515f 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -155,6 +155,6 @@ func (g *Game) doWearAll(sess *net.Session) { } else { g.AccountStore.SaveCharacter(p) sess.Write("You wear: ") - innerPickupReport(sess, equipped) + formatPickupList(sess, equipped) } } diff --git a/internal/game/doc.go b/internal/game/doc.go new file mode 100644 index 0000000..97814e9 --- /dev/null +++ b/internal/game/doc.go @@ -0,0 +1,29 @@ +// Package game is the core session handler, command dispatch, login flow, +// and action system for the MUD. +// +// The Game struct holds all world state: room/world access, item/object +// stores, mob instances, behavior definitions, the player hub, and the +// tick engine. HandleSession routes incoming input through the login +// state machine into the in-game command dispatch. +// +// Commands are dispatched via handleGameCommand in game.go, which +// resolves account aliases before switching on the command word. +// Most commands cancel the player's ongoing action (gathering, etc.). +// +// Action system: StartAction normalizes verbs, resolves object/mob +// targets, loads behaviors, and routes to type-specific handlers. +// Actions that take time (gather, use, burn, stoke) tick via +// AdvanceActions, called each 600ms tick. +// +// Tick handlers: DisconnectTick, RegenTick, WanderTick, WoodcuttingTick, +// and FireTick run each game tick to advance world simulation. +// +// File organization: +// cmd_*.go — player command handlers (doLook, doMove, doGet, etc.) +// action_*.go — action lifecycle (startGather, advanceBurn, talk, toggle, etc.) +// login.go — account creation, authentication, character management +// map.go — BFS graph builder, tiny + full-map rendering +// tick.go — per-tick world updates (disconnect, regen, wander, woodcutting) +// utils.go — shared types and helper functions +// help.go — help topic loading and display +package game diff --git a/internal/game/game.go b/internal/game/game.go index 21dee76..cecde2a 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -59,7 +59,7 @@ func (g *Game) SetHub(hub *net.Hub) { } func (g *Game) HandleSession(sess *net.Session, input string) { - input = player.StripControl(input) + input = player.StripControlCharacters(input) switch sess.State { case net.StateAccountName: g.handleAccountName(sess, input) diff --git a/internal/game/login.go b/internal/game/login_account.go index 3de70bc..7243955 100644 --- a/internal/game/login.go +++ b/internal/game/login_account.go @@ -268,91 +268,6 @@ func (g *Game) handleMenu(sess *net.Session, input string) { } } -func (g *Game) handleNewCharName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - - if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = "" - g.connectCharacter(sess, sess.Account.Characters[idx-1]) - return - } - sess.WriteLine("Invalid choice.") - sess.Write("\n> ") - return - } - - if name == "" { - sess.Write("Character name: ") - return - } - - if verr := player.ValidName(name); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("Character name: ") - return - } - - if g.AccountStore.CharacterExists(name) { - sess.WriteLine("A character with that name already exists.") - sess.Write("Character name: ") - return - } - - p := player.New(name) - p.RoomID = 1 - - if err := g.AccountStore.SaveCharacter(p); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - acc.Characters = append(acc.Characters, name) - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - g.connectCharacter(sess, name) -} - -func (g *Game) connectCharacter(sess *net.Session, name string) { - g.charsMu.Lock() - if existing := g.loggedInChars[name]; existing != nil { - g.charsMu.Unlock() - sess.WriteLine("This character is logged in elsewhere.") - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - g.charsMu.Unlock() - sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - sess.Player = p - sess.State = net.StateGame - g.loggedInChars[name] = sess - g.charsMu.Unlock() - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - g.ensureRoomObjects(p.RoomID) - - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - - g.doLook(sess) - sess.Write("\r\n> ") -} - func (g *Game) handleRenameAccount(sess *net.Session, input string) { newName := strings.TrimSpace(input) if newName == "" { @@ -389,147 +304,6 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) { g.showMenu(sess) } -func (g *Game) handleRenameChar(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - g.showMenu(sess) - return - } - - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) - sess.Write("New name: ") -} - -func (g *Game) handleRenameCharName(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - oldName := sess.PendingChar - if newName == "" { - sess.Write("New name: ") - return - } - if verr := player.ValidName(newName); verr != nil { - sess.WriteLine(verr.Error()) - sess.Write("New name: ") - return - } - if newName == oldName { - sess.WriteLine("That's already the character's name.") - g.showMenu(sess) - return - } - if g.AccountStore.CharacterExists(newName) { - sess.WriteLine("A character with that name already exists.") - sess.Write("New name: ") - return - } - - oldPath := g.AccountStore.CharPath(oldName) - newPath := g.AccountStore.CharPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) - g.showMenu(sess) - return - } - - p, _ := g.AccountStore.LoadCharacter(newName) - if p != nil { - p.Name = newName - g.AccountStore.SaveCharacter(p) - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - for i, c := range acc.Characters { - if c == oldName { - acc.Characters[i] = newName - break - } - } - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - sess.PendingChar = "" - sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) - g.showMenu(sess) -} - -func (g *Game) showDeleteConfirm(sess *net.Session) { - sess.State = net.StateDeleteChar - sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) -} - -func (g *Game) handleDeleteChar(sess *net.Session, input string) { - if sess.PendingChar == "" { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - g.showDeleteConfirm(sess) - return - } - - input = strings.TrimSpace(input) - expected := "DELETE " + sess.PendingChar - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Delete cancelled.") - sess.PendingChar = "" - g.showMenu(sess) - return - } - - charPath := g.AccountStore.CharPath(sess.PendingChar) - if err := os.Remove(charPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - var newChars []string - for _, c := range acc.Characters { - if c != sess.PendingChar { - newChars = append(newChars, c) - } - } - acc.Characters = newChars - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = newChars - - sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) -} - func (g *Game) handlePurgeAccount(sess *net.Session, input string) { input = strings.TrimSpace(input) expected := "PURGE " + sess.Account.Name diff --git a/internal/game/login_char.go b/internal/game/login_char.go new file mode 100644 index 0000000..ba034bb --- /dev/null +++ b/internal/game/login_char.go @@ -0,0 +1,236 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) handleNewCharName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + + if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = "" + g.connectCharacter(sess, sess.Account.Characters[idx-1]) + return + } + sess.WriteLine("Invalid choice.") + sess.Write("\n> ") + return + } + + if name == "" { + sess.Write("Character name: ") + return + } + + if verr := player.ValidName(name); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("Character name: ") + return + } + + if g.AccountStore.CharacterExists(name) { + sess.WriteLine("A character with that name already exists.") + sess.Write("Character name: ") + return + } + + p := player.New(name) + p.RoomID = 1 + + if err := g.AccountStore.SaveCharacter(p); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc.Characters = append(acc.Characters, name) + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + g.connectCharacter(sess, name) +} + +func (g *Game) connectCharacter(sess *net.Session, name string) { + g.charsMu.Lock() + if existing := g.loggedInChars[name]; existing != nil { + g.charsMu.Unlock() + sess.WriteLine("This character is logged in elsewhere.") + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + g.charsMu.Unlock() + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + sess.Player = p + sess.State = net.StateGame + g.loggedInChars[name] = sess + g.charsMu.Unlock() + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.seedRoomObjects(p.RoomID) + + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.doLook(sess) + sess.Write("\r\n> ") +} + +func (g *Game) handleRenameChar(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + g.showMenu(sess) + return + } + + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) + sess.Write("New name: ") +} + +func (g *Game) handleRenameCharName(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + oldName := sess.PendingChar + if newName == "" { + sess.Write("New name: ") + return + } + if verr := player.ValidName(newName); verr != nil { + sess.WriteLine(verr.Error()) + sess.Write("New name: ") + return + } + if newName == oldName { + sess.WriteLine("That's already the character's name.") + g.showMenu(sess) + return + } + if g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") + return + } + + oldPath := g.AccountStore.CharPath(oldName) + newPath := g.AccountStore.CharPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } + + p, _ := g.AccountStore.LoadCharacter(newName) + if p != nil { + p.Name = newName + g.AccountStore.SaveCharacter(p) + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + for i, c := range acc.Characters { + if c == oldName { + acc.Characters[i] = newName + break + } + } + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + sess.PendingChar = "" + sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + g.showMenu(sess) +} + +func (g *Game) showDeleteConfirm(sess *net.Session) { + sess.State = net.StateDeleteChar + sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) +} + +func (g *Game) handleDeleteChar(sess *net.Session, input string) { + if sess.PendingChar == "" { + name := strings.TrimSpace(input) + if idx, err := parseChoiceIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + g.showDeleteConfirm(sess) + return + } + + input = strings.TrimSpace(input) + expected := "DELETE " + sess.PendingChar + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Delete cancelled.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + + charPath := g.AccountStore.CharPath(sess.PendingChar) + if err := os.Remove(charPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + var newChars []string + for _, c := range acc.Characters { + if c != sess.PendingChar { + newChars = append(newChars, c) + } + } + acc.Characters = newChars + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = newChars + + sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) +} diff --git a/internal/game/map.go b/internal/game/map.go index cd1012b..e7738bd 100644 --- a/internal/game/map.go +++ b/internal/game/map.go @@ -1,6 +1,8 @@ package game import ( + "strings" + "thirdcollapse/internal/world" ) @@ -189,3 +191,103 @@ func roomMapSymbol(g *Game, roomID int) rune { } return 'o' } + +func stripBlankRows(lines []string) []string { + var out []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return out +} + +func leftTrimCommon(lines []string) []string { + min := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + n := 0 + for _, r := range line { + if r == ' ' { + n++ + } else { + break + } + } + if min < 0 || n < min { + min = n + } + } + if min <= 0 { + return lines + } + result := make([]string, len(lines)) + for i, line := range lines { + if len(line) <= min { + result[i] = "" + } else { + result[i] = line[min:] + } + } + return result +} + +func buildFullMap(g *Game, roomID, mapWidth, mapHeight int) []string { + mg := buildGraph(g, roomID) + + grid := make([][]rune, mapHeight) + for i := range grid { + grid[i] = make([]rune, mapWidth) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + cx := mapWidth / 2 + cy := mapHeight / 2 + + for pos, rid := range mg.posToRoom { + gr := cy + pos[1]*2 + gc := cx + pos[0]*2 + if gr < 0 || gr >= mapHeight || gc < 0 || gc >= mapWidth { + continue + } + if rid == roomID { + grid[gr][gc] = '@' + } else { + grid[gr][gc] = roomMapSymbol(g, rid) + } + } + + for pos, rid := range mg.posToRoom { + x, y := pos[0], pos[1] + + if rightID, ok := mg.posToRoom[[2]int{x + 1, y}]; ok { + if exitsConnect(g, rid, rightID, world.East, world.West) { + gr := cy + y*2 + gc := cx + x*2 + 1 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = '─' + } + } + } + + if bottomID, ok := mg.posToRoom[[2]int{x, y + 1}]; ok { + if exitsConnect(g, rid, bottomID, world.South, world.North) { + gr := cy + y*2 + 1 + gc := cx + x*2 + if gr >= 0 && gr < mapHeight && gc >= 0 && gc < mapWidth { + grid[gr][gc] = '│' + } + } + } + } + + lines := make([]string, mapHeight) + for i := range grid { + lines[i] = string(grid[i]) + } + return lines +} diff --git a/internal/game/types.go b/internal/game/types.go new file mode 100644 index 0000000..faed9e1 --- /dev/null +++ b/internal/game/types.go @@ -0,0 +1,29 @@ +package game + +import "thirdcollapse/internal/object" + +var EquipSlots = []object.EquipSlot{ + object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, + object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, + object.SlotMainHand, object.SlotOffHand, object.SlotRing, +} + +type itemMatch struct { + ID string + Name string + Slot int +} + +type xpGain struct { + Skill string + XP int +} + +type deathDrop struct { + itemID string + quantity int + totalVal int + isEquip bool + equipSlot object.EquipSlot + invSlot int +} diff --git a/internal/game/utils.go b/internal/game/utils.go index 969eba4..e80de24 100644 --- a/internal/game/utils.go +++ b/internal/game/utils.go @@ -7,37 +7,10 @@ import ( "strings" "thirdcollapse/internal/net" - "thirdcollapse/internal/object" "thirdcollapse/internal/player" "thirdcollapse/internal/world" ) -var EquipSlots = []object.EquipSlot{ - object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, - object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, - object.SlotMainHand, object.SlotOffHand, object.SlotRing, -} - -type itemMatch struct { - ID string - Name string - Slot int -} - -type xpGain struct { - Skill string - XP int -} - -type deathDrop struct { - itemID string - quantity int - totalVal int - isEquip bool - equipSlot object.EquipSlot - invSlot int -} - func plural(n int) string { if n == 1 { return "" @@ -55,7 +28,7 @@ func parseQty(input string) (int, string) { return 0, input } -func parseIndex(s string) (int, error) { +func parseChoiceIndex(s string) (int, error) { var idx int _, err := fmt.Sscanf(s, "%d", &idx) return idx, err @@ -94,7 +67,7 @@ func randInt(max int) int { return rand.Intn(max) } -func innerPickupReport(sess *net.Session, picked []string) { +func formatPickupList(sess *net.Session, picked []string) { for i, name := range picked { if i == len(picked)-1 { sess.WriteLine(name) @@ -106,7 +79,7 @@ func innerPickupReport(sess *net.Session, picked []string) { } } -func actionDesc(p *player.Player) string { +func playerActionDescription(p *player.Player) string { if p.Action == nil { return "" } @@ -129,7 +102,7 @@ func actionDesc(p *player.Player) string { return "" } -func (g *Game) newInvSlot(itemID string, qty int) *player.InventorySlot { +func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { slot := &player.InventorySlot{ItemID: itemID, Quantity: qty} if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 { slot.Quality = def.Quality diff --git a/internal/net/doc.go b/internal/net/doc.go new file mode 100644 index 0000000..13e7f71 --- /dev/null +++ b/internal/net/doc.go @@ -0,0 +1,15 @@ +// Package net provides transport-agnostic network connections, session +// management, and a multi-listener server. +// +// The Conn interface abstracts TCP (telnet) and WebSocket connections, +// handling IAC echo control for password suppression. The Session type +// tracks connection state through the login flow (account name, password, +// menu navigation, character selection, and in-game play). +// +// The Hub tracks which sessions are in which rooms, enabling room-scoped +// messaging. The Server manages telnet, HTTP, and HTTPS listeners and +// dispatches incoming input to the game handler. +// +// Session I/O helpers (Write, WriteLine, WriteLines) handle CRLF line +// endings required by telnet clients. +package net diff --git a/internal/object/doc.go b/internal/object/doc.go new file mode 100644 index 0000000..89f6db9 --- /dev/null +++ b/internal/object/doc.go @@ -0,0 +1,14 @@ +// Package object defines item and object definitions with their YAML-backed +// loading stores. +// +// ItemDef covers all game items: equipment stats, weapon types, tool +// properties (tool_type, tool_speed for gathering/firemaking), firemaking +// fields (burn_ticks, fire_level, fire_xp), and stackable/quality metadata. +// +// ObjectDef covers interactive world objects: their behavior reference, +// hidden flag for unlisted-but-interactable objects, in-room description +// overrides, removal items, and arbitrary props. +// +// ItemStore and ObjectStore load definitions from YAML files in the +// data/items/ and data/objects/ directories, caching results in memory. +package object diff --git a/internal/object/item.go b/internal/object/item.go index 009f10e..18fd034 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -1,6 +1,10 @@ package object -import "strings" +import ( + "strings" + + "thirdcollapse/internal/world" +) type EquipSlot string @@ -67,34 +71,13 @@ func (d *ItemDef) MatchesName(input string) bool { return true } } - if wordPrefixMatch(lower, d.Name) { + if world.WordPrefixMatch(lower, d.Name) { return true } for _, alias := range d.Aliases { - if wordPrefixMatch(lower, alias) { + if world.WordPrefixMatch(lower, alias) { return true } } return false } - -func wordPrefixMatch(input, name string) bool { - inputWords := strings.Fields(input) - if len(inputWords) == 0 { - return false - } - nameWords := strings.Fields(strings.ToLower(name)) - for _, iw := range inputWords { - found := false - for _, nw := range nameWords { - if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { - found = true - break - } - } - if !found { - return false - } - } - return true -} diff --git a/internal/player/doc.go b/internal/player/doc.go new file mode 100644 index 0000000..a163bd7 --- /dev/null +++ b/internal/player/doc.go @@ -0,0 +1,14 @@ +// Package player defines character data, skills, equipment, inventory, +// and account management for the game. +// +// The Player struct is the full character sheet: 23 skills with RSC-style +// XP progression, 28-slot inventory, 11-slot equipment, combat level +// calculation, and death mechanics. +// +// Accounts store authentication (salted sha256 passwords), character lists, +// and command aliases. Validation functions enforce name and password rules. +// The AccountStore manages persistence to YAML files under data/players/. +// +// Player options (15 settings) support bool, string, and int types with +// defined defaults and valid values. +package player diff --git a/internal/player/validate.go b/internal/player/validate.go index 63f40d2..d836089 100644 --- a/internal/player/validate.go +++ b/internal/player/validate.go @@ -22,7 +22,7 @@ func ValidPassword(pw string) error { return nil } -func StripControl(input string) string { +func StripControlCharacters(input string) string { runes := make([]rune, 0, len(input)) for _, r := range input { if r == '\n' || r == '\t' { diff --git a/internal/world/doc.go b/internal/world/doc.go new file mode 100644 index 0000000..cc11535 --- /dev/null +++ b/internal/world/doc.go @@ -0,0 +1,18 @@ +// Package world manages rooms, exits, ground items, mob instances, +// and object instance state for the game world. +// +// Rooms are loaded from YAML files on every access (live-editable). Exits +// support conditional blocking via the action.Condition system. Ground +// items have independent despawn timers and combat-loot reservations. +// +// Object instance state (ObjState) tracks per-instance properties: +// depletion with regen timers, shared depletion for trees with countdown, +// quality for fire objects, and wandering for fishing spots. +// +// MobDef and MobInstance cover mob definitions and runtime instances. +// The MobStore manages YAML loading, instance seeding, and per-room +// mob lists. Mobs wander via legal (unconditioned) room exits. +// +// WordPrefixMatch provides bidirectional prefix matching used by +// commands and object lookups. +package world diff --git a/internal/world/world.go b/internal/world/world.go index e2d12ba..ac5345e 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -192,7 +192,7 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState { defer w.mu.Unlock() var out []ObjState lower := strings.ToLower(name) - for key, st := range w.objStates { + for _, st := range w.objStates { if st.RoomID != roomID { continue } @@ -210,7 +210,6 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState { SharedTimer: st.SharedTimer, Quality: st.Quality, }) - _ = key } sort.Slice(out, func(i, j int) bool { if out[i].DefID != out[j].DefID { @@ -225,14 +224,11 @@ func wordMatchesObj(lower, defID, objName string) bool { if WordPrefixMatch(lower, objName) { return true } - nameWords := strings.Fields(strings.ToLower(objName)) inputWords := strings.Fields(strings.ToLower(lower)) - // Check defID as whole (bidirectional) if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) { return true } - // Check each defID part (split by underscore) against each input word (bidirectional) for _, part := range strings.Split(defID, "_") { for _, iw := range inputWords { pl := strings.ToLower(part) @@ -241,42 +237,38 @@ func wordMatchesObj(lower, defID, objName string) bool { } } } - _ = nameWords return false } func (w *World) SetObjName(roomID int, defID string, name string) { w.mu.Lock() defer w.mu.Unlock() - for key, st := range w.objStates { + for _, st := range w.objStates { if st.RoomID == roomID && st.DefID == defID { st.Name = name } - _ = key } } func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int) { w.mu.Lock() defer w.mu.Unlock() - for key, st := range w.objStates { + for _, st := range w.objStates { if st.RoomID == roomID && st.DefID == defID { st.WanderRooms = rooms st.WanderInterval = interval } - _ = key } } -func (w *World) SetObjSharedDeplete(roomID int, defID string, max int) { +func (w *World) SetObjDepleteTimer(roomID int, defID string, max int) { w.mu.Lock() defer w.mu.Unlock() - for key, st := range w.objStates { + for _, st := range w.objStates { if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 { st.SharedMax = max st.SharedTimer = max } - _ = key } } |
