1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
|
# Construction Skill Implementation Plan
## 1. Overview
Construction adds player housing, a workshop for crafting planks and furniture, and a real estate broker NPC. Players buy a house from the broker for 10 credits, then visit their house (or other players' houses) through a Directory object in the Local Neighborhood room. Each house consists of two virtual rooms: a house entrance and a workshop. The workshop contains a workbench station where players slowly convert logs into planks (the `make`/`construct` command), then convert planks into sellable furniture items for Construction XP.
**Key design decisions:**
- Player houses are virtual rooms generated in memory, NOT YAML files on disk
- The workshop has a "workbench" object that acts as a production station
- Construction products are items with credit value (not room decorations)
- The house is just 2 rooms (entrance + workshop) for now
- No Runescape-style dismantling — items are final products
**Existing state:**
- Room 16 is "Construction Site" (`data/rooms/16.yaml`) with exits east:17, west:15
- `Construction` skill already defined in `internal/player/player.go` (line 27) with abbreviation `"con"` (line 59)
- The production system in `action_production.go` is reusable for a new `"construction"` recipe type
## 2. Architecture
### Virtual Room System
Player houses exist only in memory. The `World.LoadRoom()` function (at `internal/world/world.go:306`) currently reads exclusively from disk. We add a **virtual room registry** so `LoadRoom` checks memory first before disk.
Each player who buys a house gets two virtual room IDs allocated:
- **House entrance**: base ID
- **Workshop**: base ID + 1
Room IDs for virtual rooms use the range **100000+** to avoid collision with YAML room files. A counter in `World` tracks the next available virtual room ID.
**Player flags used** (stored in character YAML `flags:` map):
- `has_house` (bool): Whether the player owns a house
- `house_room_id` (int): The virtual room ID of the player's house entrance
- `house_owner` (string): The player's character name (redundant but useful for Directory)
### Data Flow
```
Player buys house (talk broker)
→ set player_flag has_house: true
→ allocate virtual room IDs via housing.go
→ store house_room_id in player flags
→ save character
Player visits house (use directory)
→ read target player's character file for house_room_id
→ ensure virtual rooms exist in World registry
→ teleport player to house entrance
Player uses workshop
→ findStation finds "workbench" object in virtual room
→ production system handles make/construct recipes normally
```
## 3. Commands
### `make` / `construct` (Active command, production-style)
These are aliases for the same command. They work like `smith` — require a workbench station in the room, show a production table of available recipes, and use the unified production cycle.
**Classification** — add to `classifyCommand()` in `internal/game/game.go:136`:
```go
case "get", "take", "grab", "pick", "drop",
"attack", "kill",
"north", "n", "south", "s", "east", "e",
"west", "w", "up", "u", "down", "d",
"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct":
return ClassActive
```
**Dispatch** — add to `executeCommand()` in `internal/game/game.go` (after the `craft` case around line 380):
```go
case "make", "construct":
g.doMake(sess, strings.Join(args, " "))
return
```
**AdvanceActions** — the production system already handles any action type registered in `productionActionTypes` (via the `productionTypes` map in `action_production.go:29`). Add the entry:
```go
var productionTypes = map[string]productionTypeInfo{
// ... existing entries ...
"construction": {"make", "construction"},
}
```
This causes `advanceProduction` to be called for `make` action types automatically via the `default` case in `AdvanceActions` at `action.go:237`.
## 4. New Files to Create
### Go Files
| File | Purpose |
|------|---------|
| `internal/game/cmd_make.go` | `doMake()` command handler for `make`/`construct` |
| `internal/game/housing.go` | Virtual room generation, Directory logic, homeowner scanning |
### YAML Data Files
| File | Purpose |
|------|---------|
| `data/rooms/150.yaml` | Local Neighborhood room (north of room 16) |
| `data/objects/directory.yaml` | Directory object definition |
| `data/objects/workbench.yaml` | Workbench station object definition |
| `data/mobs/real_estate_broker.yaml` | Real estate broker mob definition |
| `data/behaviors/broker_talk.yaml` | Broker talk behavior (buy house dialog) |
| `data/items/plank.yaml` | Regular plank item |
| `data/items/oak_plank.yaml` | Oak plank item |
| `data/items/teak_plank.yaml` | Teak plank item |
| `data/items/mahogany_plank.yaml` | Mahogany plank item |
| `data/items/wooden_shelf.yaml` | Wooden shelf (furniture item) |
| `data/items/wooden_table.yaml` | Wooden table (furniture item) |
| `data/items/wooden_chair.yaml` | Wooden chair (furniture item) |
| `data/items/wooden_bench.yaml` | Wooden bench (furniture item) |
| `data/items/oak_shelf.yaml` | Oak shelf (furniture item) |
| `data/items/oak_table.yaml` | Oak table (furniture item) |
| `data/items/oak_chair.yaml` | Oak chair (furniture item) |
| `data/items/oak_bench.yaml` | Oak bench (furniture item) |
| `data/items/teak_shelf.yaml` | Teak shelf (furniture item) |
| `data/items/teak_table.yaml` | Teak table (furniture item) |
| `data/items/teak_chair.yaml` | Teak chair (furniture item) |
| `data/items/teak_bench.yaml` | Teak bench (furniture item) |
| `data/items/mahogany_shelf.yaml` | Mahogany shelf (furniture item) |
| `data/items/mahogany_table.yaml` | Mahogany table (furniture item) |
| `data/items/mahogany_chair.yaml` | Mahogany chair (furniture item) |
| `data/items/mahogany_bench.yaml` | Mahogany bench (furniture item) |
| `data/recipes/construct_plank.yaml` | Logs → plank recipe |
| `data/recipes/construct_oak_plank.yaml` | Oak logs → oak plank recipe |
| `data/recipes/construct_teak_plank.yaml` | Teak logs → teak plank recipe |
| `data/recipes/construct_mahogany_plank.yaml` | Mahogany logs → mahogany plank recipe |
| `data/recipes/construct_wooden_shelf.yaml` | Plank → wooden shelf recipe |
| `data/recipes/construct_wooden_table.yaml` | Plank → wooden table recipe |
| `data/recipes/construct_wooden_chair.yaml` | Plank → wooden chair recipe |
| `data/recipes/construct_wooden_bench.yaml` | Plank → wooden bench recipe |
| `data/recipes/construct_oak_shelf.yaml` | Oak plank → oak shelf recipe |
| `data/recipes/construct_oak_table.yaml` | Oak plank → oak table recipe |
| `data/recipes/construct_oak_chair.yaml` | Oak plank → oak chair recipe |
| `data/recipes/construct_oak_bench.yaml` | Oak plank → oak bench recipe |
| `data/recipes/construct_teak_shelf.yaml` | Teak plank → teak shelf recipe |
| `data/recipes/construct_teak_table.yaml` | Teak plank → teak table recipe |
| `data/recipes/construct_teak_chair.yaml` | Teak plank → teak chair recipe |
| `data/recipes/construct_teak_bench.yaml` | Teak plank → teak bench recipe |
| `data/recipes/construct_mahogany_shelf.yaml` | Mahogany plank → mahogany shelf recipe |
| `data/recipes/construct_mahogany_table.yaml` | Mahogany plank → mahogany table recipe |
| `data/recipes/construct_mahogany_chair.yaml` | Mahogany plank → mahogany chair recipe |
| `data/recipes/construct_mahogany_bench.yaml` | Mahogany plank → mahogany bench recipe |
| `data/help/make.yaml` | Help topic for make/construct command |
| `data/help/construction.yaml` | Help topic for construction skill |
## 5. Code Changes to Existing Files
### `internal/game/game.go`
**1. `classifyCommand()` (line 136)** — Add `"make"` and `"construct"` to the Active command case:
```go
// Change this line (around line 150):
"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft":
// To:
"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct":
```
**2. `executeCommand()` (around line 379)** — Add dispatch case after `craft`:
```go
case "make", "construct":
g.doMake(sess, strings.Join(args, " "))
return
```
### `internal/game/action_production.go`
**3. `productionTypes` map (line 29)** — Add construction entry:
```go
var productionTypes = map[string]productionTypeInfo{
"cooking": {"cook", "cooking"},
"smelting": {"smelt", "smelting"},
"smithing": {"smith", "smithing"},
"crafting": {"craft", "crafting"},
"combine": {"combine", "combining"},
"fletching": {"fletch", "fletching"},
"construction": {"make", "construction"},
}
```
This single addition causes the entire production cycle (`startProduction`, `advanceProduction`, `canContinueProduction`) to work automatically for `type: "construction"` recipes. The `init()` function at line 40 will register `"make"` in `productionActionTypes`, and the `default` case in `AdvanceActions` at `action.go:237` will route to `advanceProduction`.
### `internal/world/world.go`
**4. Add virtual room registry** — Add a `virtualRooms` map and a `nextVirtualID` counter to the `World` struct (line 39):
```go
type World struct {
dataDir string
mu sync.Mutex
groundItems map[int][]*groundEntry
seeded map[int]bool
objStates map[string]*ObjState
objMoves []ObjMove
virtualRooms map[int]*Room // NEW: in-memory rooms
nextVirtualID int // NEW: next available virtual room ID
}
```
**5. Initialize in `New()` (line 297)**:
```go
func New(dataDir string) *World {
return &World{
dataDir: dataDir,
groundItems: make(map[int][]*groundEntry),
seeded: make(map[int]bool),
objStates: make(map[string]*ObjState),
virtualRooms: make(map[int]*Room),
nextVirtualID: 100000,
}
}
```
**6. Modify `LoadRoom()` (line 306)** — Check virtual rooms first:
```go
func (w *World) LoadRoom(id int) (*Room, error) {
w.mu.Lock()
if vr, ok := w.virtualRooms[id]; ok {
w.mu.Unlock()
return vr, nil
}
w.mu.Unlock()
// existing disk-loading code follows...
path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id))
// ...
}
```
**7. Add virtual room management methods**:
```go
func (w *World) RegisterVirtualRoom(room *Room) {
w.mu.Lock()
defer w.mu.Unlock()
w.virtualRooms[room.ID] = room
}
func (w *World) AllocateVirtualID() int {
w.mu.Lock()
defer w.mu.Unlock()
id := w.nextVirtualID
w.nextVirtualID += 2 // reserve 2 IDs per house (entrance + workshop)
return id
}
func (w *World) HasVirtualRoom(id int) bool {
w.mu.Lock()
defer w.mu.Unlock()
_, ok := w.virtualRooms[id]
return ok
}
```
### `internal/player/store.go`
**8. Add `ListHomeowners()` method** — Scan all character files for `has_house` flag:
```go
func (s *AccountStore) ListHomeowners() []string {
dir := filepath.Join(s.dataDir, "players", "characters")
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
continue
}
charName := strings.TrimSuffix(e.Name(), ".yaml")
p, err := s.LoadCharacter(charName)
if err != nil {
continue
}
if p.Flags != nil {
if hasHouse, ok := p.Flags["has_house"].(bool); ok && hasHouse {
names = append(names, p.Name)
}
}
}
sort.Strings(names)
return names
}
```
This requires adding `"sort"` to the imports in `store.go`.
### `internal/player/player.go`
**9. Add `make_all` option** — Add to `OptionDefs` slice (around line 134, after the existing `*_all` options):
```go
{"make_all", OptBool, false, nil, "Auto-start construction when only one product is possible"},
```
### `internal/game/game.go` (HandleSession)
**10. Add `StateVisitHouse` handling** — In `HandleSession()` (line 92), add a case for the new session state that the Directory object will use:
```go
case net.StateVisitHouse:
g.handleVisitHouseInput(sess, input)
```
### `internal/net/server.go`
**11. Add `StateVisitHouse` constant** — Add to the session state constants (around line 39):
```go
StateVisitHouse
```
## 6. Real Estate Broker
The broker is a mob NPC placed in the Construction Site room (room 16). Uses the existing talk behavior system.
### Mob Definition: `data/mobs/real_estate_broker.yaml`
```yaml
id: real_estate_broker
name: Real Estate Broker
description: "A smartly dressed broker clutching a clipboard and a ring of keys. She looks eager to make a sale."
behavior: broker_talk
unique: true
protected: true
hp: 50
attack: 1
strength: 1
defense: 1
speed: 5
aggressive: false
respawn_ticks: 30
idle_descriptions:
- "flips through property listings on a clipboard"
- "jingles a ring of keys"
- "adjusts her collar and checks her watch"
- "polishes a small 'SOLD' stamp"
```
### Talk Behavior: `data/behaviors/broker_talk.yaml`
```yaml
id: broker_talk
type: talk
nodes:
start:
message: "\"Welcome! I'm the local real estate broker. Looking for a place to call home?\""
options:
- text: "\"I'd like to buy a house.\""
goto: buy_offer
condition:
all_of:
- player_flag: has_house
not: true
- min_credits: 10
- text: "\"I'd like to buy a house.\""
goto: no_credits
condition:
all_of:
- player_flag: has_house
not: true
- min_credits: 10
not: true
- text: "\"I already own a house.\""
goto: already_own
condition:
player_flag: has_house
value: true
- text: "\"Just looking around.\""
end: true
buy_offer:
message: "\"Excellent! I have a lovely starter home available in the Local Neighborhood — just north of here. It comes with a workshop, perfect for construction projects. The price is 10 credits. Shall I draw up the paperwork?\""
options:
- text: "\"Yes, I'll take it!\""
goto: purchase_complete
- text: "\"Let me think about it.\""
end: true
purchase_complete:
message: "\"Congratulations! Here are your keys. Your new home is in the Local Neighborhood, just north of here. Use the Directory there to find your house. Happy building!\""
action:
cost: 10
set_player_flags:
has_house: true
options:
- text: "\"Thanks!\""
end: true
no_credits:
message: "\"I'm afraid the starter home costs 10 credits. Come back when you've saved up!\""
options:
- text: "\"I'll be back.\""
end: true
already_own:
message: "\"You already own a home! Head to the Local Neighborhood north of here and use the Directory to visit it.\""
options:
- text: "\"Thanks for the reminder.\""
end: true
```
**Note on house room allocation:** The talk behavior sets `has_house: true` via `set_player_flags`. The actual virtual room ID allocation happens lazily the **first time** the player visits their house via the Directory (in `housing.go`). This avoids needing to extend `applyNodeAction` — the existing `set_player_flags` and `cost` handling in `action_talk.go:165-218` handles everything.
### Update Room 16: `data/rooms/16.yaml`
```yaml
id: 16
name: "Construction Site"
description: "A half-built structure surrounded by planks, nails, and blueprints. A {130}real estate broker{/} stands near a model home display, eager to chat."
exits:
north: 150
east: 17
west: 15
mobs:
- id: real_estate_broker
```
Changes from current:
- Added `north: 150` exit to Local Neighborhood
- Updated description to mention the broker
- Added broker mob
## 7. Local Neighborhood Room
### `data/rooms/150.yaml`
```yaml
id: 150
name: "Local Neighborhood"
description: "A quiet residential street lined with small houses. A large {45}directory board{/} stands at the entrance, listing all the homeowners in the area. The construction site lies to the south."
exits:
south: 16
objects:
- id: directory
```
## 8. Directory Object
### `data/objects/directory.yaml`
```yaml
id: directory
name: directory
color: "45"
description: "A large board listing all homeowners in the neighborhood. Use it to visit someone's house."
inroom_description: "A large directory board lists the local homeowners."
```
The Directory has two interactions:
1. **`look directory`** — Shows a list of all homeowners (handled by custom logic in `cmd_look.go`)
2. **`use directory`** — Prompts "Visit whose house (enter for yours):" and teleports (handled by custom logic in `housing.go`)
### Implementation in `internal/game/housing.go`
This file contains all housing-related logic.
```go
package game
import (
"fmt"
"sort"
"strings"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
const neighborhoodRoomID = 150
func (g *Game) isDirectoryObject(defID string) bool {
return defID == "directory"
}
func (g *Game) listHomeowners() []string {
return g.AccountStore.ListHomeowners()
}
func (g *Game) lookDirectory(sess *net.Session) {
owners := g.listHomeowners()
if len(owners) == 0 {
sess.WriteLine("The directory is empty — no one has bought a house yet.")
return
}
sess.WriteLine("The directory lists the following homeowners:")
for _, name := range owners {
sess.WriteLine(fmt.Sprintf(" - %s", name))
}
}
func (g *Game) useDirectory(sess *net.Session) {
p := sess.Player.(*player.Player)
_ = p
sess.State = net.StateVisitHouse
sess.Write("Visit whose house (enter for yours): ")
}
func (g *Game) handleVisitHouseInput(sess *net.Session, input string) {
sess.State = net.StateGame
p := sess.Player.(*player.Player)
input = strings.TrimSpace(input)
var targetName string
if input == "" {
if p.Flags == nil {
sess.WriteLine("You don't own a house.")
g.reprompt(sess)
return
}
hasHouse, _ := p.Flags["has_house"].(bool)
if !hasHouse {
sess.WriteLine("You don't own a house. Talk to the Real Estate Broker at the Construction Site.")
g.reprompt(sess)
return
}
targetName = p.Name
} else {
found := false
for _, name := range g.listHomeowners() {
if strings.EqualFold(name, input) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(input)) {
targetName = name
found = true
break
}
}
if !found {
sess.WriteLine(fmt.Sprintf("%s doesn't own a house here.", input))
g.reprompt(sess)
return
}
}
roomID := g.ensurePlayerHouse(targetName)
if roomID == 0 {
sess.WriteLine("Something went wrong finding that house.")
g.reprompt(sess)
return
}
p.RoomID = roomID
g.AccountStore.SaveCharacter(p)
g.World.SeedGroundItems(p.RoomID)
g.seedRoomObjects(p.RoomID)
if g.Hub != nil {
g.Hub.EnterRoom(sess, p.RoomID)
}
g.doLook(sess)
g.writePrompt(sess)
}
func (g *Game) ensurePlayerHouse(charName string) int {
target, err := g.AccountStore.LoadCharacter(charName)
if err != nil {
return 0
}
if target.Flags == nil {
return 0
}
// Check if the character already has a room ID allocated
if rid, ok := target.Flags["house_room_id"]; ok {
var roomID int
switch v := rid.(type) {
case int:
roomID = v
case float64:
roomID = int(v)
}
if roomID > 0 && g.World.HasVirtualRoom(roomID) {
return roomID
}
// Room ID stored but virtual rooms not registered (server restart) — regenerate
if roomID > 0 {
g.registerHouseRooms(roomID, charName)
return roomID
}
}
// Allocate new virtual room IDs
roomID := g.World.AllocateVirtualID()
target.Flags["house_room_id"] = roomID
g.AccountStore.SaveCharacter(target)
// If this is the current player, also update their in-memory flags
// (the caller may have a different Player pointer)
g.registerHouseRooms(roomID, charName)
return roomID
}
func (g *Game) registerHouseRooms(baseID int, ownerName string) {
workshopID := baseID + 1
entrance := &world.Room{
ID: baseID,
Name: fmt.Sprintf("%s's House", ownerName),
Description: fmt.Sprintf("A cozy starter home belonging to %s. The walls are bare but full of potential. A doorway to the west leads to a workshop.", ownerName),
Exits: map[world.ExitDir]world.ExitDef{
world.South: {Room: neighborhoodRoomID},
world.West: {Room: workshopID},
},
}
workshop := &world.Room{
ID: workshopID,
Name: "Workshop",
Description: "A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls.",
Exits: map[world.ExitDir]world.ExitDef{
world.East: {Room: baseID},
},
Objects: []world.RoomObject{
{ID: "workbench"},
},
}
g.World.RegisterVirtualRoom(entrance)
g.World.RegisterVirtualRoom(workshop)
}
```
### Hooking `look directory` into `cmd_look.go`
In `doLookTarget()` in `internal/game/cmd_look.go`, add a check: if the target matches a directory object in the current room, call `g.lookDirectory(sess)` instead of (or in addition to) showing the object description.
Find the section in `doLookTarget` that handles looking at objects. After the object is found and its description is displayed, add:
```go
// After displaying the object description for "directory"
if obj.ID == "directory" {
g.lookDirectory(sess)
}
```
This is an addition to the normal look flow — the object's `description` field text is shown first ("A large board listing all homeowners..."), then the dynamic homeowner list is appended.
### Hooking `use directory` into `cmd_use.go`
In `doUse()` in `internal/game/cmd_use.go`, the flow already routes through `StartAction` for object interactions. However, the Directory doesn't have a behavior YAML — it needs a custom handler.
The cleanest approach: add a `use_interactions` entry on the directory object that triggers a custom action, OR handle it as a special case in `doUse`. Since the directory needs a prompt state (StateVisitHouse), handle it as a special case:
In `cmd_use.go`, when `use directory` is entered and the directory object is found in the room, call `g.useDirectory(sess)` instead of routing through the behavior system. The simplest hook point is in `doUse` — after identifying the target is the `directory` object, short-circuit:
```go
if target object's defID == "directory" {
g.useDirectory(sess)
return
}
```
Alternatively, add a check in `StartAction` (`action.go:45`) that intercepts `directory` before behavior lookup. The `doUse` approach is cleaner since `use` on its own already has custom item-on-object logic.
## 9. Player House Rooms
### Dynamic Room Generation
Virtual rooms are `*world.Room` structs stored in `World.virtualRooms`. They are created on-demand by `ensurePlayerHouse()` in `housing.go`.
**House Entrance Room:**
- Name: `"<PlayerName>'s House"`
- Description: `"A cozy starter home belonging to <PlayerName>. The walls are bare but full of potential. A doorway to the west leads to a workshop."`
- Exits: `south → 150` (Local Neighborhood), `west → workshopID`
- No objects, no mobs, no spawns
**Workshop Room:**
- Name: `"Workshop"`
- Description: `"A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls."`
- Exits: `east → entranceID`
- Objects: `[{id: "workbench"}]`
### Server Restart Handling
Virtual rooms are lost on server restart (they live in memory). When a player visits a house after restart, `ensurePlayerHouse()` checks if the virtual rooms exist. If not, it re-registers them using the stored `house_room_id` from the player's flags. This is seamless — the first visit after restart recreates the rooms.
The `AllocateVirtualID()` counter also resets on restart. To prevent ID collisions, on startup the system should scan all character files for existing `house_room_id` values and set `nextVirtualID` above the maximum found. Add this to `Game.New()` or a separate init function:
```go
func (g *Game) initHousingIDs() {
dir := filepath.Join(g.dataDir, "players", "characters")
entries, _ := os.ReadDir(dir)
maxID := 100000
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
continue
}
name := strings.TrimSuffix(e.Name(), ".yaml")
p, err := g.AccountStore.LoadCharacter(name)
if err != nil {
continue
}
if p.Flags != nil {
if rid, ok := p.Flags["house_room_id"]; ok {
var id int
switch v := rid.(type) {
case int:
id = v
case float64:
id = int(v)
}
if id+2 > maxID {
maxID = id + 2
}
}
}
}
g.World.SetNextVirtualID(maxID)
}
```
Add `SetNextVirtualID` to `World`:
```go
func (w *World) SetNextVirtualID(id int) {
w.mu.Lock()
defer w.mu.Unlock()
if id > w.nextVirtualID {
w.nextVirtualID = id
}
}
```
Call `g.initHousingIDs()` after `Game.New()` in `cmd/mud/main.go`, before the server starts accepting connections.
### Map Integration
The BFS map builder in `map.go` calls `World.LoadRoom()` to traverse exits. Virtual rooms will automatically be included since `LoadRoom` checks the virtual registry. Player house rooms will appear on the map when the player is inside them.
## 10. Workshop
### Workbench Object: `data/objects/workbench.yaml`
```yaml
id: workbench
name: workbench
color: "137"
description: "A sturdy wooden workbench with a built-in vice, saw guides, and tool racks. Perfect for construction projects."
inroom_description: "A sturdy workbench dominates the center of the room."
```
The workbench has no behavior — it serves purely as a station object. `findStation()` in `stations.go:3` searches for objects in the room by `defID`. Construction recipes list `station: [workbench]`, and `findStation` will match the workbench's `defID` of `"workbench"`.
### How the Workshop Works
1. Player enters workshop (east exit from house entrance)
2. `seedRoomObjects` is called, which calls `World.LoadRoom(workshopID)` — returns the virtual room — and then calls `EnsureObjectStates` for the `workbench` object
3. Player types `make` or `construct`
4. `doMake()` calls `findStation(p.RoomID, []string{"workbench"})` — finds the workbench
5. Recipes of `type: "construction"` with `station: [workbench]` are loaded and filtered
6. Player picks a recipe from the production table
7. Unified production cycle runs: consume materials, wait ticks, produce output, award XP
## 11. Items
### Plank Items
#### `data/items/plank.yaml`
```yaml
id: plank
name: plank
color: "137"
description: "A carefully shaped wooden plank, ready for construction."
value: 5
stackable: false
```
#### `data/items/oak_plank.yaml`
```yaml
id: oak_plank
name: oak plank
color: "143"
description: "A sturdy oak plank, suitable for quality furniture."
value: 15
stackable: false
```
#### `data/items/teak_plank.yaml`
```yaml
id: teak_plank
name: teak plank
color: "179"
description: "A fine teak plank with a beautiful grain."
value: 40
stackable: false
```
#### `data/items/mahogany_plank.yaml`
```yaml
id: mahogany_plank
name: mahogany plank
color: "124"
description: "A rich mahogany plank, the finest building material."
value: 100
stackable: false
```
### Furniture Items (Wooden)
#### `data/items/wooden_shelf.yaml`
```yaml
id: wooden_shelf
name: wooden shelf
color: "137"
description: "A simple wooden shelf. Could hold a few things."
value: 15
stackable: false
```
#### `data/items/wooden_table.yaml`
```yaml
id: wooden_table
name: wooden table
color: "137"
description: "A basic wooden table with four legs."
value: 25
stackable: false
```
#### `data/items/wooden_chair.yaml`
```yaml
id: wooden_chair
name: wooden chair
color: "137"
description: "A simple wooden chair. Not comfortable, but functional."
value: 20
stackable: false
```
#### `data/items/wooden_bench.yaml`
```yaml
id: wooden_bench
name: wooden bench
color: "137"
description: "A long wooden bench for sitting."
value: 30
stackable: false
```
### Furniture Items (Oak)
#### `data/items/oak_shelf.yaml`
```yaml
id: oak_shelf
name: oak shelf
color: "143"
description: "A sturdy oak shelf with dovetail joints."
value: 50
stackable: false
```
#### `data/items/oak_table.yaml`
```yaml
id: oak_table
name: oak table
color: "143"
description: "A solid oak table with a polished surface."
value: 80
stackable: false
```
#### `data/items/oak_chair.yaml`
```yaml
id: oak_chair
name: oak chair
color: "143"
description: "A well-crafted oak chair with carved armrests."
value: 65
stackable: false
```
#### `data/items/oak_bench.yaml`
```yaml
id: oak_bench
name: oak bench
color: "143"
description: "A heavy oak bench with smooth edges."
value: 95
stackable: false
```
### Furniture Items (Teak)
#### `data/items/teak_shelf.yaml`
```yaml
id: teak_shelf
name: teak shelf
color: "179"
description: "An elegant teak shelf with a warm finish."
value: 130
stackable: false
```
#### `data/items/teak_table.yaml`
```yaml
id: teak_table
name: teak table
color: "179"
description: "A beautiful teak table with intricate leg carvings."
value: 200
stackable: false
```
#### `data/items/teak_chair.yaml`
```yaml
id: teak_chair
name: teak chair
color: "179"
description: "A refined teak chair with a contoured seat."
value: 165
stackable: false
```
#### `data/items/teak_bench.yaml`
```yaml
id: teak_bench
name: teak bench
color: "179"
description: "A gorgeous teak bench with a curved backrest."
value: 240
stackable: false
```
### Furniture Items (Mahogany)
#### `data/items/mahogany_shelf.yaml`
```yaml
id: mahogany_shelf
name: mahogany shelf
color: "124"
description: "A luxurious mahogany shelf with beveled edges."
value: 320
stackable: false
```
#### `data/items/mahogany_table.yaml`
```yaml
id: mahogany_table
name: mahogany table
color: "124"
description: "A magnificent mahogany table with brass inlays."
value: 500
stackable: false
```
#### `data/items/mahogany_chair.yaml`
```yaml
id: mahogany_chair
name: mahogany chair
color: "124"
description: "A grand mahogany chair fit for a captain."
value: 400
stackable: false
```
#### `data/items/mahogany_bench.yaml`
```yaml
id: mahogany_bench
name: mahogany bench
color: "124"
description: "A stately mahogany bench with ornate scrollwork."
value: 600
stackable: false
```
## 12. Recipes
All construction recipes use `type: "construction"`, `station: [workbench]`, and have very slow wait times (20+ ticks for planks, 15+ ticks for furniture).
### Plank Recipes
#### `data/recipes/construct_plank.yaml`
```yaml
id: construct_plank
type: construction
level: 1
xp: 30
wait: 20
station: [workbench]
consume:
- items: [logs]
qty: 1
output: plank
message: "You carefully saw the logs into a plank."
```
#### `data/recipes/construct_oak_plank.yaml`
```yaml
id: construct_oak_plank
type: construction
level: 15
xp: 60
wait: 24
station: [workbench]
consume:
- items: [oak_logs]
qty: 1
output: oak_plank
message: "You saw the oak logs into a fine plank."
```
#### `data/recipes/construct_teak_plank.yaml`
```yaml
id: construct_teak_plank
type: construction
level: 35
xp: 100
wait: 28
station: [workbench]
consume:
- items: [teak_logs]
qty: 1
output: teak_plank
message: "You carefully work the teak logs into a smooth plank."
```
#### `data/recipes/construct_mahogany_plank.yaml`
```yaml
id: construct_mahogany_plank
type: construction
level: 50
xp: 150
wait: 32
station: [workbench]
consume:
- items: [mahogany_logs]
qty: 1
output: mahogany_plank
message: "You painstakingly shape the mahogany logs into a perfect plank."
```
### Wooden Furniture Recipes
#### `data/recipes/construct_wooden_shelf.yaml`
```yaml
id: construct_wooden_shelf
type: construction
level: 1
xp: 50
wait: 15
station: [workbench]
consume:
- items: [plank]
qty: 2
output: wooden_shelf
message: "You assemble a simple wooden shelf."
```
#### `data/recipes/construct_wooden_table.yaml`
```yaml
id: construct_wooden_table
type: construction
level: 5
xp: 80
wait: 18
station: [workbench]
consume:
- items: [plank]
qty: 3
output: wooden_table
message: "You build a sturdy wooden table."
```
#### `data/recipes/construct_wooden_chair.yaml`
```yaml
id: construct_wooden_chair
type: construction
level: 3
xp: 65
wait: 16
station: [workbench]
consume:
- items: [plank]
qty: 2
output: wooden_chair
message: "You craft a simple wooden chair."
```
#### `data/recipes/construct_wooden_bench.yaml`
```yaml
id: construct_wooden_bench
type: construction
level: 8
xp: 100
wait: 20
station: [workbench]
consume:
- items: [plank]
qty: 4
output: wooden_bench
message: "You construct a long wooden bench."
```
### Oak Furniture Recipes
#### `data/recipes/construct_oak_shelf.yaml`
```yaml
id: construct_oak_shelf
type: construction
level: 20
xp: 120
wait: 18
station: [workbench]
consume:
- items: [oak_plank]
qty: 2
output: oak_shelf
message: "You assemble a sturdy oak shelf."
```
#### `data/recipes/construct_oak_table.yaml`
```yaml
id: construct_oak_table
type: construction
level: 25
xp: 180
wait: 22
station: [workbench]
consume:
- items: [oak_plank]
qty: 3
output: oak_table
message: "You build a solid oak table."
```
#### `data/recipes/construct_oak_chair.yaml`
```yaml
id: construct_oak_chair
type: construction
level: 22
xp: 150
wait: 20
station: [workbench]
consume:
- items: [oak_plank]
qty: 2
output: oak_chair
message: "You craft a well-made oak chair."
```
#### `data/recipes/construct_oak_bench.yaml`
```yaml
id: construct_oak_bench
type: construction
level: 28
xp: 220
wait: 24
station: [workbench]
consume:
- items: [oak_plank]
qty: 4
output: oak_bench
message: "You construct a heavy oak bench."
```
### Teak Furniture Recipes
#### `data/recipes/construct_teak_shelf.yaml`
```yaml
id: construct_teak_shelf
type: construction
level: 40
xp: 200
wait: 20
station: [workbench]
consume:
- items: [teak_plank]
qty: 2
output: teak_shelf
message: "You assemble an elegant teak shelf."
```
#### `data/recipes/construct_teak_table.yaml`
```yaml
id: construct_teak_table
type: construction
level: 45
xp: 300
wait: 25
station: [workbench]
consume:
- items: [teak_plank]
qty: 3
output: teak_table
message: "You build a beautiful teak table."
```
#### `data/recipes/construct_teak_chair.yaml`
```yaml
id: construct_teak_chair
type: construction
level: 42
xp: 250
wait: 22
station: [workbench]
consume:
- items: [teak_plank]
qty: 2
output: teak_chair
message: "You craft a refined teak chair."
```
#### `data/recipes/construct_teak_bench.yaml`
```yaml
id: construct_teak_bench
type: construction
level: 48
xp: 360
wait: 28
station: [workbench]
consume:
- items: [teak_plank]
qty: 4
output: teak_bench
message: "You construct a gorgeous teak bench."
```
### Mahogany Furniture Recipes
#### `data/recipes/construct_mahogany_shelf.yaml`
```yaml
id: construct_mahogany_shelf
type: construction
level: 55
xp: 350
wait: 24
station: [workbench]
consume:
- items: [mahogany_plank]
qty: 2
output: mahogany_shelf
message: "You assemble a luxurious mahogany shelf."
```
#### `data/recipes/construct_mahogany_table.yaml`
```yaml
id: construct_mahogany_table
type: construction
level: 60
xp: 500
wait: 30
station: [workbench]
consume:
- items: [mahogany_plank]
qty: 3
output: mahogany_table
message: "You build a magnificent mahogany table."
```
#### `data/recipes/construct_mahogany_chair.yaml`
```yaml
id: construct_mahogany_chair
type: construction
level: 58
xp: 420
wait: 26
station: [workbench]
consume:
- items: [mahogany_plank]
qty: 2
output: mahogany_chair
message: "You craft a grand mahogany chair."
```
#### `data/recipes/construct_mahogany_bench.yaml`
```yaml
id: construct_mahogany_bench
type: construction
level: 65
xp: 600
wait: 32
station: [workbench]
consume:
- items: [mahogany_plank]
qty: 4
output: mahogany_bench
message: "You construct a stately mahogany bench."
```
## 13. Plank Making
Plank making is the core "slow grind" of Construction. Converting logs to planks is intentionally very slow (20-32 ticks per plank, meaning 12-19 seconds at default 600ms ticks) to make it feel like real labor.
| Log Type | Plank Output | Level | XP | Wait (ticks) | Wait (seconds at 600ms) |
|----------|-------------|-------|----|-------------|------------------------|
| logs | plank | 1 | 30 | 20 | 12.0 |
| oak_logs | oak_plank | 15 | 60 | 24 | 14.4 |
| teak_logs | teak_plank | 35 | 100 | 28 | 16.8 |
| mahogany_logs | mahogany_plank | 50 | 150 | 32 | 19.2 |
Planks are non-stackable (like logs), so inventory management is part of the challenge — you can only carry 28 items, so a full inventory of logs becomes a full inventory of planks.
No success/fail rolls — plank making always succeeds. This keeps it simple and avoids frustrating material loss on an already-slow process.
## 14. XP Table
### Plank Making XP
| Recipe | Level | XP | Ticks |
|--------|-------|----|-------|
| Plank | 1 | 30 | 20 |
| Oak Plank | 15 | 60 | 24 |
| Teak Plank | 35 | 100 | 28 |
| Mahogany Plank | 50 | 150 | 32 |
### Furniture XP
| Recipe | Level | XP | Planks | Ticks |
|--------|-------|----|--------|-------|
| Wooden Shelf | 1 | 50 | 2 | 15 |
| Wooden Chair | 3 | 65 | 2 | 16 |
| Wooden Table | 5 | 80 | 3 | 18 |
| Wooden Bench | 8 | 100 | 4 | 20 |
| Oak Shelf | 20 | 120 | 2 | 18 |
| Oak Chair | 22 | 150 | 2 | 20 |
| Oak Table | 25 | 180 | 3 | 22 |
| Oak Bench | 28 | 220 | 4 | 24 |
| Teak Shelf | 40 | 200 | 2 | 20 |
| Teak Chair | 42 | 250 | 2 | 22 |
| Teak Table | 45 | 300 | 3 | 25 |
| Teak Bench | 48 | 360 | 4 | 28 |
| Mahogany Shelf | 55 | 350 | 2 | 24 |
| Mahogany Chair | 58 | 420 | 2 | 26 |
| Mahogany Table | 60 | 500 | 3 | 30 |
| Mahogany Bench | 65 | 600 | 4 | 32 |
### XP/Hour Efficiency (approximate, at 600ms ticks)
| Method | XP/tick | XP/hour |
|--------|---------|---------|
| Regular planks | 1.5 | 9,000 |
| Regular furniture (shelf) | 3.3 | 12,000 |
| Oak planks | 2.5 | 15,000 |
| Oak furniture (table) | 8.2 | 29,500 |
| Teak planks | 3.6 | 21,400 |
| Teak furniture (table) | 12.0 | 43,200 |
| Mahogany planks | 4.7 | 28,100 |
| Mahogany furniture (table) | 16.7 | 60,000 |
## 15. Help Files
### `data/help/make.yaml`
```yaml
name: "make"
category: "Skills"
description: |
Construct items at a workbench using the Construction skill.
Usage: make <item> Build a specific item
make Show all items you can make right now
construct Same as make
Construction requires a workbench, found in your house's
workshop. Buy a house from the Real Estate Broker at the
Construction Site, then visit it via the Directory in the
Local Neighborhood.
Plank making (logs to planks) is slow but reliable — no chance
of failure. Furniture crafting uses planks to produce sellable
items for credits.
At the prompt, type a product number or name to start making.
Partial names work if unambiguous. You can prefix with a count
to limit quantity (e.g., "3 shelf"). Press enter to repeat
the last product.
See also: construction
```
### `data/help/construction.yaml`
```yaml
name: "construction"
category: "Skills"
description: |
Construction is a production skill for building furniture and
other items from planks at a workbench.
Getting started:
1. Visit the Construction Site (south of the skill halls)
2. Talk to the Real Estate Broker to buy a house (10 credits)
3. Go north to the Local Neighborhood
4. Use the Directory to visit your house
5. Go west to the Workshop
6. Use "make" or "construct" to start building
Plank making: Convert logs into planks at the workbench. This
is very slow work. Different log types require different levels
and yield different plank types.
Furniture: Use planks to build furniture items (shelves, tables,
chairs, benches). These items have credit value and can be sold.
Log types: logs (level 1), oak (15), teak (35), mahogany (50)
Use "make" at a workbench to see available recipes. Items you
can't yet make are shown dimmed.
See also: make
```
## Implementation Order
For an AI agent implementing this, the recommended order is:
1. **YAML data files first** (no code changes needed to test creation):
- `data/objects/workbench.yaml`
- `data/objects/directory.yaml`
- `data/mobs/real_estate_broker.yaml`
- `data/behaviors/broker_talk.yaml`
- All `data/items/*.yaml` (planks + furniture)
- All `data/recipes/construct_*.yaml`
- `data/help/make.yaml` and `data/help/construction.yaml`
2. **Room YAML**:
- `data/rooms/150.yaml` (Local Neighborhood)
- Update `data/rooms/16.yaml` (Construction Site — add north exit, broker mob, update description)
3. **World virtual room system** (`internal/world/world.go`):
- Add `virtualRooms` map and `nextVirtualID` to `World` struct
- Update `New()` to initialize them
- Modify `LoadRoom()` to check virtual rooms first
- Add `RegisterVirtualRoom()`, `AllocateVirtualID()`, `HasVirtualRoom()`, `SetNextVirtualID()`
4. **Player store** (`internal/player/store.go`):
- Add `ListHomeowners()` method
5. **Housing logic** (`internal/game/housing.go` — new file):
- `isDirectoryObject()`, `listHomeowners()`, `lookDirectory()`
- `useDirectory()`, `handleVisitHouseInput()`
- `ensurePlayerHouse()`, `registerHouseRooms()`
- `initHousingIDs()`
6. **Session state** (`internal/net/server.go`):
- Add `StateVisitHouse` constant
7. **Production type** (`internal/game/action_production.go`):
- Add `"construction"` entry to `productionTypes` map
8. **Make command** (`internal/game/cmd_make.go` — new file):
- `doMake()` handler following `cmd_cook.go` / `cmd_craft.go` pattern
9. **Command dispatch** (`internal/game/game.go`):
- Add `"make"`, `"construct"` to `classifyCommand()` Active case
- Add `"make"`, `"construct"` case to `executeCommand()`
- Add `StateVisitHouse` case to `HandleSession()`
10. **Option** (`internal/player/player.go`):
- Add `make_all` option
11. **Look/Use hooks**:
- Hook `lookDirectory()` into object look flow in `cmd_look.go`
- Hook `useDirectory()` into `cmd_use.go` or `StartAction` for the directory object
12. **Startup init** (`cmd/mud/main.go`):
- Call `g.initHousingIDs()` after game creation
13. **Build and test**:
- `make vet` — verify no compilation errors
- `make test` — run test suite
- `make run` — manual testing
## `cmd_make.go` Full Implementation
```go
package game
import (
"fmt"
"strings"
"thehouseoficarus/internal/action"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
)
func (g *Game) doMake(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
g.CancelAction(p)
stationDefID, _ := g.findStation(p.RoomID, []string{"workbench"})
if stationDefID == "" {
sess.WriteLine("You need a workbench to make things. Visit your house's workshop.")
return
}
allRecipes, err := g.RecipeStore.LoadAll()
if err != nil {
sess.WriteLine("Error loading recipes.")
return
}
var makeRecipes []action.RecipeDef
for _, r := range allRecipes {
if r.Type != "construction" {
continue
}
if !stationMatch(stationDefID, r.Station) {
continue
}
makeRecipes = append(makeRecipes, r)
}
if len(makeRecipes) == 0 {
sess.WriteLine("There's nothing to make here.")
return
}
if input != "" {
g.doMakeWithInput(sess, p, makeRecipes, input)
return
}
lastRecipeID, _ := p.Flags["last_make"].(string)
if lastRecipeID != "" {
for i := range makeRecipes {
if makeRecipes[i].ID == lastRecipeID {
r := &makeRecipes[i]
if g.canDoMakeRecipe(p, r) {
g.startMakeRecipe(sess, p, r, 0)
return
}
break
}
}
}
available := g.availableMakeRecipes(p, makeRecipes)
if len(available) == 0 {
sess.WriteLine("You don't have any materials to make anything.")
return
}
if p.OptionBool("make_all") && len(available) == 1 {
g.startMakeRecipe(sess, p, &available[0], 0)
return
}
g.showProductionTable(sess, p, makeRecipes, "Construction", "construction", "last_make", "Make", false)
}
func (g *Game) doMakeWithInput(sess *net.Session, p *player.Player, makeRecipes []action.RecipeDef, input string) {
qty, productName := parseQty(input)
productName = strings.TrimSpace(productName)
var matched []action.RecipeDef
for _, r := range makeRecipes {
outDef, _ := g.ItemStore.Load(r.Output)
name := r.Output
if outDef != nil {
name = outDef.Name
}
if world.WordPrefixMatch(productName, name) {
matched = append(matched, r)
}
}
if len(matched) == 0 {
sess.WriteLine("You can't make that.")
return
}
var available []action.RecipeDef
for _, r := range matched {
if g.canDoMakeRecipe(p, &r) {
available = append(available, r)
}
}
if len(available) == 0 {
sess.WriteLine("You don't have the materials or level for that.")
return
}
if len(available) == 1 {
g.startMakeRecipe(sess, p, &available[0], qty)
return
}
g.showProductionTable(sess, p, available, "Construction", "construction", "last_make", "Make", false)
}
func (g *Game) canDoMakeRecipe(p *player.Player, r *action.RecipeDef) bool {
if p.Level(player.Construction) < r.Level {
return false
}
if !r.HasAllItemsQty(p.CountItem) {
return false
}
return true
}
func (g *Game) availableMakeRecipes(p *player.Player, allMake []action.RecipeDef) []action.RecipeDef {
var result []action.RecipeDef
for _, r := range allMake {
if r.HasAllItemsQty(p.CountItem) {
result = append(result, r)
}
}
return result
}
func (g *Game) startMakeRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) {
if p.Flags == nil {
p.Flags = make(map[string]any)
}
p.Flags["last_make"] = recipe.ID
g.AccountStore.SaveCharacter(p)
g.startProductionFromRecipe(sess, p, recipe, count)
}
```
## Edge Cases to Handle
1. **Player visits someone else's house** — The Directory allows visiting any homeowner's house. `ensurePlayerHouse(targetName)` loads the target's character file, not the visiting player's.
2. **Server restart** — Virtual rooms are gone. `ensurePlayerHouse()` detects the missing virtual room and re-registers it. `initHousingIDs()` ensures the ID counter doesn't re-allocate existing IDs.
3. **Player dies in their house** — Death teleports to room 1 (Town Square). Standard behavior, no special handling needed.
4. **Player quits inside house** — `room_id` is saved to character YAML. On next login, `LoadRoom()` is called — if virtual rooms aren't registered yet, the first `look` or `move` will fail gracefully. The `completeMove` / `doLook` code calls `LoadRoom`; if it returns an error, the player gets "You can't move from here." To handle this: on login/character connection, if the player's `room_id` >= 100000, call `ensurePlayerHouse` to register the virtual rooms before showing the room. Add this check to the character connection flow in `login_char.go`.
5. **Multiple players in the same house** — Works naturally. The Hub tracks sessions by room ID. Multiple players can be in the same virtual room. Chat (`say`) works normally.
6. **YAML flag type mismatch** — When `house_room_id` is loaded from YAML, integers may deserialize as `float64` or `int` depending on the YAML parser. The `ensurePlayerHouse` function handles both via type switch.
7. **Player doesn't have `has_house` but has `house_room_id`** — Shouldn't happen in normal flow, but `ensurePlayerHouse` checks both flags defensively.
8. **Inventory full during construction** — The standard production system already handles this: "Your inventory is too full!" and cancels the action (`action_production.go:362`).
9. **No logs/planks** — The `doMake` handler shows "You don't have any materials to make anything." Same pattern as `doCook`.
10. **Using directory outside neighborhood room** — The directory object only exists in room 150. `use` on a non-existent object in other rooms won't match. No special guard needed.
|