1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
|
# AGENTS.md
Third Collapse — a Runescape-like sci-fi MUD written in Go. Data-driven via YAML files. No database, no ORM.
See `TODO.md` for the full project overview, implemented features, and roadmap.
## Build & Run
```bash
make build # go build -o tc ./cmd/mud
make run # build + run with DATA_DIR=./data
make test # go test ./...
make vet # go vet ./...
```
Binary accepts `--config <path>` (defaults to `config.yaml`). If no config found, starts telnet on :4000.
Config options:
```yaml
game:
tick_length: 600 # ms per game tick (default 600, min 50)
game_speed: 1.0 # multiplier for all tick timers (default 1.0)
```
Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`.
## Package Map
```
cmd/mud/main.go Entry point — config loading, multi-listener, tick engine
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. 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) |
| `action_state.go` | ActionState type, ActionType consts, Description for look display |
| `tick.go` | DisconnectTick, RegenTick, WanderTick, SharedDepletionTick |
| `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
**YAML-driven.** Rooms, items, objects, mobs, behaviors, drops are all YAML files under `data/`. Read from disk on every access — edit a room description and it takes effect immediately, no restart.
**Live state.** Player data (characters, accounts) is written to YAML on every state change. Ground items, mob instances, and object states live in memory only and are lost on restart.
**No database.** Everything is flat YAML files. Account passwords use sha256 + 16-byte random salt.
**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 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.
**Fractional ticks.** All YAML timer fields (tool_speed, speed, base_wait, deplete_timer, etc.) accept `float64` values. `engine.FractionalTicks(base, speed)` probabilistically rounds to an integer — a tool_speed of 4.5 means each action cycle has a 50% chance of 4 ticks and 50% of 5 ticks. The `game_speed` config option scales all timers uniformly via `g.computeTicks()`.
## Two State Systems
- **World flags** (`set_flags` / checked with `flag`): Shared by all players. A door opened by one player is open for everyone.
- **Player flags** (`set_player_flags` / checked with `player_flag`): Per-character, saved to character YAML. Quest progress, dialog history.
## Command Dispatch
`internal/game/game.go` — `handleGameCommand()`:
1. Cancel rest timer
2. Resolve account aliases (alias expansion happens BEFORE command lookup)
3. Switch on command word
4. Most commands call into `cmd_*.go` files
Actions that interact with objects/mobs route through `StartAction()` in `action.go`, which:
1. Normalizes verb (mine/chop/fish/cut → gather, talk/speak/ask → talk, pull/push → toggle)
2. Checks for ambiguous matches (multiple object types)
3. Searches object instances in room
4. Falls back to mob instances if no object matches
5. Loads the behavior from YAML
6. Routes to type-specific handler (startGather, startTalk, startUse, startToggle)
## Adding a New Skill
1. Add `SkillName` constant + entry in `AllSkills` + `SkillAbbr` in `internal/player/player.go`
2. Create a gather behavior YAML in `data/behaviors/`
3. Create an object YAML referencing that behavior in `data/objects/`
4. Create an item for the tool in `data/items/` (with `tool_type`)
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 `deplete_timer` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion.
## Adding a New Production Skill
Same as gathering but use `type: use` behavior (see `UseConfig` in `internal/action/behavior.go`). Smithing is the reference example planned.
## Adding a New Command
1. Add a `cmd_*.go` file in `internal/game/` with the handler
2. Add a case in `handleGameCommand()` in `internal/game/game.go`
3. Update `data/help/` YAML files
## Firemaking (`burn` / `stoke`)
Firemaking is implemented as standalone actions (not behavior-YAML-driven) in `internal/game/action_burn.go`. The flow:
| Command | Action | Description |
|---------|--------|-------------|
| `burn [item]` | `"burn"` | Start a fire from logs. Checks ground items first, then inventory. Requires a `tool_type: fire` item. |
| `stoke [item]` | `"stoke"` | Add logs from inventory to an existing fire object for XP. No tool required. |
**Burn flow (3 phases):**
- Phase 0 (inventory only): Drops 1 log to ground, outputs "You drop the X and begin to make a fire..."
- Phase 1: "You try burning the X on the ground..." (waits tool_speed ticks)
- Phase 2: Skill check (firemaking level vs log's `fire_level`). On fail, outputs "You can't seem to get a fire going" and retries at Phase 1. On success, creates a `fire` object (`quality` = log's `burn_ticks`), gives XP, broadcasts to room.
**Stoke flow:**
- One-time "You begin to tend to the fire."
- Loops: waits 10 ticks → "You throw X onto the fire", gives XP, adds `burn_ticks/2` to fire `quality`
- Ends when out of the item: "You're out of X and the fire is roaring."
**Fire objects:**
- Created dynamically via `World.AddObjInstance()`, tracked by `ObjState.Quality`.
- Each tick, `FireTick()` decrements quality; at 0, `RemoveObjInstance()` cleans up.
- `inroom_description` on object YAML replaces the generic "A X is here."
- `props.quality_description` (with `{quality}` placeholder) is used by `look <fire>`.
**Item fields for firemaking:**
- `burn_ticks: <int>` — how long the log burns (fire quality). Only items with `burn_ticks > 0` are burnable.
- `fire_level: <int>` — required firemaking level to burn.
- `fire_xp: <int>` — XP awarded.
- `quality: <int>` / `max_quality: <int>` — for tools like lighter (start value / max value).
- `EquipQuality map[string]int` on Player tracks quality of equipped items with quality.
**Fire tools:** `matches` (stackable, consumed on use), `firesteel` (non-consumable), `lighter` (quality-based, `MaxQuality: 100`, decreases per use).
**Smart defaults:** `burn` auto-selects if only one burnable type on ground OR in inventory. `stoke` auto-selects if only one burnable type in inventory.
**Level-up messages:** All skills that gain XP (gathering, combat, use, firemaking) output `*** You are now level N <skill>! ***` when the player levels up.
**Stoke error messages:** "There's no fire here to stoke" (no fire), "You have no logs to stoke with!" (no burnable items), "Stoke what?" (ambiguous). Fire going out mid-stoke interrupts immediately with "The fire went out!".
## Two Depletion Mechanics
**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 `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. If multiple players succeed on the depletion tick, all get their drops. The timer regenerates back to max when no one is chopping. `SharedDepletionTick` manages this in `internal/game/tick.go`.
## Data Files
```
data/rooms/<id>.yaml Room definitions (exits, objects, mobs, spawns, on_enter)
data/items/<id>.yaml Item definitions (stats, equip slot, tool type, speed)
data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior — NO wander config)
data/objects/<id>.yaml Object definitions (name, behavior, hidden)
data/behaviors/<id>.yaml Behaviors (gather, talk, use, toggle configs)
data/drops/<id>.yaml Shared drop tables (weighted item lists)
data/help/<id>.yaml Help topics
data/players/accounts/ Account YAML (gitignored)
data/players/characters/ Character YAML (gitignored)
```
Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their list.
See `WORLDBUILDING.md` for full YAML format reference with examples.
## Tick-Based Action Queue
All gameplay commands are queued for the next 600ms game tick instead of executing immediately. Commands are classified into three types:
| Class | Behavior | Commands |
|---|---|---|
| Instant | Execute immediately, no queue | `say`, `score`, `inventory`, `equipment`, `look`, `exits`, `help`, `map`, `option`, `alias`, `unalias`, `description`, `queued` |
| Free | Stackable, execute in order before active | `wear`/`wield`, `remove`/`unwear`, `style` |
| Active | Only the last active per player survives | Everything else: move, attack, mine/chop/fish/cut, use, talk, burn, stoke, get, drop, search, quit |
Each 600ms tick, `ProcessQueuedCommands` in `internal/game/game.go`:
1. Clears stale ActionState from one-tick actions (move, get, drop, etc.)
2. Executes all free commands per player in queued order
3. Executes all active commands sorted globally by real-time timestamp
4. Flushes any pending shared resource depletions
**Conflict resolution:**
- Multi-player attack on same mob: first player to queue wins (timestamp-order).
- Multi-player get on same item: first player to queue wins.
- Multi-player shared depletion: defer depletion, award all successful gatherers.
**ActionState** (`internal/game/action_state.go`) tracks what a player is doing for `look` display. Timed actions (gather, combat, burn, stoke, use) persist the state until interrupted. One-tick actions (move, get, drop) clear after one tick.
Queue feedback is controlled by the `queue_silently` player option (default on).
## Places to Be Careful
- `StartAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable.
- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern used for `RoomMob` (supports both `"man"` and `{id: man, ...}`).
- `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and toggle checks.
- Object `Hidden: true` means the object doesn't appear in room listings but is still interactable.
- Alias expansion happens before command dispatch. An alias can shadow a built-in command.
- `say` preserves case (alias expansion preserves case, and the dispatch extracts the original input text for say).
- `get`, `drop`, and `quit` cancel the player's active action (gathering, use, etc.).
- `findGroundMatches` and `findInventoryMatches` support bidirectional prefix matching — `"iron ax"` matches `"iron axe"`.
- Mob wander config is per-instance in room YAML via `RoomMob`, not on `MobDef`. Mobs without `wander_interval` in the room stay still.
|