From 458916fb78dcef3ff77cb29b7ba98d9f646819fc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 02:08:55 -0400 Subject: feat: rough pharmacy skill added --- skill_plans/construction.md | 1638 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1638 insertions(+) create mode 100644 skill_plans/construction.md (limited to 'skill_plans/construction.md') diff --git a/skill_plans/construction.md b/skill_plans/construction.md new file mode 100644 index 0000000..85452ad --- /dev/null +++ b/skill_plans/construction.md @@ -0,0 +1,1638 @@ +# Construction Skill Implementation Plan + +## 1. Overview + +Construction adds player housing, a workshop for crafting planks and furniture, and a real estate broker NPC. Players buy a house from the broker for 10 credits, then visit their house (or other players' houses) through a Directory object in the Local Neighborhood room. Each house consists of two virtual rooms: a house entrance and a workshop. The workshop contains a workbench station where players slowly convert logs into planks (the `make`/`construct` command), then convert planks into sellable furniture items for Construction XP. + +**Key design decisions:** +- Player houses are virtual rooms generated in memory, NOT YAML files on disk +- The workshop has a "workbench" object that acts as a production station +- Construction products are items with credit value (not room decorations) +- The house is just 2 rooms (entrance + workshop) for now +- No Runescape-style dismantling — items are final products + +**Existing state:** +- Room 16 is "Construction Site" (`data/rooms/16.yaml`) with exits east:17, west:15 +- `Construction` skill already defined in `internal/player/player.go` (line 27) with abbreviation `"con"` (line 59) +- The production system in `action_production.go` is reusable for a new `"construction"` recipe type + +## 2. Architecture + +### Virtual Room System + +Player houses exist only in memory. The `World.LoadRoom()` function (at `internal/world/world.go:306`) currently reads exclusively from disk. We add a **virtual room registry** so `LoadRoom` checks memory first before disk. + +Each player who buys a house gets two virtual room IDs allocated: +- **House entrance**: base ID +- **Workshop**: base ID + 1 + +Room IDs for virtual rooms use the range **100000+** to avoid collision with YAML room files. A counter in `World` tracks the next available virtual room ID. + +**Player flags used** (stored in character YAML `flags:` map): +- `has_house` (bool): Whether the player owns a house +- `house_room_id` (int): The virtual room ID of the player's house entrance +- `house_owner` (string): The player's character name (redundant but useful for Directory) + +### Data Flow + +``` +Player buys house (talk broker) + → set player_flag has_house: true + → allocate virtual room IDs via housing.go + → store house_room_id in player flags + → save character + +Player visits house (use directory) + → read target player's character file for house_room_id + → ensure virtual rooms exist in World registry + → teleport player to house entrance + +Player uses workshop + → findStation finds "workbench" object in virtual room + → production system handles make/construct recipes normally +``` + +## 3. Commands + +### `make` / `construct` (Active command, production-style) + +These are aliases for the same command. They work like `smith` — require a workbench station in the room, show a production table of available recipes, and use the unified production cycle. + +**Classification** — add to `classifyCommand()` in `internal/game/game.go:136`: +```go +case "get", "take", "grab", "pick", "drop", + "attack", "kill", + "north", "n", "south", "s", "east", "e", + "west", "w", "up", "u", "down", "d", + "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct": + return ClassActive +``` + +**Dispatch** — add to `executeCommand()` in `internal/game/game.go` (after the `craft` case around line 380): +```go +case "make", "construct": + g.doMake(sess, strings.Join(args, " ")) + return +``` + +**AdvanceActions** — the production system already handles any action type registered in `productionActionTypes` (via the `productionTypes` map in `action_production.go:29`). Add the entry: +```go +var productionTypes = map[string]productionTypeInfo{ + // ... existing entries ... + "construction": {"make", "construction"}, +} +``` + +This causes `advanceProduction` to be called for `make` action types automatically via the `default` case in `AdvanceActions` at `action.go:237`. + +## 4. New Files to Create + +### Go Files + +| File | Purpose | +|------|---------| +| `internal/game/cmd_make.go` | `doMake()` command handler for `make`/`construct` | +| `internal/game/housing.go` | Virtual room generation, Directory logic, homeowner scanning | + +### YAML Data Files + +| File | Purpose | +|------|---------| +| `data/rooms/150.yaml` | Local Neighborhood room (north of room 16) | +| `data/objects/directory.yaml` | Directory object definition | +| `data/objects/workbench.yaml` | Workbench station object definition | +| `data/mobs/real_estate_broker.yaml` | Real estate broker mob definition | +| `data/behaviors/broker_talk.yaml` | Broker talk behavior (buy house dialog) | +| `data/items/plank.yaml` | Regular plank item | +| `data/items/oak_plank.yaml` | Oak plank item | +| `data/items/teak_plank.yaml` | Teak plank item | +| `data/items/mahogany_plank.yaml` | Mahogany plank item | +| `data/items/wooden_shelf.yaml` | Wooden shelf (furniture item) | +| `data/items/wooden_table.yaml` | Wooden table (furniture item) | +| `data/items/wooden_chair.yaml` | Wooden chair (furniture item) | +| `data/items/wooden_bench.yaml` | Wooden bench (furniture item) | +| `data/items/oak_shelf.yaml` | Oak shelf (furniture item) | +| `data/items/oak_table.yaml` | Oak table (furniture item) | +| `data/items/oak_chair.yaml` | Oak chair (furniture item) | +| `data/items/oak_bench.yaml` | Oak bench (furniture item) | +| `data/items/teak_shelf.yaml` | Teak shelf (furniture item) | +| `data/items/teak_table.yaml` | Teak table (furniture item) | +| `data/items/teak_chair.yaml` | Teak chair (furniture item) | +| `data/items/teak_bench.yaml` | Teak bench (furniture item) | +| `data/items/mahogany_shelf.yaml` | Mahogany shelf (furniture item) | +| `data/items/mahogany_table.yaml` | Mahogany table (furniture item) | +| `data/items/mahogany_chair.yaml` | Mahogany chair (furniture item) | +| `data/items/mahogany_bench.yaml` | Mahogany bench (furniture item) | +| `data/recipes/construct_plank.yaml` | Logs → plank recipe | +| `data/recipes/construct_oak_plank.yaml` | Oak logs → oak plank recipe | +| `data/recipes/construct_teak_plank.yaml` | Teak logs → teak plank recipe | +| `data/recipes/construct_mahogany_plank.yaml` | Mahogany logs → mahogany plank recipe | +| `data/recipes/construct_wooden_shelf.yaml` | Plank → wooden shelf recipe | +| `data/recipes/construct_wooden_table.yaml` | Plank → wooden table recipe | +| `data/recipes/construct_wooden_chair.yaml` | Plank → wooden chair recipe | +| `data/recipes/construct_wooden_bench.yaml` | Plank → wooden bench recipe | +| `data/recipes/construct_oak_shelf.yaml` | Oak plank → oak shelf recipe | +| `data/recipes/construct_oak_table.yaml` | Oak plank → oak table recipe | +| `data/recipes/construct_oak_chair.yaml` | Oak plank → oak chair recipe | +| `data/recipes/construct_oak_bench.yaml` | Oak plank → oak bench recipe | +| `data/recipes/construct_teak_shelf.yaml` | Teak plank → teak shelf recipe | +| `data/recipes/construct_teak_table.yaml` | Teak plank → teak table recipe | +| `data/recipes/construct_teak_chair.yaml` | Teak plank → teak chair recipe | +| `data/recipes/construct_teak_bench.yaml` | Teak plank → teak bench recipe | +| `data/recipes/construct_mahogany_shelf.yaml` | Mahogany plank → mahogany shelf recipe | +| `data/recipes/construct_mahogany_table.yaml` | Mahogany plank → mahogany table recipe | +| `data/recipes/construct_mahogany_chair.yaml` | Mahogany plank → mahogany chair recipe | +| `data/recipes/construct_mahogany_bench.yaml` | Mahogany plank → mahogany bench recipe | +| `data/help/make.yaml` | Help topic for make/construct command | +| `data/help/construction.yaml` | Help topic for construction skill | + +## 5. Code Changes to Existing Files + +### `internal/game/game.go` + +**1. `classifyCommand()` (line 136)** — Add `"make"` and `"construct"` to the Active command case: + +```go +// Change this line (around line 150): +"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft": +// To: +"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct": +``` + +**2. `executeCommand()` (around line 379)** — Add dispatch case after `craft`: + +```go +case "make", "construct": + g.doMake(sess, strings.Join(args, " ")) + return +``` + +### `internal/game/action_production.go` + +**3. `productionTypes` map (line 29)** — Add construction entry: + +```go +var productionTypes = map[string]productionTypeInfo{ + "cooking": {"cook", "cooking"}, + "smelting": {"smelt", "smelting"}, + "smithing": {"smith", "smithing"}, + "crafting": {"craft", "crafting"}, + "combine": {"combine", "combining"}, + "fletching": {"fletch", "fletching"}, + "construction": {"make", "construction"}, +} +``` + +This single addition causes the entire production cycle (`startProduction`, `advanceProduction`, `canContinueProduction`) to work automatically for `type: "construction"` recipes. The `init()` function at line 40 will register `"make"` in `productionActionTypes`, and the `default` case in `AdvanceActions` at `action.go:237` will route to `advanceProduction`. + +### `internal/world/world.go` + +**4. Add virtual room registry** — Add a `virtualRooms` map and a `nextVirtualID` counter to the `World` struct (line 39): + +```go +type World struct { + dataDir string + mu sync.Mutex + groundItems map[int][]*groundEntry + seeded map[int]bool + objStates map[string]*ObjState + objMoves []ObjMove + virtualRooms map[int]*Room // NEW: in-memory rooms + nextVirtualID int // NEW: next available virtual room ID +} +``` + +**5. Initialize in `New()` (line 297)**: + +```go +func New(dataDir string) *World { + return &World{ + dataDir: dataDir, + groundItems: make(map[int][]*groundEntry), + seeded: make(map[int]bool), + objStates: make(map[string]*ObjState), + virtualRooms: make(map[int]*Room), + nextVirtualID: 100000, + } +} +``` + +**6. Modify `LoadRoom()` (line 306)** — Check virtual rooms first: + +```go +func (w *World) LoadRoom(id int) (*Room, error) { + w.mu.Lock() + if vr, ok := w.virtualRooms[id]; ok { + w.mu.Unlock() + return vr, nil + } + w.mu.Unlock() + + // existing disk-loading code follows... + path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id)) + // ... +} +``` + +**7. Add virtual room management methods**: + +```go +func (w *World) RegisterVirtualRoom(room *Room) { + w.mu.Lock() + defer w.mu.Unlock() + w.virtualRooms[room.ID] = room +} + +func (w *World) AllocateVirtualID() int { + w.mu.Lock() + defer w.mu.Unlock() + id := w.nextVirtualID + w.nextVirtualID += 2 // reserve 2 IDs per house (entrance + workshop) + return id +} + +func (w *World) HasVirtualRoom(id int) bool { + w.mu.Lock() + defer w.mu.Unlock() + _, ok := w.virtualRooms[id] + return ok +} +``` + +### `internal/player/store.go` + +**8. Add `ListHomeowners()` method** — Scan all character files for `has_house` flag: + +```go +func (s *AccountStore) ListHomeowners() []string { + dir := filepath.Join(s.dataDir, "players", "characters") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + charName := strings.TrimSuffix(e.Name(), ".yaml") + p, err := s.LoadCharacter(charName) + if err != nil { + continue + } + if p.Flags != nil { + if hasHouse, ok := p.Flags["has_house"].(bool); ok && hasHouse { + names = append(names, p.Name) + } + } + } + sort.Strings(names) + return names +} +``` + +This requires adding `"sort"` to the imports in `store.go`. + +### `internal/player/player.go` + +**9. Add `make_all` option** — Add to `OptionDefs` slice (around line 134, after the existing `*_all` options): + +```go +{"make_all", OptBool, false, nil, "Auto-start construction when only one product is possible"}, +``` + +### `internal/game/game.go` (HandleSession) + +**10. Add `StateVisitHouse` handling** — In `HandleSession()` (line 92), add a case for the new session state that the Directory object will use: + +```go +case net.StateVisitHouse: + g.handleVisitHouseInput(sess, input) +``` + +### `internal/net/server.go` + +**11. Add `StateVisitHouse` constant** — Add to the session state constants (around line 39): + +```go +StateVisitHouse +``` + +## 6. Real Estate Broker + +The broker is a mob NPC placed in the Construction Site room (room 16). Uses the existing talk behavior system. + +### Mob Definition: `data/mobs/real_estate_broker.yaml` + +```yaml +id: real_estate_broker +name: Real Estate Broker +description: "A smartly dressed broker clutching a clipboard and a ring of keys. She looks eager to make a sale." +behavior: broker_talk +unique: true +protected: true +hp: 50 +attack: 1 +strength: 1 +defense: 1 +speed: 5 +aggressive: false +respawn_ticks: 30 +idle_descriptions: + - "flips through property listings on a clipboard" + - "jingles a ring of keys" + - "adjusts her collar and checks her watch" + - "polishes a small 'SOLD' stamp" +``` + +### Talk Behavior: `data/behaviors/broker_talk.yaml` + +```yaml +id: broker_talk +type: talk +nodes: + start: + message: "\"Welcome! I'm the local real estate broker. Looking for a place to call home?\"" + options: + - text: "\"I'd like to buy a house.\"" + goto: buy_offer + condition: + all_of: + - player_flag: has_house + not: true + - min_credits: 10 + - text: "\"I'd like to buy a house.\"" + goto: no_credits + condition: + all_of: + - player_flag: has_house + not: true + - min_credits: 10 + not: true + - text: "\"I already own a house.\"" + goto: already_own + condition: + player_flag: has_house + value: true + - text: "\"Just looking around.\"" + end: true + buy_offer: + message: "\"Excellent! I have a lovely starter home available in the Local Neighborhood — just north of here. It comes with a workshop, perfect for construction projects. The price is 10 credits. Shall I draw up the paperwork?\"" + options: + - text: "\"Yes, I'll take it!\"" + goto: purchase_complete + - text: "\"Let me think about it.\"" + end: true + purchase_complete: + message: "\"Congratulations! Here are your keys. Your new home is in the Local Neighborhood, just north of here. Use the Directory there to find your house. Happy building!\"" + action: + cost: 10 + set_player_flags: + has_house: true + options: + - text: "\"Thanks!\"" + end: true + no_credits: + message: "\"I'm afraid the starter home costs 10 credits. Come back when you've saved up!\"" + options: + - text: "\"I'll be back.\"" + end: true + already_own: + message: "\"You already own a home! Head to the Local Neighborhood north of here and use the Directory to visit it.\"" + options: + - text: "\"Thanks for the reminder.\"" + end: true +``` + +**Note on house room allocation:** The talk behavior sets `has_house: true` via `set_player_flags`. The actual virtual room ID allocation happens lazily the **first time** the player visits their house via the Directory (in `housing.go`). This avoids needing to extend `applyNodeAction` — the existing `set_player_flags` and `cost` handling in `action_talk.go:165-218` handles everything. + +### Update Room 16: `data/rooms/16.yaml` + +```yaml +id: 16 +name: "Construction Site" +description: "A half-built structure surrounded by planks, nails, and blueprints. A {130}real estate broker{/} stands near a model home display, eager to chat." +exits: + north: 150 + east: 17 + west: 15 +mobs: + - id: real_estate_broker +``` + +Changes from current: +- Added `north: 150` exit to Local Neighborhood +- Updated description to mention the broker +- Added broker mob + +## 7. Local Neighborhood Room + +### `data/rooms/150.yaml` + +```yaml +id: 150 +name: "Local Neighborhood" +description: "A quiet residential street lined with small houses. A large {45}directory board{/} stands at the entrance, listing all the homeowners in the area. The construction site lies to the south." +exits: + south: 16 +objects: + - id: directory +``` + +## 8. Directory Object + +### `data/objects/directory.yaml` + +```yaml +id: directory +name: directory +color: "45" +description: "A large board listing all homeowners in the neighborhood. Use it to visit someone's house." +inroom_description: "A large directory board lists the local homeowners." +``` + +The Directory has two interactions: +1. **`look directory`** — Shows a list of all homeowners (handled by custom logic in `cmd_look.go`) +2. **`use directory`** — Prompts "Visit whose house (enter for yours):" and teleports (handled by custom logic in `housing.go`) + +### Implementation in `internal/game/housing.go` + +This file contains all housing-related logic. + +```go +package game + +import ( + "fmt" + "sort" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +const neighborhoodRoomID = 150 + +func (g *Game) isDirectoryObject(defID string) bool { + return defID == "directory" +} + +func (g *Game) listHomeowners() []string { + return g.AccountStore.ListHomeowners() +} + +func (g *Game) lookDirectory(sess *net.Session) { + owners := g.listHomeowners() + if len(owners) == 0 { + sess.WriteLine("The directory is empty — no one has bought a house yet.") + return + } + sess.WriteLine("The directory lists the following homeowners:") + for _, name := range owners { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } +} + +func (g *Game) useDirectory(sess *net.Session) { + p := sess.Player.(*player.Player) + _ = p + sess.State = net.StateVisitHouse + sess.Write("Visit whose house (enter for yours): ") +} + +func (g *Game) handleVisitHouseInput(sess *net.Session, input string) { + sess.State = net.StateGame + p := sess.Player.(*player.Player) + input = strings.TrimSpace(input) + + var targetName string + if input == "" { + if p.Flags == nil { + sess.WriteLine("You don't own a house.") + g.reprompt(sess) + return + } + hasHouse, _ := p.Flags["has_house"].(bool) + if !hasHouse { + sess.WriteLine("You don't own a house. Talk to the Real Estate Broker at the Construction Site.") + g.reprompt(sess) + return + } + targetName = p.Name + } else { + found := false + for _, name := range g.listHomeowners() { + if strings.EqualFold(name, input) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(input)) { + targetName = name + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("%s doesn't own a house here.", input)) + g.reprompt(sess) + return + } + } + + roomID := g.ensurePlayerHouse(targetName) + if roomID == 0 { + sess.WriteLine("Something went wrong finding that house.") + g.reprompt(sess) + return + } + + p.RoomID = roomID + g.AccountStore.SaveCharacter(p) + g.World.SeedGroundItems(p.RoomID) + g.seedRoomObjects(p.RoomID) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + g.writePrompt(sess) +} + +func (g *Game) ensurePlayerHouse(charName string) int { + target, err := g.AccountStore.LoadCharacter(charName) + if err != nil { + return 0 + } + if target.Flags == nil { + return 0 + } + + // Check if the character already has a room ID allocated + if rid, ok := target.Flags["house_room_id"]; ok { + var roomID int + switch v := rid.(type) { + case int: + roomID = v + case float64: + roomID = int(v) + } + if roomID > 0 && g.World.HasVirtualRoom(roomID) { + return roomID + } + // Room ID stored but virtual rooms not registered (server restart) — regenerate + if roomID > 0 { + g.registerHouseRooms(roomID, charName) + return roomID + } + } + + // Allocate new virtual room IDs + roomID := g.World.AllocateVirtualID() + target.Flags["house_room_id"] = roomID + g.AccountStore.SaveCharacter(target) + + // If this is the current player, also update their in-memory flags + // (the caller may have a different Player pointer) + + g.registerHouseRooms(roomID, charName) + return roomID +} + +func (g *Game) registerHouseRooms(baseID int, ownerName string) { + workshopID := baseID + 1 + + entrance := &world.Room{ + ID: baseID, + Name: fmt.Sprintf("%s's House", ownerName), + Description: fmt.Sprintf("A cozy starter home belonging to %s. The walls are bare but full of potential. A doorway to the west leads to a workshop.", ownerName), + Exits: map[world.ExitDir]world.ExitDef{ + world.South: {Room: neighborhoodRoomID}, + world.West: {Room: workshopID}, + }, + } + + workshop := &world.Room{ + ID: workshopID, + Name: "Workshop", + Description: "A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls.", + Exits: map[world.ExitDir]world.ExitDef{ + world.East: {Room: baseID}, + }, + Objects: []world.RoomObject{ + {ID: "workbench"}, + }, + } + + g.World.RegisterVirtualRoom(entrance) + g.World.RegisterVirtualRoom(workshop) +} +``` + +### Hooking `look directory` into `cmd_look.go` + +In `doLookTarget()` in `internal/game/cmd_look.go`, add a check: if the target matches a directory object in the current room, call `g.lookDirectory(sess)` instead of (or in addition to) showing the object description. + +Find the section in `doLookTarget` that handles looking at objects. After the object is found and its description is displayed, add: + +```go +// After displaying the object description for "directory" +if obj.ID == "directory" { + g.lookDirectory(sess) +} +``` + +This is an addition to the normal look flow — the object's `description` field text is shown first ("A large board listing all homeowners..."), then the dynamic homeowner list is appended. + +### Hooking `use directory` into `cmd_use.go` + +In `doUse()` in `internal/game/cmd_use.go`, the flow already routes through `StartAction` for object interactions. However, the Directory doesn't have a behavior YAML — it needs a custom handler. + +The cleanest approach: add a `use_interactions` entry on the directory object that triggers a custom action, OR handle it as a special case in `doUse`. Since the directory needs a prompt state (StateVisitHouse), handle it as a special case: + +In `cmd_use.go`, when `use directory` is entered and the directory object is found in the room, call `g.useDirectory(sess)` instead of routing through the behavior system. The simplest hook point is in `doUse` — after identifying the target is the `directory` object, short-circuit: + +```go +if target object's defID == "directory" { + g.useDirectory(sess) + return +} +``` + +Alternatively, add a check in `StartAction` (`action.go:45`) that intercepts `directory` before behavior lookup. The `doUse` approach is cleaner since `use` on its own already has custom item-on-object logic. + +## 9. Player House Rooms + +### Dynamic Room Generation + +Virtual rooms are `*world.Room` structs stored in `World.virtualRooms`. They are created on-demand by `ensurePlayerHouse()` in `housing.go`. + +**House Entrance Room:** +- Name: `"'s House"` +- Description: `"A cozy starter home belonging to . The walls are bare but full of potential. A doorway to the west leads to a workshop."` +- Exits: `south → 150` (Local Neighborhood), `west → workshopID` +- No objects, no mobs, no spawns + +**Workshop Room:** +- Name: `"Workshop"` +- Description: `"A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls."` +- Exits: `east → entranceID` +- Objects: `[{id: "workbench"}]` + +### Server Restart Handling + +Virtual rooms are lost on server restart (they live in memory). When a player visits a house after restart, `ensurePlayerHouse()` checks if the virtual rooms exist. If not, it re-registers them using the stored `house_room_id` from the player's flags. This is seamless — the first visit after restart recreates the rooms. + +The `AllocateVirtualID()` counter also resets on restart. To prevent ID collisions, on startup the system should scan all character files for existing `house_room_id` values and set `nextVirtualID` above the maximum found. Add this to `Game.New()` or a separate init function: + +```go +func (g *Game) initHousingIDs() { + dir := filepath.Join(g.dataDir, "players", "characters") + entries, _ := os.ReadDir(dir) + maxID := 100000 + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + name := strings.TrimSuffix(e.Name(), ".yaml") + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + continue + } + if p.Flags != nil { + if rid, ok := p.Flags["house_room_id"]; ok { + var id int + switch v := rid.(type) { + case int: + id = v + case float64: + id = int(v) + } + if id+2 > maxID { + maxID = id + 2 + } + } + } + } + g.World.SetNextVirtualID(maxID) +} +``` + +Add `SetNextVirtualID` to `World`: +```go +func (w *World) SetNextVirtualID(id int) { + w.mu.Lock() + defer w.mu.Unlock() + if id > w.nextVirtualID { + w.nextVirtualID = id + } +} +``` + +Call `g.initHousingIDs()` after `Game.New()` in `cmd/mud/main.go`, before the server starts accepting connections. + +### Map Integration + +The BFS map builder in `map.go` calls `World.LoadRoom()` to traverse exits. Virtual rooms will automatically be included since `LoadRoom` checks the virtual registry. Player house rooms will appear on the map when the player is inside them. + +## 10. Workshop + +### Workbench Object: `data/objects/workbench.yaml` + +```yaml +id: workbench +name: workbench +color: "137" +description: "A sturdy wooden workbench with a built-in vice, saw guides, and tool racks. Perfect for construction projects." +inroom_description: "A sturdy workbench dominates the center of the room." +``` + +The workbench has no behavior — it serves purely as a station object. `findStation()` in `stations.go:3` searches for objects in the room by `defID`. Construction recipes list `station: [workbench]`, and `findStation` will match the workbench's `defID` of `"workbench"`. + +### How the Workshop Works + +1. Player enters workshop (east exit from house entrance) +2. `seedRoomObjects` is called, which calls `World.LoadRoom(workshopID)` — returns the virtual room — and then calls `EnsureObjectStates` for the `workbench` object +3. Player types `make` or `construct` +4. `doMake()` calls `findStation(p.RoomID, []string{"workbench"})` — finds the workbench +5. Recipes of `type: "construction"` with `station: [workbench]` are loaded and filtered +6. Player picks a recipe from the production table +7. Unified production cycle runs: consume materials, wait ticks, produce output, award XP + +## 11. Items + +### Plank Items + +#### `data/items/plank.yaml` +```yaml +id: plank +name: plank +color: "137" +description: "A carefully shaped wooden plank, ready for construction." +value: 5 +stackable: false +``` + +#### `data/items/oak_plank.yaml` +```yaml +id: oak_plank +name: oak plank +color: "143" +description: "A sturdy oak plank, suitable for quality furniture." +value: 15 +stackable: false +``` + +#### `data/items/teak_plank.yaml` +```yaml +id: teak_plank +name: teak plank +color: "179" +description: "A fine teak plank with a beautiful grain." +value: 40 +stackable: false +``` + +#### `data/items/mahogany_plank.yaml` +```yaml +id: mahogany_plank +name: mahogany plank +color: "124" +description: "A rich mahogany plank, the finest building material." +value: 100 +stackable: false +``` + +### Furniture Items (Wooden) + +#### `data/items/wooden_shelf.yaml` +```yaml +id: wooden_shelf +name: wooden shelf +color: "137" +description: "A simple wooden shelf. Could hold a few things." +value: 15 +stackable: false +``` + +#### `data/items/wooden_table.yaml` +```yaml +id: wooden_table +name: wooden table +color: "137" +description: "A basic wooden table with four legs." +value: 25 +stackable: false +``` + +#### `data/items/wooden_chair.yaml` +```yaml +id: wooden_chair +name: wooden chair +color: "137" +description: "A simple wooden chair. Not comfortable, but functional." +value: 20 +stackable: false +``` + +#### `data/items/wooden_bench.yaml` +```yaml +id: wooden_bench +name: wooden bench +color: "137" +description: "A long wooden bench for sitting." +value: 30 +stackable: false +``` + +### Furniture Items (Oak) + +#### `data/items/oak_shelf.yaml` +```yaml +id: oak_shelf +name: oak shelf +color: "143" +description: "A sturdy oak shelf with dovetail joints." +value: 50 +stackable: false +``` + +#### `data/items/oak_table.yaml` +```yaml +id: oak_table +name: oak table +color: "143" +description: "A solid oak table with a polished surface." +value: 80 +stackable: false +``` + +#### `data/items/oak_chair.yaml` +```yaml +id: oak_chair +name: oak chair +color: "143" +description: "A well-crafted oak chair with carved armrests." +value: 65 +stackable: false +``` + +#### `data/items/oak_bench.yaml` +```yaml +id: oak_bench +name: oak bench +color: "143" +description: "A heavy oak bench with smooth edges." +value: 95 +stackable: false +``` + +### Furniture Items (Teak) + +#### `data/items/teak_shelf.yaml` +```yaml +id: teak_shelf +name: teak shelf +color: "179" +description: "An elegant teak shelf with a warm finish." +value: 130 +stackable: false +``` + +#### `data/items/teak_table.yaml` +```yaml +id: teak_table +name: teak table +color: "179" +description: "A beautiful teak table with intricate leg carvings." +value: 200 +stackable: false +``` + +#### `data/items/teak_chair.yaml` +```yaml +id: teak_chair +name: teak chair +color: "179" +description: "A refined teak chair with a contoured seat." +value: 165 +stackable: false +``` + +#### `data/items/teak_bench.yaml` +```yaml +id: teak_bench +name: teak bench +color: "179" +description: "A gorgeous teak bench with a curved backrest." +value: 240 +stackable: false +``` + +### Furniture Items (Mahogany) + +#### `data/items/mahogany_shelf.yaml` +```yaml +id: mahogany_shelf +name: mahogany shelf +color: "124" +description: "A luxurious mahogany shelf with beveled edges." +value: 320 +stackable: false +``` + +#### `data/items/mahogany_table.yaml` +```yaml +id: mahogany_table +name: mahogany table +color: "124" +description: "A magnificent mahogany table with brass inlays." +value: 500 +stackable: false +``` + +#### `data/items/mahogany_chair.yaml` +```yaml +id: mahogany_chair +name: mahogany chair +color: "124" +description: "A grand mahogany chair fit for a captain." +value: 400 +stackable: false +``` + +#### `data/items/mahogany_bench.yaml` +```yaml +id: mahogany_bench +name: mahogany bench +color: "124" +description: "A stately mahogany bench with ornate scrollwork." +value: 600 +stackable: false +``` + +## 12. Recipes + +All construction recipes use `type: "construction"`, `station: [workbench]`, and have very slow wait times (20+ ticks for planks, 15+ ticks for furniture). + +### Plank Recipes + +#### `data/recipes/construct_plank.yaml` +```yaml +id: construct_plank +type: construction +level: 1 +xp: 30 +wait: 20 +station: [workbench] +consume: + - items: [logs] + qty: 1 +output: plank +message: "You carefully saw the logs into a plank." +``` + +#### `data/recipes/construct_oak_plank.yaml` +```yaml +id: construct_oak_plank +type: construction +level: 15 +xp: 60 +wait: 24 +station: [workbench] +consume: + - items: [oak_logs] + qty: 1 +output: oak_plank +message: "You saw the oak logs into a fine plank." +``` + +#### `data/recipes/construct_teak_plank.yaml` +```yaml +id: construct_teak_plank +type: construction +level: 35 +xp: 100 +wait: 28 +station: [workbench] +consume: + - items: [teak_logs] + qty: 1 +output: teak_plank +message: "You carefully work the teak logs into a smooth plank." +``` + +#### `data/recipes/construct_mahogany_plank.yaml` +```yaml +id: construct_mahogany_plank +type: construction +level: 50 +xp: 150 +wait: 32 +station: [workbench] +consume: + - items: [mahogany_logs] + qty: 1 +output: mahogany_plank +message: "You painstakingly shape the mahogany logs into a perfect plank." +``` + +### Wooden Furniture Recipes + +#### `data/recipes/construct_wooden_shelf.yaml` +```yaml +id: construct_wooden_shelf +type: construction +level: 1 +xp: 50 +wait: 15 +station: [workbench] +consume: + - items: [plank] + qty: 2 +output: wooden_shelf +message: "You assemble a simple wooden shelf." +``` + +#### `data/recipes/construct_wooden_table.yaml` +```yaml +id: construct_wooden_table +type: construction +level: 5 +xp: 80 +wait: 18 +station: [workbench] +consume: + - items: [plank] + qty: 3 +output: wooden_table +message: "You build a sturdy wooden table." +``` + +#### `data/recipes/construct_wooden_chair.yaml` +```yaml +id: construct_wooden_chair +type: construction +level: 3 +xp: 65 +wait: 16 +station: [workbench] +consume: + - items: [plank] + qty: 2 +output: wooden_chair +message: "You craft a simple wooden chair." +``` + +#### `data/recipes/construct_wooden_bench.yaml` +```yaml +id: construct_wooden_bench +type: construction +level: 8 +xp: 100 +wait: 20 +station: [workbench] +consume: + - items: [plank] + qty: 4 +output: wooden_bench +message: "You construct a long wooden bench." +``` + +### Oak Furniture Recipes + +#### `data/recipes/construct_oak_shelf.yaml` +```yaml +id: construct_oak_shelf +type: construction +level: 20 +xp: 120 +wait: 18 +station: [workbench] +consume: + - items: [oak_plank] + qty: 2 +output: oak_shelf +message: "You assemble a sturdy oak shelf." +``` + +#### `data/recipes/construct_oak_table.yaml` +```yaml +id: construct_oak_table +type: construction +level: 25 +xp: 180 +wait: 22 +station: [workbench] +consume: + - items: [oak_plank] + qty: 3 +output: oak_table +message: "You build a solid oak table." +``` + +#### `data/recipes/construct_oak_chair.yaml` +```yaml +id: construct_oak_chair +type: construction +level: 22 +xp: 150 +wait: 20 +station: [workbench] +consume: + - items: [oak_plank] + qty: 2 +output: oak_chair +message: "You craft a well-made oak chair." +``` + +#### `data/recipes/construct_oak_bench.yaml` +```yaml +id: construct_oak_bench +type: construction +level: 28 +xp: 220 +wait: 24 +station: [workbench] +consume: + - items: [oak_plank] + qty: 4 +output: oak_bench +message: "You construct a heavy oak bench." +``` + +### Teak Furniture Recipes + +#### `data/recipes/construct_teak_shelf.yaml` +```yaml +id: construct_teak_shelf +type: construction +level: 40 +xp: 200 +wait: 20 +station: [workbench] +consume: + - items: [teak_plank] + qty: 2 +output: teak_shelf +message: "You assemble an elegant teak shelf." +``` + +#### `data/recipes/construct_teak_table.yaml` +```yaml +id: construct_teak_table +type: construction +level: 45 +xp: 300 +wait: 25 +station: [workbench] +consume: + - items: [teak_plank] + qty: 3 +output: teak_table +message: "You build a beautiful teak table." +``` + +#### `data/recipes/construct_teak_chair.yaml` +```yaml +id: construct_teak_chair +type: construction +level: 42 +xp: 250 +wait: 22 +station: [workbench] +consume: + - items: [teak_plank] + qty: 2 +output: teak_chair +message: "You craft a refined teak chair." +``` + +#### `data/recipes/construct_teak_bench.yaml` +```yaml +id: construct_teak_bench +type: construction +level: 48 +xp: 360 +wait: 28 +station: [workbench] +consume: + - items: [teak_plank] + qty: 4 +output: teak_bench +message: "You construct a gorgeous teak bench." +``` + +### Mahogany Furniture Recipes + +#### `data/recipes/construct_mahogany_shelf.yaml` +```yaml +id: construct_mahogany_shelf +type: construction +level: 55 +xp: 350 +wait: 24 +station: [workbench] +consume: + - items: [mahogany_plank] + qty: 2 +output: mahogany_shelf +message: "You assemble a luxurious mahogany shelf." +``` + +#### `data/recipes/construct_mahogany_table.yaml` +```yaml +id: construct_mahogany_table +type: construction +level: 60 +xp: 500 +wait: 30 +station: [workbench] +consume: + - items: [mahogany_plank] + qty: 3 +output: mahogany_table +message: "You build a magnificent mahogany table." +``` + +#### `data/recipes/construct_mahogany_chair.yaml` +```yaml +id: construct_mahogany_chair +type: construction +level: 58 +xp: 420 +wait: 26 +station: [workbench] +consume: + - items: [mahogany_plank] + qty: 2 +output: mahogany_chair +message: "You craft a grand mahogany chair." +``` + +#### `data/recipes/construct_mahogany_bench.yaml` +```yaml +id: construct_mahogany_bench +type: construction +level: 65 +xp: 600 +wait: 32 +station: [workbench] +consume: + - items: [mahogany_plank] + qty: 4 +output: mahogany_bench +message: "You construct a stately mahogany bench." +``` + +## 13. Plank Making + +Plank making is the core "slow grind" of Construction. Converting logs to planks is intentionally very slow (20-32 ticks per plank, meaning 12-19 seconds at default 600ms ticks) to make it feel like real labor. + +| Log Type | Plank Output | Level | XP | Wait (ticks) | Wait (seconds at 600ms) | +|----------|-------------|-------|----|-------------|------------------------| +| logs | plank | 1 | 30 | 20 | 12.0 | +| oak_logs | oak_plank | 15 | 60 | 24 | 14.4 | +| teak_logs | teak_plank | 35 | 100 | 28 | 16.8 | +| mahogany_logs | mahogany_plank | 50 | 150 | 32 | 19.2 | + +Planks are non-stackable (like logs), so inventory management is part of the challenge — you can only carry 28 items, so a full inventory of logs becomes a full inventory of planks. + +No success/fail rolls — plank making always succeeds. This keeps it simple and avoids frustrating material loss on an already-slow process. + +## 14. XP Table + +### Plank Making XP + +| Recipe | Level | XP | Ticks | +|--------|-------|----|-------| +| Plank | 1 | 30 | 20 | +| Oak Plank | 15 | 60 | 24 | +| Teak Plank | 35 | 100 | 28 | +| Mahogany Plank | 50 | 150 | 32 | + +### Furniture XP + +| Recipe | Level | XP | Planks | Ticks | +|--------|-------|----|--------|-------| +| Wooden Shelf | 1 | 50 | 2 | 15 | +| Wooden Chair | 3 | 65 | 2 | 16 | +| Wooden Table | 5 | 80 | 3 | 18 | +| Wooden Bench | 8 | 100 | 4 | 20 | +| Oak Shelf | 20 | 120 | 2 | 18 | +| Oak Chair | 22 | 150 | 2 | 20 | +| Oak Table | 25 | 180 | 3 | 22 | +| Oak Bench | 28 | 220 | 4 | 24 | +| Teak Shelf | 40 | 200 | 2 | 20 | +| Teak Chair | 42 | 250 | 2 | 22 | +| Teak Table | 45 | 300 | 3 | 25 | +| Teak Bench | 48 | 360 | 4 | 28 | +| Mahogany Shelf | 55 | 350 | 2 | 24 | +| Mahogany Chair | 58 | 420 | 2 | 26 | +| Mahogany Table | 60 | 500 | 3 | 30 | +| Mahogany Bench | 65 | 600 | 4 | 32 | + +### XP/Hour Efficiency (approximate, at 600ms ticks) + +| Method | XP/tick | XP/hour | +|--------|---------|---------| +| Regular planks | 1.5 | 9,000 | +| Regular furniture (shelf) | 3.3 | 12,000 | +| Oak planks | 2.5 | 15,000 | +| Oak furniture (table) | 8.2 | 29,500 | +| Teak planks | 3.6 | 21,400 | +| Teak furniture (table) | 12.0 | 43,200 | +| Mahogany planks | 4.7 | 28,100 | +| Mahogany furniture (table) | 16.7 | 60,000 | + +## 15. Help Files + +### `data/help/make.yaml` + +```yaml +name: "make" +category: "Skills" +description: | + Construct items at a workbench using the Construction skill. + + Usage: make Build a specific item + make Show all items you can make right now + construct Same as make + + Construction requires a workbench, found in your house's + workshop. Buy a house from the Real Estate Broker at the + Construction Site, then visit it via the Directory in the + Local Neighborhood. + + Plank making (logs to planks) is slow but reliable — no chance + of failure. Furniture crafting uses planks to produce sellable + items for credits. + + At the prompt, type a product number or name to start making. + Partial names work if unambiguous. You can prefix with a count + to limit quantity (e.g., "3 shelf"). Press enter to repeat + the last product. + + See also: construction +``` + +### `data/help/construction.yaml` + +```yaml +name: "construction" +category: "Skills" +description: | + Construction is a production skill for building furniture and + other items from planks at a workbench. + + Getting started: + 1. Visit the Construction Site (south of the skill halls) + 2. Talk to the Real Estate Broker to buy a house (10 credits) + 3. Go north to the Local Neighborhood + 4. Use the Directory to visit your house + 5. Go west to the Workshop + 6. Use "make" or "construct" to start building + + Plank making: Convert logs into planks at the workbench. This + is very slow work. Different log types require different levels + and yield different plank types. + + Furniture: Use planks to build furniture items (shelves, tables, + chairs, benches). These items have credit value and can be sold. + + Log types: logs (level 1), oak (15), teak (35), mahogany (50) + + Use "make" at a workbench to see available recipes. Items you + can't yet make are shown dimmed. + + See also: make +``` + +## Implementation Order + +For an AI agent implementing this, the recommended order is: + +1. **YAML data files first** (no code changes needed to test creation): + - `data/objects/workbench.yaml` + - `data/objects/directory.yaml` + - `data/mobs/real_estate_broker.yaml` + - `data/behaviors/broker_talk.yaml` + - All `data/items/*.yaml` (planks + furniture) + - All `data/recipes/construct_*.yaml` + - `data/help/make.yaml` and `data/help/construction.yaml` + +2. **Room YAML**: + - `data/rooms/150.yaml` (Local Neighborhood) + - Update `data/rooms/16.yaml` (Construction Site — add north exit, broker mob, update description) + +3. **World virtual room system** (`internal/world/world.go`): + - Add `virtualRooms` map and `nextVirtualID` to `World` struct + - Update `New()` to initialize them + - Modify `LoadRoom()` to check virtual rooms first + - Add `RegisterVirtualRoom()`, `AllocateVirtualID()`, `HasVirtualRoom()`, `SetNextVirtualID()` + +4. **Player store** (`internal/player/store.go`): + - Add `ListHomeowners()` method + +5. **Housing logic** (`internal/game/housing.go` — new file): + - `isDirectoryObject()`, `listHomeowners()`, `lookDirectory()` + - `useDirectory()`, `handleVisitHouseInput()` + - `ensurePlayerHouse()`, `registerHouseRooms()` + - `initHousingIDs()` + +6. **Session state** (`internal/net/server.go`): + - Add `StateVisitHouse` constant + +7. **Production type** (`internal/game/action_production.go`): + - Add `"construction"` entry to `productionTypes` map + +8. **Make command** (`internal/game/cmd_make.go` — new file): + - `doMake()` handler following `cmd_cook.go` / `cmd_craft.go` pattern + +9. **Command dispatch** (`internal/game/game.go`): + - Add `"make"`, `"construct"` to `classifyCommand()` Active case + - Add `"make"`, `"construct"` case to `executeCommand()` + - Add `StateVisitHouse` case to `HandleSession()` + +10. **Option** (`internal/player/player.go`): + - Add `make_all` option + +11. **Look/Use hooks**: + - Hook `lookDirectory()` into object look flow in `cmd_look.go` + - Hook `useDirectory()` into `cmd_use.go` or `StartAction` for the directory object + +12. **Startup init** (`cmd/mud/main.go`): + - Call `g.initHousingIDs()` after game creation + +13. **Build and test**: + - `make vet` — verify no compilation errors + - `make test` — run test suite + - `make run` — manual testing + +## `cmd_make.go` Full Implementation + +```go +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) doMake(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + g.CancelAction(p) + + stationDefID, _ := g.findStation(p.RoomID, []string{"workbench"}) + if stationDefID == "" { + sess.WriteLine("You need a workbench to make things. Visit your house's workshop.") + return + } + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + sess.WriteLine("Error loading recipes.") + return + } + + var makeRecipes []action.RecipeDef + for _, r := range allRecipes { + if r.Type != "construction" { + continue + } + if !stationMatch(stationDefID, r.Station) { + continue + } + makeRecipes = append(makeRecipes, r) + } + + if len(makeRecipes) == 0 { + sess.WriteLine("There's nothing to make here.") + return + } + + if input != "" { + g.doMakeWithInput(sess, p, makeRecipes, input) + return + } + + lastRecipeID, _ := p.Flags["last_make"].(string) + if lastRecipeID != "" { + for i := range makeRecipes { + if makeRecipes[i].ID == lastRecipeID { + r := &makeRecipes[i] + if g.canDoMakeRecipe(p, r) { + g.startMakeRecipe(sess, p, r, 0) + return + } + break + } + } + } + + available := g.availableMakeRecipes(p, makeRecipes) + if len(available) == 0 { + sess.WriteLine("You don't have any materials to make anything.") + return + } + + if p.OptionBool("make_all") && len(available) == 1 { + g.startMakeRecipe(sess, p, &available[0], 0) + return + } + + g.showProductionTable(sess, p, makeRecipes, "Construction", "construction", "last_make", "Make", false) +} + +func (g *Game) doMakeWithInput(sess *net.Session, p *player.Player, makeRecipes []action.RecipeDef, input string) { + qty, productName := parseQty(input) + productName = strings.TrimSpace(productName) + + var matched []action.RecipeDef + for _, r := range makeRecipes { + outDef, _ := g.ItemStore.Load(r.Output) + name := r.Output + if outDef != nil { + name = outDef.Name + } + if world.WordPrefixMatch(productName, name) { + matched = append(matched, r) + } + } + + if len(matched) == 0 { + sess.WriteLine("You can't make that.") + return + } + + var available []action.RecipeDef + for _, r := range matched { + if g.canDoMakeRecipe(p, &r) { + available = append(available, r) + } + } + + if len(available) == 0 { + sess.WriteLine("You don't have the materials or level for that.") + return + } + + if len(available) == 1 { + g.startMakeRecipe(sess, p, &available[0], qty) + return + } + + g.showProductionTable(sess, p, available, "Construction", "construction", "last_make", "Make", false) +} + +func (g *Game) canDoMakeRecipe(p *player.Player, r *action.RecipeDef) bool { + if p.Level(player.Construction) < r.Level { + return false + } + if !r.HasAllItemsQty(p.CountItem) { + return false + } + return true +} + +func (g *Game) availableMakeRecipes(p *player.Player, allMake []action.RecipeDef) []action.RecipeDef { + var result []action.RecipeDef + for _, r := range allMake { + if r.HasAllItemsQty(p.CountItem) { + result = append(result, r) + } + } + return result +} + +func (g *Game) startMakeRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags["last_make"] = recipe.ID + g.AccountStore.SaveCharacter(p) + g.startProductionFromRecipe(sess, p, recipe, count) +} +``` + +## Edge Cases to Handle + +1. **Player visits someone else's house** — The Directory allows visiting any homeowner's house. `ensurePlayerHouse(targetName)` loads the target's character file, not the visiting player's. + +2. **Server restart** — Virtual rooms are gone. `ensurePlayerHouse()` detects the missing virtual room and re-registers it. `initHousingIDs()` ensures the ID counter doesn't re-allocate existing IDs. + +3. **Player dies in their house** — Death teleports to room 1 (Town Square). Standard behavior, no special handling needed. + +4. **Player quits inside house** — `room_id` is saved to character YAML. On next login, `LoadRoom()` is called — if virtual rooms aren't registered yet, the first `look` or `move` will fail gracefully. The `completeMove` / `doLook` code calls `LoadRoom`; if it returns an error, the player gets "You can't move from here." To handle this: on login/character connection, if the player's `room_id` >= 100000, call `ensurePlayerHouse` to register the virtual rooms before showing the room. Add this check to the character connection flow in `login_char.go`. + +5. **Multiple players in the same house** — Works naturally. The Hub tracks sessions by room ID. Multiple players can be in the same virtual room. Chat (`say`) works normally. + +6. **YAML flag type mismatch** — When `house_room_id` is loaded from YAML, integers may deserialize as `float64` or `int` depending on the YAML parser. The `ensurePlayerHouse` function handles both via type switch. + +7. **Player doesn't have `has_house` but has `house_room_id`** — Shouldn't happen in normal flow, but `ensurePlayerHouse` checks both flags defensively. + +8. **Inventory full during construction** — The standard production system already handles this: "Your inventory is too full!" and cancels the action (`action_production.go:362`). + +9. **No logs/planks** — The `doMake` handler shows "You don't have any materials to make anything." Same pattern as `doCook`. + +10. **Using directory outside neighborhood room** — The directory object only exists in room 150. `use` on a non-existent object in other rooms won't match. No special guard needed. -- cgit v1.2.3