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
|
# AGENTS.md
The House of Icarus — a sci-fi MUD in Go. YAML-driven, no database. "Technology" replaces Prayer, "Science" replaces Magic, Scavenging replaces Runecrafting.
## Build & Run
```
make build # go build -o thoi ./cmd/thoi
make run # build + run with DATA_DIR=./data
make test # DATA_DIR=$(PWD)/data go test ./... -timeout 60s
make vet # go vet ./...
```
Binary accepts `--config <path>` (defaults to `config.yaml`). Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`.
## Prefixes
File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `core_*` (infra), `act_*` (actions), `cmd_*` (commands), `sys_*` (subsystems), `render_*` (output), `production_*` (crafting engine), `combat_*` (hits), `look_*` (display).
## Key Conventions
**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only. **Local objects:** a room `objects:` entry that carries content fields (`name`/`description`/`aliases`/`inroom_description`/`color`/`hidden`/`on_look`) is a fully local, room-scoped object def instead of a reference — passive subset only (no gather/talk/use/safespot/steal/guard_mob/use_interactions). Local identity derives from the normalized `name` (lowercased, whitespace-collapsed, spaces kept); `id:` is reserved for references and ignored+warned on local objects. Each object in a room must have a unique name (startup error on collision, incl. local-name vs referenced file id); names may repeat across different rooms.
**Live state.** Player data persisted to YAML on every change. Ground items, mob instances, object states are in-memory only.
**No database.** Everything flat YAML. Passwords use sha256 + 16-byte random salt.
**Transport-agnostic.** `Conn` interface (`internal/net/conn.go`) — telnet and websocket. Game code works with `*Session`.
**Tick-driven.** 600ms default (min 50ms). All game advancement per tick. YAML timer fields accept `float64`; `engine.ToTicks(base)` probabilistically rounds.
**Concurrency.** Every state package uses its own `sync.Mutex`. Pattern: lock → snapshot/unmarshal → unlock → process. Engine creates subscriber snapshot each tick.
## Two State Systems
- **Global flags** (`set_global_flags` / `global_flag`): Shared by all players.
- **Player flags** (`set_player_flags` / `player_flag`): Per-character, saved to YAML.
## Room Scripting
- **`on_enter` steps**: message + optional condition. A step may have `delay` and/or `set_global_flags`/`set_player_flags`. Timed steps run as scheduled enter sequences; otherwise messages print synchronously. Conditions snapshotted once at entry. Resumes on reconnect if interrupted.
- **Conditional descriptions** (`description: [{text, condition}]`): first passing variant wins. For objects, if no variant matches the object is absent for that player. Accepts plain string or list.
- **Exits** can `set_global_flags`/`set_player_flags` on successful traverse.
## Data Files
```
data/rooms/<id>.yaml data/items/<id>.yaml data/mobs/<id>.yaml
data/objects/<id>.yaml data/objects/unique/ data/drops/<id>.yaml
data/hazards/<id>.yaml data/help/<id>.yaml data/modules/<id>.yaml
data/techs/<id>.yaml data/courses/<id>.yaml data/players/accounts|characters/
```
Wander config per-instance in room YAML, not on MobDef. See `building_guide/` for full YAML format reference.
## Map & Colors
**Color specs are hex** (`00`–`FF`, case-insensitive) — the notation for the xterm-256 palette index (internally still `0–255`; only the string form is hex). Applies everywhere `color.Parse` runs: `color:` fields (rooms/items/objects), the `color`/`symbol` commands, `bg:`/`g:` tokens, and inline `{spec}text{/}` tags. Raw `ColorSpec{Fg:int}` literals stay decimal indices. `color` command targets live in `colorCategoryOrder` (`cmd_color.go`) + `config.DefaultColors()`; `map_at` (the `@`) and `map_blocked` (the blocked `X`) are themeable.
**Map rendering** (`render_map.go`): BFS lays rooms on a 2D grid (NSEW move, up/down don't). Rooms carry an optional `color:` field (player `symbol` color overrides it). Links between two grid-adjacent rooms render per-direction: two-way → bar (`-`/`│`), one-way → arrow (`← ↑ → ↓` / `< ^ > v` pointing along travel), no traversable direction → blocked `X` (`map_blocked`). Bars/arrows blend the two endpoints' colors (`color.Average`); only `X` uses the blocked color. A player's custom symbol also shows in dim brackets on the look title.
## Startup Validation
Comprehensive integrity checks on all data files (errors logged, server still starts). Duplicate ID checks across all directories. Referential integrity: exits→rooms, mobs/objects/spawns→defs, drops/steal→items/tables, craft blocks→items/stations/tools, etc. Configurable orphan-room reachability check (`startup_validation.root_rooms`). Grid-overlap check: starting from `root_rooms`, lays each horizontal plane (NSEW only; up/down seed new planes) on a 2D grid and errors when two rooms collide on one cell (overlap) or a room maps to two cells (twist).
## Command Dispatch
`handleGameCommand()` in `game.go`: expands account aliases → split input → `classifyCommand()` → `ClassInstant` (executes now + prompt), `ClassFree` (next tick queue), `ClassActive` (sorted timestamp queue), `ClassUnknown` (verbAliases/obstacleVerbs, else error).
## Tick-Based Action Queue
`ProcessQueuedCommands()`: clear stale one-tick actions → execute free commands → execute active commands (sorted, first-queued wins conflicts) → advance walks → flush shared depletions. `ActionState` tracks player activity for `look`. Queue feedback shown by `show_queued_cmds`.
## Action System
Actions route through `startAction()` in `act.go`: normalize verb → resolve target (objects then mobs) → load behavior → route to handler.
**Behavior types (YAML-driven):** `gather` (mine/chop/cut/fish), `use` (crafting stations), `talk` (NPC dialog). **Hardcoded actions:** burn/stoke, search, production (cook/smelt/smith/craft/mix/fletch/clean), steal, agility, farm, identify, finishing blow. Gather behaviors use SuccessChance = Base + (level-required)*PerLevel, clamped `[0, Cap]`. Action types and all YAML config fields are self-documenting in the source (`act_state.go`, `behavior.go`, `item/item.go`).
### Condition System
Used by exits, talk options, on-enter, use_interactions: `global_flag` (global), `player_flag` (per-char), `value` (exact match), `has_item`, `min_credits`, `all_of`, `any_of`, `not`. Bare global_flag/player_flag passes on truthy.
## Two Depletion Mechanics
- **Per-drop** (`depletes: true`): depletes on first successful gather of that drop.
- **Shared** (`deplete_timer: <ticks>`): timer counts down while anyone gathers; at 0, next gather depletes for all. Regenerates when idle.
## Ground Item System
`DropDespawnTicks = 1000`. `ReserveTicks = 100` (mob loot reserved for killer). Same-ID items NOT merged. Spawn items respawn with merge. Despawn/reserve visible via options.
## Drop Resolution (`behavior/store.go`)
Drop tables are skill-independent weighted lists of items. `ResolveDrop` does one weighted pick; if the picked entry is a `table:` ref, its `quantity` yields that many copies of a single sub-roll (used by gather/search/steal). `ResolveDropList` (mob loot only, `combat_mob.go`) instead treats a picked table entry's `quantity` as the number of **independent rolls** against the sub-table (each may differ), returning one entry per roll; a plain item entry yields one result. Mob loot is still a single weighted pick per kill — a table entry just expands into N sub-rolls.
## Shop System (`cmd_shop.go`)
Root-level `shop:` on a MobDef (`behavior.ShopConfig`) — NOT part of the talk tree. Traded via plain `StateGame` commands: `list`, `buy <item>`, `sell <item>`, with `list <mob>` / `buy <item> from <mob>` / `sell <item> to <mob>` to disambiguate when a room has multiple shop mobs (else "Which shop?"). No bespoke shop CLI/session state. **Prices derive from item `value:`** — shops sell at 100% of value; buy back via OSRS formula `value×max(10, buy_percentage−stock×change_percentage)/100` (defaults `buy_percentage: 40`, `change_percentage: 3`, floor 10%). Per-item `stock` (target+starting); buying depletes (out of stock at 0), selling raises it (lowering next sell price). Restock moves current stock toward target one step per item `restock_ticks` (default `behavior.ShopDefaultRestock`, set from `game_constants.shop_default_restock` in config.yaml = 1000) in **both** directions; dynamic (unlisted) items decay to 0. `buys_anything` (default true) lets specialty shops refuse unlisted items. **Live stock is per-MobInstance, in-memory only** (`ShopStock`/`ShopRestock` maps), mutated only under `MobStore.mu` via `ShopTake`/`ShopAdd`/`ShopStockOf`; restock runs in `MobStore.Tick()`.
## Combat System
`attack_type` (stab/slash/crush/ranged/science) selects attack/defense bonuses. **Weapons** carry a single `attack_type` string. **Mobs** carry an `attack_type` list (`[]string`, validated): exactly one melee type (stab/slash/crush) plus optionally `ranged`/`science`. A mob uses its melee type in normal combat; when a player is safespotted from melee (`safespotBlocksMelee`), the mob switches to its strongest ranged/science type (`mobStrongestRangedScience`, by max hit) via `effectiveMobAttackType`. A melee-only mob whose melee is blocked is fully blocked (`safespotBlocksMob`). Tech protection (`applyTechProtection`) uses the *resolved* attack type of the landed hit. Ranged consumes 1 ammo/attack. Aggressive mobs auto-attack if `playerLevel <= mobLevel * 2`. Movement during combat = flee attempt (mob hits abort). Death: drop credits + keep 3 most valuable items, respawn at room 1, deactivate techs. Equipment `requirements` checked before equipping. Formulas and XP splits in `combat/tracker.go`.
## Labor System (`sys_labor.go`, `sys_hazard.go`)
Non-violent reuse of combat engine for worksites. Task mobs (`kind: task`): HP drains to 0 to complete work (progress bar fills). Never attacks back. Commands `work`/`attack`/`kill` route to `doAttack`. Per-type defenses = method efficiency.
**Hazards:** Room references shared hazard by ID. `HazardTick()` rolls against everyone in room every `speed` ticks. `required_item` negates. Safespot blocks all hazard damage. Lethal hit calls `killPlayer`. Does NOT interrupt movement. Safe→dangerous prompts `[Y/n]` unless `danger_warning` off.
## Technology System (`tech.go`)
Togglable buffs that drain battery. 4 categories: Attack/Strength/etc. tiers (5/10/15% boost), Protection techs (40% reduction), Utility (regen/drain/retribution), Combo. Reserved IDs wired to code: `protect_melee`, `protect_ranged`, `protect_science`, `retribution`. Battery = Technology level. Each tech has drain rate. Equipment TechnologyBonus reduces drain (cap 50%). ActiveTechs resets on reconnect.
## Science System (`science.go`)
Mods from `data/modules/`: combat/utility/transport/enchant/processing. `trigger <mod>` fires once. `autotrigger <mod>` auto-fires each round when deck equipped. Deck combat uses weapon speed; mod fires instead if autotrigger set. No deck = manual queuing. View with `mods`/`modules`/`module`.
## Hacking System
Minigame interface: 3 games at terminals (levels 1/20/40). Interface in `game/hacking/`.
## Skills (23 total, 1-99, classic XP table)
| Category | Skills |
| ---------- | --------------------------------------------------------------------------- |
| Combat | Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin |
| Gathering | Fishing, Woodcutting, Mining, Hacking, Scavenging, Thieving |
| Production | Cooking, Smithing, Crafting, Fletching, Pharmacy, Construction, Farming |
| Utility | Agility, Firemaking |
## Session States
States in `net/server.go` covering login flow, game, NPC talk, menus (bank/recipe/confirm), and hacking. (Shops are command-driven in `StateGame` — no dedicated shop state.)
## ItemDef Fields
Full field reference in `item/item.go`. Key fields: equip_slot, weapon_type, attack_type, stats, speed, requirements, tool_type, craft block, farming, potion, search_table.
## CraftIndex
`CraftIndex` built once at startup: O(1) lookups via `ByType`, `ByStation`, `ByInput`, `FindByTwoInputs`. No cache invalidation — static after startup.
## Places to Be Careful
- `startAction` searches objects first, then mobs. Mobs need `behavior:` on YAML def to be interactable.
- Exits: old `north: 2` still works via custom `UnmarshalYAML`. Same for `RoomMob`.
- `checkCondition` is the single condition evaluator — shared across exits, talk, on-enter, use_interactions.
- Object `hidden: true` = not in room listings but still interactable.
- Alias expansion happens before command dispatch and can shadow built-ins.
- `say` preserves case from raw input.
- `get`, `drop`, `quit` cancel active action.
- `findGroundMatches`/`findInventoryMatches` support bidirectional prefix matching.
- Mob wander config is per-instance in room YAML, not on MobDef.
- Walk distance capped by agility level.
- `wear all` equips highest-value per slot; `remove all` unequips everything.
- Cape of Agility: instant movement (1 tick). Graceful pieces reduce by 0.25 each; full set bonus. Cape overrides Graceful.
- During combat, movement = flee attempt. Mob hits abort flee.
- Task mobs (`kind: task`) reuse combat engine 1:1; HP drains to 0 to complete. Never attack back — only room hazard provides danger.
- Hazard damage omits flee-abort — never interrupts walking. Mob hits still do.
- Safespot blocks ALL hazard damage, but melee work/attack forces you out of cover.
- `killPlayer` is the shared death path — used by combat and lethal hazards. `mob` may be nil.
- Science (deck) attacks use `combat.EffectiveRoll` for both rolls. `techLevelBonus(p, "science")` included in attack roll (Neural Focus).
## Adding a New Command
1. Add `cmd_<name>.go` in `internal/game/` with `func (g *Game) executeXxx(sess *net.Session, args []string, rawInput string)`
2. Register in `commandRegistry` in `cmd_registry.go`
3. Add help YAML in `data/help/`
## Known Gaps
- **Tick subscriber ordering**: `AdvanceActions`, combat subscriptions, mob attack subscriptions,
and hazard ticks are independent subscribers without priority guarantees. A trigger completing
and a mob landing a hit on the same tick have non-deterministic ordering.
- **3D map BFS cost**: `buildGraph()` does a full 3D BFS across the entire reachable world on every
map render (`map`/`look`/move). For large worlds (10k+ rooms) this could become expensive. Options:
(a) cache the graph keyed by player room and invalidate on world edits (`dig`/`room insert`/`undig`),
(b) prune by visited rooms (revert to horizontal-only expansion from unvisited), or (c) compute z-level
assignments once at startup and use precomputed plane membership.
|