aboutsummaryrefslogtreecommitdiff
path: root/skill_plans/construction.md
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans/construction.md')
-rw-r--r--skill_plans/construction.md1638
1 files changed, 1638 insertions, 0 deletions
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: `"<PlayerName>'s House"`
+- Description: `"A cozy starter home belonging to <PlayerName>. 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 <item> 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.