aboutsummaryrefslogtreecommitdiff
path: root/skill_plans
diff options
context:
space:
mode:
Diffstat (limited to 'skill_plans')
-rw-r--r--skill_plans/construction.md1670
1 files changed, 58 insertions, 1612 deletions
diff --git a/skill_plans/construction.md b/skill_plans/construction.md
index 85452ad..17a1533 100644
--- a/skill_plans/construction.md
+++ b/skill_plans/construction.md
@@ -1,1638 +1,84 @@
-# Construction Skill Implementation Plan
+# Construction Skill Plan
-## 1. Overview
+## 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.
+Construction is a production skill centered around processing logs into planks and assembling furniture. Players gain XP through repetitive crafting at a workbench, with higher-tier logs providing more XP and more valuable outputs.
-**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
+## Core Loop
-**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
+1. **Gather logs** via Woodcutting
+2. **Process logs into planks** at a workbench (with a saw) or at the sawmill (for credits) or via Plank Make (science mod)
+3. **Assemble planks into furniture** at a workbench
+4. **Sell furniture** for credits (furniture has higher value than input planks)
-## 2. Architecture
+## Architecture
-### Virtual Room System
+### Production System Integration
-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.
+Construction plugs into the existing unified production system. Recipes use `type: construction`, `station: [workbench]`, and optionally `tool: saw`. The `advanceProduction` function handles the full lifecycle without any construction-specific code.
-Each player who buys a house gets two virtual room IDs allocated:
-- **House entrance**: base ID
-- **Workshop**: base ID + 1
+### Recipe Tiers
-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.
+**Plank recipes** (4 tiers):
+- Regular (Lv1): logs → planks (20 ticks, 5xp)
+- Oak (Lv15): oak_logs → oak_planks (20 ticks, 10xp)
+- Teak (Lv35): teak_logs → teak_planks (20 ticks, 20xp)
+- Mahogany (Lv50): mahogany_logs → mahogany_planks (20 ticks, 30xp)
-**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)
+**Furniture recipes** (7 items across 4 tiers):
+- Wooden shelf (Lv4), table (Lv6), chair (Lv8)
+- Oak shelf (Lv18), table (Lv20)
+- Teak table (Lv38)
+- Mahogany table (Lv52)
-### Data Flow
+### Alternative Plank Sources
-```
-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
+1. **Sawmill operator** (room 171): Pays credits per log to get planks instantly. Fees: 5/10/20/40 credits. Uses the `sawmill` node action — a custom talk node action that process all logs at once.
-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
+2. **Plank Make mod** (Science Lv86): Converts logs to planks at 70% of sawmill cost (3/7/14/28 credits) plus junk cost. Awards both Science XP and Construction XP.
-Player uses workshop
- → findStation finds "workbench" object in virtual room
- → production system handles make/construct recipes normally
-```
+## Player Housing
-## 3. Commands
+Houses serve as a private space with a workbench and bank booth. Future expansions will allow adding rooms, furniture placement, and multi-city ownership.
-### `make` / `construct` (Active command, production-style)
+### House Purchase
+- Talk to the estate broker at the Construction Site (room 16)
+- Cost: 10 credits
+- Sets player flag: `owns_house_local_neighborhood: "local_neighborhood"`
-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.
+### House Access
+- Walk north from Construction Site to Local Neighborhood (room 170)
+- Use or talk to the Estate Directory
+- Enter your house via the directory
-**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
-```
+### House Rooms
+- Room 200: Entrance Hall (charging station)
+- Room 201: Workshop (workbench + bank booth)
-**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
-```
+Rooms are real YAML files in `data/rooms/player_housing/`. The room loader checks this directory as fallback after the main rooms directory.
-**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"},
-}
-```
+### Future: Multi-House Support
-This causes `advanceProduction` to be called for `make` action types automatically via the `default` case in `AdvanceActions` at `action.go:237`.
+Each housing area gets a unique player flag:
+- `owns_house_local_neighborhood: "local_neighborhood"` → rooms 200-201
+- `owns_house_industrial_district: "industrial_district"` → rooms 210-211 (future)
+- etc.
-## 4. New Files to Create
+The `getOwnedHouseRoom` function maps flag values to room IDs. The directory scans for area-specific flags.
-### Go Files
+## 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.
+| `internal/game/action_production.go` | Added `"construction"` to productionTypes |
+| `internal/game/cmd_construct.go` | `doConstruct` command handler |
+| `internal/game/cmd_estate_directory.go` | Directory handling + teleport |
+| `internal/game/action_talk.go` | Added sawmill node action + `processSawmill` |
+| `internal/game/action.go` | Added directory interception in StartAction |
+| `internal/game/cmd_look.go` | Added directory handling in doLookTarget |
+| `internal/game/science.go` | Added Plank Make mod definition |
+| `internal/game/cmd_trigger.go` | Added `triggerPlankMake` handler |
+| `internal/game/game.go` | Added `construct`/`make` to command dispatch |
+| `internal/action/behavior.go` | Added `Sawmill` field to NodeAction |
+| `internal/world/world.go` | Extended LoadRoom to support player_housing dir |
+| `internal/player/player.go` | Added `construct_all` option |