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