aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-09 05:27:10 -0400
committerhistoria <[not public]>2026-07-09 05:27:10 -0400
commitc705ae942573984784ef501bf8198f61f5206ddd (patch)
treec964066f3a244002bf75cb50a877630f46496f81 /internal
parent74153c7814fc4cef988066ef04e38731a943bc12 (diff)
downloadthehouseoficarus-c705ae942573984784ef501bf8198f61f5206ddd.tar.gz
refactor: simplify yaml, remove support for old scalar fields
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_room_insert_remove_test.go101
-rw-r--r--internal/admin/api_rooms.go4
-rw-r--r--internal/admin/static/intereditor.js19
-rw-r--r--internal/admin/static/map.js25
-rw-r--r--internal/behavior/behavior.go18
-rw-r--r--internal/behavior/desc.go19
-rw-r--r--internal/engine/tick.go21
-rw-r--r--internal/game/act.go28
-rw-r--r--internal/game/cmd_room_remove_test.go62
-rw-r--r--internal/game/core_course.go60
-rw-r--r--internal/game/core_course_test.go45
-rw-r--r--internal/game/core_flags.go7
-rw-r--r--internal/game/core_flagstore.go10
-rw-r--r--internal/game/map_test.go48
-rw-r--r--internal/game/sys_triggers.go5
-rw-r--r--internal/item/item.go17
-rw-r--r--internal/validate/grid_test.go72
-rw-r--r--internal/validate/local_test.go24
-rw-r--r--internal/world/grid_test.go38
-rw-r--r--internal/world/insert_remove_test.go68
-rw-r--r--internal/world/mob.go2
-rw-r--r--internal/world/room.go26
-rw-r--r--internal/world/room_test.go9
-rw-r--r--internal/world/trigger.go4
-rw-r--r--internal/world/trigger_store.go24
25 files changed, 291 insertions, 465 deletions
diff --git a/internal/admin/api_room_insert_remove_test.go b/internal/admin/api_room_insert_remove_test.go
index 5a3b28e..6d80595 100644
--- a/internal/admin/api_room_insert_remove_test.go
+++ b/internal/admin/api_room_insert_remove_test.go
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
+ "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/world"
)
@@ -93,8 +94,8 @@ func postRemove(t *testing.T, s *AdminServer, body string) (map[string]any, int)
// rewires A and B onto it, and pushes an undo entry that restores everything.
func TestInsertSuccess(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n west: 1\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n west: {room: 1}\n",
})
resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
@@ -157,8 +158,8 @@ func TestInsertSuccess(t *testing.T) {
// TestInsertDefaultName confirms an empty name falls back to "Room #<id>".
func TestInsertDefaultName(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n west: 1\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n west: {room: 1}\n",
})
resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":""}`)
if code != http.StatusOK {
@@ -177,14 +178,14 @@ func TestInsertGridConflict(t *testing.T) {
// 1→east→2→east→3 and 1→south→4→east→5→east→6→east→7→north→8 at (3,0,0).
// Inserting between 1 and 2 pushes 3 to (3,0,0), colliding with 8.
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n south: 4\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n west: 2\n",
- 4: "name: D\nexits:\n east: 5\n north: 1\n",
- 5: "name: E\nexits:\n east: 6\n west: 4\n",
- 6: "name: F\nexits:\n east: 7\n west: 5\n",
- 7: "name: G\nexits:\n north: 8\n west: 6\n",
- 8: "name: H\nexits:\n south: 7\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n south: {room: 4}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
+ 4: "name: D\nexits:\n east: {room: 5}\n north: {room: 1}\n",
+ 5: "name: E\nexits:\n east: {room: 6}\n west: {room: 4}\n",
+ 6: "name: F\nexits:\n east: {room: 7}\n west: {room: 5}\n",
+ 7: "name: G\nexits:\n north: {room: 8}\n west: {room: 6}\n",
+ 8: "name: H\nexits:\n south: {room: 7}\n",
})
resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"X"}`)
if code != http.StatusConflict {
@@ -210,9 +211,9 @@ func TestInsertGridConflict(t *testing.T) {
// undo/redo round-trip.
func TestRemoveSuccess(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n west: 2\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
})
resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
if code != http.StatusOK {
@@ -282,8 +283,8 @@ func TestRemoveGuards(t *testing.T) {
{
name: "no such exit",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n west: 1\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n west: {room: 1}\n",
},
body: `{"from":1,"dir":"north"}`,
want: "has no north exit",
@@ -291,7 +292,7 @@ func TestRemoveGuards(t *testing.T) {
{
name: "self loop",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 1\n",
+ 1: "name: A\nexits:\n east: {room: 1}\n",
},
body: `{"from":1,"dir":"east"}`,
want: "loops back",
@@ -299,9 +300,9 @@ func TestRemoveGuards(t *testing.T) {
{
name: "not an insert chain (B does not lead back)",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n",
- 3: "name: C\nexits:\n west: 2\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
},
body: `{"from":1,"dir":"east"}`,
want: "not an insert chain",
@@ -309,8 +310,8 @@ func TestRemoveGuards(t *testing.T) {
{
name: "dead end (B has no forward exit)",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n west: 1\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n west: {room: 1}\n",
},
body: `{"from":1,"dir":"east"}`,
want: "no room beyond",
@@ -318,10 +319,10 @@ func TestRemoveGuards(t *testing.T) {
{
name: "B has extra exits",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n north: 4\n",
- 3: "name: C\nexits:\n west: 2\n",
- 4: "name: D\nexits:\n south: 2\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n north: {room: 4}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
+ 4: "name: D\nexits:\n south: {room: 2}\n",
},
body: `{"from":1,"dir":"east"}`,
want: "other exits besides",
@@ -329,10 +330,10 @@ func TestRemoveGuards(t *testing.T) {
{
name: "C does not lead back to B",
rooms: map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n east: 4\n",
- 4: "name: D\nexits:\n west: 3\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n east: {room: 4}\n",
+ 4: "name: D\nexits:\n west: {room: 3}\n",
},
body: `{"from":1,"dir":"east"}`,
want: "does not lead back to",
@@ -363,12 +364,12 @@ func TestRemoveGridConflict(t *testing.T) {
// to (1,0,0); 6 (at 3's old south neighbor (2,1,0)) follows to (1,1,0)? No
// — the pull shifts the whole beyond-component; 6 lands on 5's cell (1,1,0).
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n south: 4\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n south: 6\n west: 2\n",
- 4: "name: D\nexits:\n east: 5\n north: 1\n",
- 5: "name: E\nexits:\n west: 4\n",
- 6: "name: F\nexits:\n north: 3\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n south: {room: 4}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n south: {room: 6}\n west: {room: 2}\n",
+ 4: "name: D\nexits:\n east: {room: 5}\n north: {room: 1}\n",
+ 5: "name: E\nexits:\n west: {room: 4}\n",
+ 6: "name: F\nexits:\n north: {room: 3}\n",
})
resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
if code != http.StatusConflict {
@@ -394,10 +395,10 @@ func TestRemoveGridConflict(t *testing.T) {
// room points into B, and that the message names the count.
func TestRemoveExtraInbound(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n west: 2\n",
- 9: "name: X\nexits:\n north: 2\n", // extra inbound edge into B
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
+ 9: "name: X\nexits:\n north: {room: 2}\n", // extra inbound edge into B
})
resp, code := postRemove(t, s, `{"from":1,"dir":"east"}`)
if code != http.StatusBadRequest {
@@ -418,12 +419,12 @@ func TestNextRoomIDNoCrossSubdirCollision(t *testing.T) {
t.Fatal(err)
}
// Room 1 in root, room 2 in a subdirectory "zone".
- writeRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
+ writeRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
zoneDir := filepath.Join(roomsDir, "zone")
if err := os.MkdirAll(zoneDir, 0o755); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(zoneDir, "2.yaml"), []byte("name: B\nexits:\n west: 1\n"), 0o644); err != nil {
+ if err := os.WriteFile(filepath.Join(zoneDir, "2.yaml"), []byte("name: B\nexits:\n west: {room: 1}\n"), 0o644); err != nil {
t.Fatal(err)
}
s := &AdminServer{
@@ -461,8 +462,8 @@ func testMobDef() *world.MobDef {
// instances for the (now-deleted) new room, via rebuildAfterUndo's stat check.
func TestUndoInsertClearsMobs(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n west: 1\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n west: {room: 1}\n",
})
resp, code := postInsert(t, s, `{"from":1,"dir":"east","name":"Mid"}`)
if code != http.StatusOK {
@@ -471,7 +472,7 @@ func TestUndoInsertClearsMobs(t *testing.T) {
newID := int(resp["room"].(map[string]any)["id"].(float64))
// Spawn a transient mob in the new room so the store has something to clear.
- inst := s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, newID, "")
+ inst := s.mobStore.SpawnTransient(testMobDef(), &behavior.SpawnMobConfig{ID: "testmob"}, newID, "")
if inst == nil {
t.Fatal("SpawnTransient returned nil")
}
@@ -494,9 +495,9 @@ func TestUndoInsertClearsMobs(t *testing.T) {
// for the re-deleted room.
func TestRedoRemoveClearsMobs(t *testing.T) {
s := newTestAdminServer(t, map[int]string{
- 1: "name: A\nexits:\n east: 2\n",
- 2: "name: B\nexits:\n east: 3\n west: 1\n",
- 3: "name: C\nexits:\n west: 2\n",
+ 1: "name: A\nexits:\n east: {room: 2}\n",
+ 2: "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n",
+ 3: "name: C\nexits:\n west: {room: 2}\n",
})
if _, code := postRemove(t, s, `{"from":1,"dir":"east"}`); code != http.StatusOK {
t.Fatal("remove failed")
@@ -510,7 +511,7 @@ func TestRedoRemoveClearsMobs(t *testing.T) {
if !roomExists(s, 2) {
t.Fatal("precondition: B should exist after undo")
}
- s.mobStore.SpawnTransient(testMobDef(), &world.SpawnMobConfig{ID: "testmob"}, 2, "")
+ s.mobStore.SpawnTransient(testMobDef(), &behavior.SpawnMobConfig{ID: "testmob"}, 2, "")
if got := len(s.mobStore.MobsInRoom(2)); got != 1 {
t.Fatalf("precondition: expected 1 mob in B, got %d", got)
}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 0925f8b..1339bcd 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -171,7 +171,7 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
}
if !linkOneWay {
- exits[string(opp)] = *linkFrom
+ exits[string(opp)] = map[string]any{"room": *linkFrom}
}
}
}
@@ -1081,7 +1081,7 @@ func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Reque
roomName := fmt.Sprintf("Room #%d", id)
m := map[string]any{
"name": roomName,
- "description": "An empty room.",
+ "description": []map[string]any{{"text": "An empty room."}},
}
newContent, writeErr := writeMapAsYAML(path, m)
if writeErr != nil {
diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js
index 4e48df1..b46c456 100644
--- a/internal/admin/static/intereditor.js
+++ b/internal/admin/static/intereditor.js
@@ -944,23 +944,6 @@ function ie_syncActEntry(ed, secId, secPath, idx) {
delete ed[secPath][idx].message;
}
-// ── Upgrade: migrate legacy message/condition/action on load ──
-
-function ie_upgradeItem(item) {
- if (!item) return;
- if (item.message && typeof item.message === 'string' && item.message.trim()) {
- if (!item.action || typeof item.action !== 'object') item.action = {};
- if (!item.action.message) item.action.message = item.message;
- delete item.message;
- }
- if (item.condition && typeof item.condition === 'string') {
- try { item.condition = JSON.parse(item.condition); } catch(e) { delete item.condition; }
- }
- if (item.action && typeof item.action === 'string') {
- try { item.action = JSON.parse(item.action); } catch(e) { delete item.action; }
- }
-}
-
// ── Shared interaction-style array-section renderer ──
//
// Replaces the duplicated `renderArraySection` (objecteditor.js) and
@@ -984,8 +967,6 @@ function ie_renderArraySection(ctx) {
var h = '';
arr.forEach(function(item, idx) {
- ie_upgradeItem(item);
-
visMap[idx] = visMap[idx] || {};
var vis = visMap[idx];
var showItem = vis.item_id || (item.item_id && item.item_id !== '');
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
index 3507e2d..aeb2d57 100644
--- a/internal/admin/static/map.js
+++ b/internal/admin/static/map.js
@@ -1433,11 +1433,9 @@ function renderExitsSection(r) {
var target = typeof exit === 'object' ? exit.room : exit;
var cond = (typeof exit === 'object' && exit.condition) ? exit.condition : null;
var bmsg = (typeof exit === 'object' && exit.blocked_message) ? exit.blocked_message : '';
- var setFlags = (typeof exit === 'object' && exit.set_flags) ? exit.set_flags : null;
- var setPlayerFlags = (typeof exit === 'object' && exit.set_player_flags) ? exit.set_player_flags : null;
var hidden = (typeof exit === 'object' && exit.hidden) ? exit.hidden : false;
var alwaysBlocked = (typeof exit === 'object' && exit.always_blocked) ? exit.always_blocked : false;
- var isConditional = !!(cond || bmsg || setFlags || setPlayerFlags || hidden || alwaysBlocked);
+ var isConditional = !!(cond || bmsg || hidden || alwaysBlocked);
var isBidi = mapData && mapData.links && mapData.links.some(function(l) {
return l.bidirectional && ((l.from === r.id && l.to === target) || (l.from === target && l.to === r.id));
});
@@ -1475,16 +1473,9 @@ function editExitCondition(dir) {
if (typeof exit !== 'object') { exit = {room: exit}; }
var cond = exit.condition || null;
var bmsg = exit.blocked_message || '';
- var setFlags = exit.set_flags || null;
- var setPlayerFlags = exit.set_player_flags || null;
var hidden = exit.hidden || false;
var alwaysBlocked = exit.always_blocked || false;
- var flagsStr = '';
- if (setFlags) flagsStr = Object.keys(setFlags).map(function(k) { return k + '=' + setFlags[k]; }).join(', ');
- var pflagsStr = '';
- if (setPlayerFlags) pflagsStr = Object.keys(setPlayerFlags).map(function(k) { return k + '=' + setPlayerFlags[k]; }).join(', ');
-
var parsed = parseCondition(cond);
var overlay = document.createElement('div');
@@ -1494,8 +1485,6 @@ function editExitCondition(dir) {
menu.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);min-width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;overflow-x:hidden;padding:14px;z-index:201';
var h = '<h3 style="margin-bottom:8px;font-size:13px">Exit: ' + esc(dir) + ' &rarr; #' + exit.room + '</h3>';
- h += '<div class="form-group"><label>Set Flags (key=value, comma separated)</label><input id="exitFlags" value="' + escAttr(flagsStr) + '"></div>';
- h += '<div class="form-group"><label>Set Player Flags (key=value, comma separated)</label><input id="exitPFlags" value="' + escAttr(pflagsStr) + '"></div>';
h += '<div class="form-group"><label>Blocked Message</label><textarea id="exitBmsg" rows="2">' + esc(bmsg) + '</textarea></div>';
h += '<div class="form-group"><label><input type="checkbox" id="exitHidden"' + (hidden ? ' checked' : '') + '> Hidden</label></div>';
h += '<div class="form-group"><label><input type="checkbox" id="exitAlwaysBlocked"' + (alwaysBlocked ? ' checked' : '') + '> Always Blocked (blocked, shown on map)</label></div>';
@@ -1540,7 +1529,7 @@ function parseCondition(cond) {
function extractClause(c) {
var not = c.not === true;
if (c.player_flag && c.player_flag !== '') return {type: 'player_flag', value: c.player_flag, not: not};
- if (c.flag && c.flag !== '') return {type: 'flag', value: c.flag, not: not};
+ if (c.global_flag && c.global_flag !== '') return {type: 'global_flag', value: c.global_flag, not: not};
if (c.has_item && c.has_item !== '') return {type: 'has_item', value: c.has_item, not: not};
if (c.min_credits && c.min_credits !== 0) return {type: 'min_credits', value: String(c.min_credits), not: not};
if (c.value && typeof c.value === 'object' && c.value.key) return {type: 'value', value: c.value.key + '=' + (c.value.value || ''), not: not};
@@ -1555,7 +1544,7 @@ function renderConditionClauses(menu) {
clauses.forEach(function(clause, i) {
h += '<div class="cond-clause" data-idx="' + i + '" style="display:flex;gap:4px;align-items:center;margin-bottom:4px">';
h += '<select class="cond-type" onchange="var c=this.parentElement;var m=this.closest(\'.exit-modal\');var idx=parseInt(c.getAttribute(\'data-idx\'));m._condClauses[idx].type=this.value;renderConditionClauses(m)">';
- ['player_flag','flag','value','has_item','min_credits'].forEach(function(t) {
+ ['player_flag','global_flag','value','has_item','min_credits'].forEach(function(t) {
h += '<option value="' + t + '"' + (clause.type === t ? ' selected' : '') + '>' + t + '</option>';
});
h += '</select>';
@@ -1640,10 +1629,6 @@ function saveExitCondition() {
exit.hidden = hiddenEl ? hiddenEl.checked : false;
var alwaysBlockedEl = document.getElementById('exitAlwaysBlocked');
exit.always_blocked = alwaysBlockedEl ? alwaysBlockedEl.checked : false;
- var flagsStr = document.getElementById('exitFlags');
- var pflagsStr = document.getElementById('exitPFlags');
- exit.set_flags = parseFlagInput(flagsStr ? flagsStr.value : '');
- exit.set_player_flags = parseFlagInput(pflagsStr ? pflagsStr.value : '');
var clauseEls = menu.querySelectorAll('.cond-clause');
clauseEls.forEach(function(el) { updateClauseData(el); });
@@ -1682,10 +1667,6 @@ function clearExitCondition() {
renderConditionClauses(menu);
var bmsg = document.getElementById('exitBmsg');
if (bmsg) bmsg.value = '';
- var ef = document.getElementById('exitFlags');
- if (ef) ef.value = '';
- var pf = document.getElementById('exitPFlags');
- if (pf) pf.value = '';
var hx = document.getElementById('exitHidden');
if (hx) hx.checked = false;
var ab = document.getElementById('exitAlwaysBlocked');
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index 69b91cd..58b5e6b 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -1,7 +1,5 @@
package behavior
-import "gopkg.in/yaml.v3"
-
type GatherConfig struct {
Skill string `yaml:"skill"`
Tools []string `yaml:"tools"`
@@ -106,8 +104,7 @@ func (s StepAction) IsTimed() bool {
}
// SpawnMobConfig configures a transient mob spawned by a trigger or on_enter
-// step. It accepts either a bare string (the mob id) or a full map at
-// unmarshal time.
+// step. The mob id is required; other fields are optional.
type SpawnMobConfig struct {
ID string `yaml:"id"`
OwnerOnly bool `yaml:"owner_only"`
@@ -116,19 +113,6 @@ type SpawnMobConfig struct {
DespawnTicks float64 `yaml:"despawn_ticks"`
}
-func (s *SpawnMobConfig) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var id string
- if err := value.Decode(&id); err != nil {
- return err
- }
- s.ID = id
- return nil
- }
- type raw SpawnMobConfig
- return value.Decode((*raw)(s))
-}
-
// ShopConfig is a root-level mob property (mob YAML `shop:`). Buy prices are
// always 100% of the item's value; sell prices follow the OSRS store formula:
//
diff --git a/internal/behavior/desc.go b/internal/behavior/desc.go
index ca4bf69..f6dbaf3 100644
--- a/internal/behavior/desc.go
+++ b/internal/behavior/desc.go
@@ -1,27 +1,8 @@
package behavior
-import "gopkg.in/yaml.v3"
-
type DescVariant struct {
Text string `yaml:"text"`
Condition *Condition `yaml:"condition,omitempty"`
}
type DescList []DescVariant
-
-func (dl *DescList) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var s string
- if err := value.Decode(&s); err != nil {
- return err
- }
- *dl = DescList{{Text: s}}
- return nil
- }
- var entries []DescVariant
- if err := value.Decode(&entries); err != nil {
- return err
- }
- *dl = entries
- return nil
-}
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index 7a422af..ff1bace 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -116,3 +116,24 @@ func ToTicks(base float64) int {
}
return floor
}
+
+func ValuesEqual(a, b any) bool {
+ ai, aok := NumericValue(a)
+ bi, bok := NumericValue(b)
+ if aok && bok {
+ return ai == bi
+ }
+ return a == b
+}
+
+func NumericValue(v any) (float64, bool) {
+ switch x := v.(type) {
+ case int:
+ return float64(x), true
+ case int64:
+ return float64(x), true
+ case float64:
+ return x, true
+ }
+ return 0, false
+}
diff --git a/internal/game/act.go b/internal/game/act.go
index ddeed04..91f05a8 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -7,6 +7,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
@@ -332,7 +333,7 @@ func flagMatches(present bool, val, want any, not bool) bool {
if want == nil {
match = present && isTruthy(val)
} else {
- match = present && valuesEqual(val, want)
+ match = present && engine.ValuesEqual(val, want)
}
if not {
return !match
@@ -340,31 +341,6 @@ func flagMatches(present bool, val, want any, not bool) bool {
return match
}
-// valuesEqual compares two any-typed values, normalizing numeric types (int,
-// int64, float64) so that e.g. int(3) == int64(3) == float64(3). Non-numeric
-// types fall back to ==.
-func valuesEqual(a, b any) bool {
- ai, aok := numericValue(a)
- bi, bok := numericValue(b)
- if aok && bok {
- return ai == bi
- }
- return a == b
-}
-
-// numericValue returns the value as a float64 if it is a numeric type.
-func numericValue(v any) (float64, bool) {
- switch x := v.(type) {
- case int:
- return float64(x), true
- case int64:
- return float64(x), true
- case float64:
- return x, true
- }
- return 0, false
-}
-
func isTruthy(v any) bool {
switch x := v.(type) {
case nil:
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
index 1868c30..a13af8a 100644
--- a/internal/game/cmd_room_remove_test.go
+++ b/internal/game/cmd_room_remove_test.go
@@ -85,9 +85,9 @@ func reloadRoom(t *testing.T, g *Game, id int) *world.Room {
func TestRoomRemoveSuccessPull(t *testing.T) {
dir := t.TempDir()
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -113,9 +113,9 @@ func TestRoomRemoveSuccessPull(t *testing.T) {
func TestRoomRemoveDiagonalPull(t *testing.T) {
dir := t.TempDir()
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n northeast: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n northeast: 3\n southwest: 1\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n southwest: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n northeast: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n northeast: {room: 3}\n southwest: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n southwest: {room: 2}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -145,9 +145,9 @@ func TestRoomRemoveNoExit(t *testing.T) {
func TestRoomRemoveNotInsertChain(t *testing.T) {
dir := t.TempDir()
// B does not lead back to A via west.
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -162,8 +162,8 @@ func TestRoomRemoveNotInsertChain(t *testing.T) {
func TestRoomRemoveDeadEndFarRoom(t *testing.T) {
dir := t.TempDir()
// B leads back to A but has no forward east exit.
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: {room: 1}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -177,10 +177,10 @@ func TestRoomRemoveDeadEndFarRoom(t *testing.T) {
func TestRoomRemoveOtherExitsInB(t *testing.T) {
dir := t.TempDir()
// B has east (fwd), west (back), AND up (extra).
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n up: 4\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
- writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n down: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n up: {room: 4}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n down: {room: 2}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -194,8 +194,8 @@ func TestRoomRemoveOtherExitsInB(t *testing.T) {
func TestRoomRemoveFarReciprocityBroken(t *testing.T) {
dir := t.TempDir()
// C's west does not point back to B.
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -210,10 +210,10 @@ func TestRoomRemoveFarReciprocityBroken(t *testing.T) {
func TestRoomRemoveExtraInboundEdge(t *testing.T) {
dir := t.TempDir()
// A clean chain A-east->B-east->C, but an unrelated room 5 also points to B.
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
- writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n north: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n north: {room: 2}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -228,12 +228,12 @@ func TestRoomRemoveExtraInboundEdge(t *testing.T) {
// pulls room 6 onto room 5's cell, which must be rejected.
func TestRoomRemoveOverlap(t *testing.T) {
dir := t.TempDir()
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n south: 4\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n south: 6\n west: 2\n")
- writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n east: 5\n north: 1\n")
- writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n west: 4\n")
- writeRemoveRoomFile(t, dir, 6, "name: F\nexits:\n north: 3\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n south: {room: 4}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n south: {room: 6}\n west: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n east: {room: 5}\n north: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n west: {room: 4}\n")
+ writeRemoveRoomFile(t, dir, 6, "name: F\nexits:\n north: {room: 3}\n")
g := newRemoveGame(t, dir)
sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1})
@@ -275,7 +275,7 @@ exits:
blocked_message: "A rock blocks the way."
hidden: true
`)
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
writeRemoveRoomFile(t, dir, 3, `name: C
exits:
west:
@@ -310,9 +310,9 @@ exits:
// moved to A and notified. The caller stays in A.
func TestRoomRemoveRelocatesPlayersInB(t *testing.T) {
dir := t.TempDir()
- writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: 2\n")
- writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: 3\n west: 1\n")
- writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: 2\n")
+ writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n")
+ writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n")
g := newRemoveGame(t, dir)
caller := newRemoveSession(&player.Player{Name: "builder", RoomID: 1})
diff --git a/internal/game/core_course.go b/internal/game/core_course.go
index 031eff5..6394b99 100644
--- a/internal/game/core_course.go
+++ b/internal/game/core_course.go
@@ -21,21 +21,17 @@ type ObstaclePhase struct {
}
// ObstacleDef is the YAML representation of a single agility obstacle.
-// Two equivalent forms are supported:
-// - Legacy: Messages (exactly 3) + TicksPerPhase + fail at phase index 1.
-// - Preferred: Phases, an explicit ordered list with per-phase delays and
-// an optional fail_check flag (at most one phase should carry it).
+// Phases is the ordered list of advancement steps, each with a per-phase
+// delay and an optional fail_check flag (at most one phase should carry it).
type ObstacleDef struct {
- RoomID int `yaml:"room_id"`
- Verb string `yaml:"verb"`
- XP int `yaml:"xp"`
- FailDamage [2]int `yaml:"fail_damage"`
- FailChance *float64 `yaml:"fail_chance,omitempty"`
- TicksPerPhase float64 `yaml:"ticks_per_phase"`
- Messages []string `yaml:"messages"`
- Phases []ObstaclePhase `yaml:"phases"`
- ExitDir string `yaml:"exit_dir"`
- OnFailRoom int `yaml:"on_fail,omitempty"`
+ RoomID int `yaml:"room_id"`
+ Verb string `yaml:"verb"`
+ XP int `yaml:"xp"`
+ FailDamage [2]int `yaml:"fail_damage"`
+ FailChance *float64 `yaml:"fail_chance,omitempty"`
+ Phases []ObstaclePhase `yaml:"phases"`
+ ExitDir string `yaml:"exit_dir"`
+ OnFailRoom int `yaml:"on_fail,omitempty"`
}
type CourseConfig struct {
@@ -140,33 +136,13 @@ func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo {
}
// resolvePhases converts an ObstacleDef into a normalized PhaseInfo list.
-// Preferred form: explicit Phases. Legacy form: Messages with a shared
-// TicksPerPhase and the failure check at the middle (index 1) phase.
func resolvePhases(obs ObstacleDef) []PhaseInfo {
- if len(obs.Phases) > 0 {
- phases := make([]PhaseInfo, 0, len(obs.Phases))
- for _, p := range obs.Phases {
- phases = append(phases, PhaseInfo{
- Message: p.Message,
- Delay: p.Delay,
- FailCheck: p.FailCheck,
- })
- }
- return phases
- }
- msgs := obs.Messages
- phases := make([]PhaseInfo, 0, len(msgs))
- for i, m := range msgs {
- var delay float64
- if i == 0 {
- delay = 0
- } else {
- delay = obs.TicksPerPhase
- }
+ phases := make([]PhaseInfo, 0, len(obs.Phases))
+ for _, p := range obs.Phases {
phases = append(phases, PhaseInfo{
- Message: m,
- Delay: delay,
- FailCheck: i == 1,
+ Message: p.Message,
+ Delay: p.Delay,
+ FailCheck: p.FailCheck,
})
}
return phases
@@ -197,12 +173,6 @@ func (cs *CourseStore) resolveExitTarget(roomID int, dir string) int {
return 0
}
switch x := v.(type) {
- case int:
- return x
- case int64:
- return int(x)
- case float64:
- return int(x)
case map[string]any:
if r, ok := x["room"]; ok {
switch y := r.(type) {
diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go
index 64ec8ad..93f49f5 100644
--- a/internal/game/core_course_test.go
+++ b/internal/game/core_course_test.go
@@ -6,7 +6,7 @@ import (
"testing"
)
-func TestResolvePhasesPrefersExplicitPhases(t *testing.T) {
+func TestResolvePhases(t *testing.T) {
obs := ObstacleDef{
RoomID: 5,
Verb: "climb",
@@ -15,9 +15,6 @@ func TestResolvePhasesPrefersExplicitPhases(t *testing.T) {
{Message: "middle", Delay: 2, FailCheck: true},
{Message: "end", Delay: 1.5},
},
- // Legacy fields set as a decoy; should be ignored when Phases present.
- TicksPerPhase: 99,
- Messages: []string{"ignored1", "ignored2", "ignored3"},
}
got := resolvePhases(obs)
if len(got) != 3 {
@@ -34,28 +31,6 @@ func TestResolvePhasesPrefersExplicitPhases(t *testing.T) {
}
}
-func TestResolvePhasesLegacyMigration(t *testing.T) {
- obs := ObstacleDef{
- RoomID: 7,
- Verb: "jump",
- TicksPerPhase: 2,
- Messages: []string{"a", "b", "c"},
- }
- got := resolvePhases(obs)
- if len(got) != 3 {
- t.Fatalf("expected 3 legacy phases, got %d", len(got))
- }
- if got[0].Message != "a" || got[0].Delay != 0 || got[0].FailCheck {
- t.Errorf("legacy phase 0 wrong: %+v", got[0])
- }
- if got[1].Message != "b" || got[1].Delay != 2 || !got[1].FailCheck {
- t.Errorf("legacy phase 1 must carry fail_check at index 1: %+v", got[1])
- }
- if got[2].Message != "c" || got[2].Delay != 2 || got[2].FailCheck {
- t.Errorf("legacy phase 2 wrong: %+v", got[2])
- }
-}
-
func TestCourseStoreLoadsWithExplicitFailChance(t *testing.T) {
dir := t.TempDir()
coursesDir := filepath.Join(dir, "courses")
@@ -85,8 +60,14 @@ obstacles:
verb: jump
xp: 14
fail_damage: [1, 3]
- messages: ["p0", "p1", "p2"]
- ticks_per_phase: 1
+ phases:
+ - message: "p0"
+ delay: 0
+ - message: "p1"
+ delay: 1
+ fail_check: true
+ - message: "p2"
+ delay: 1
`)
if err := os.WriteFile(filepath.Join(coursesDir, "testcourse.yaml"), yaml, 0644); err != nil {
t.Fatal(err)
@@ -116,16 +97,16 @@ obstacles:
t.Errorf("first obstacle should have no completion xp, got %d", o1.CompletionXP)
}
- // Obstacle 2: legacy migration, derived fail chance (nil)
+ // Obstacle 2: explicit phases, derived fail chance (nil)
o2 := cs.GetObstacle(102)
if o2 == nil {
t.Fatal("expected obstacle for room 102")
}
if len(o2.Phases) != 3 || !o2.Phases[1].FailCheck {
- t.Errorf("legacy obstacle phases not migrated: %+v", o2.Phases)
+ t.Errorf("obstacle 2 phases wrong: %+v", o2.Phases)
}
if o2.FailChance != nil {
- t.Errorf("legacy obstacle should have nil (derived) fail_chance, got %v", *o2.FailChance)
+ t.Errorf("obstacle 2 should have nil (derived) fail_chance, got %v", *o2.FailChance)
}
if o2.CompletionXP != 50 {
t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP)
@@ -137,4 +118,4 @@ obstacles:
if cs.GetObstacle(999) != nil {
t.Error("expected nil for unrelated room")
}
-} \ No newline at end of file
+}
diff --git a/internal/game/core_flags.go b/internal/game/core_flags.go
index 5e1c812..c78f3c3 100644
--- a/internal/game/core_flags.go
+++ b/internal/game/core_flags.go
@@ -1,6 +1,9 @@
package game
-import "thehouseoficarus/internal/player"
+import (
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/player"
+)
func getPlayerFlagInt(p *player.Player, key string) int {
if p.Flags == nil {
@@ -37,7 +40,7 @@ func (g *Game) setPlayerFlag(p *player.Player, key string, val any) {
p.EnsureFlags()
old, existed := p.Flags[key]
p.Flags[key] = val
- if !existed || !valuesEqual(old, val) {
+ if !existed || !engine.ValuesEqual(old, val) {
g.firePlayerFlagTrigger(p, key, val)
}
}
diff --git a/internal/game/core_flagstore.go b/internal/game/core_flagstore.go
index 18578b7..5dd8d00 100644
--- a/internal/game/core_flagstore.go
+++ b/internal/game/core_flagstore.go
@@ -1,6 +1,10 @@
package game
-import "sync"
+import (
+ "sync"
+
+ "thehouseoficarus/internal/engine"
+)
// GlobalFlagChangeCallback is invoked when a global flag value actually changes
// (old value differs from new, or new flag is created with a truthy value).
@@ -39,7 +43,7 @@ func (f *GlobalFlagStore) Set(name string, value any) {
cbs := f.callbacks
f.mu.Unlock()
- if !existed || !valuesEqual(old, value) {
+ if !existed || !engine.ValuesEqual(old, value) {
for _, cb := range cbs {
cb(name, value)
}
@@ -52,7 +56,7 @@ func (f *GlobalFlagStore) SetAll(m map[string]any) {
for k, v := range m {
old, existed := f.flags[k]
f.flags[k] = v
- if !existed || !valuesEqual(old, v) {
+ if !existed || !engine.ValuesEqual(old, v) {
changed[k] = v
}
}
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 2975551..d13cbdc 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -46,14 +46,14 @@ func TestMapConnectorGlyphs(t *testing.T) {
}{
{
name: "bidirectional bar",
- room1: "name: One\nexits:\n east: 2\n",
- room2: "name: Two\nexits:\n west: 1\n",
+ room1: "name: One\nexits:\n east: {room: 2}\n",
+ room2: "name: Two\nexits:\n west: {room: 1}\n",
wantPresent: "-",
wantAbsent: []string{"X", "<", ">"},
},
{
name: "one-way east arrow",
- room1: "name: One\nexits:\n east: 2\n",
+ room1: "name: One\nexits:\n east: {room: 2}\n",
room2: "name: Two\n",
wantPresent: ">",
wantAbsent: []string{"X", "-", "<"},
@@ -61,7 +61,7 @@ func TestMapConnectorGlyphs(t *testing.T) {
{
name: "forward blocked, reverse open -> X (shortest path is blocked)",
room1: condEast,
- room2: "name: Two\nexits:\n west: 1\n",
+ room2: "name: Two\nexits:\n west: {room: 1}\n",
flagOpen: false,
wantPresent: "X",
wantAbsent: []string{"-", "<", ">"},
@@ -77,15 +77,15 @@ func TestMapConnectorGlyphs(t *testing.T) {
{
name: "conditional unblocked -> bar",
room1: condEast,
- room2: "name: Two\nexits:\n west: 1\n",
+ room2: "name: Two\nexits:\n west: {room: 1}\n",
flagOpen: true,
wantPresent: "-",
wantAbsent: []string{"X", "<", ">"},
},
{
name: "outward open, inward blocked -> outward arrow (dist rules)",
- room1: "name: One\nexits:\n east: 2\n",
- room2: "name: Two\nexits:\n west: 1\n north: 3\n",
+ room1: "name: One\nexits:\n east: {room: 2}\n",
+ room2: "name: Two\nexits:\n west: {room: 1}\n north: {room: 3}\n",
room3: condSouth,
flagOpen: false,
// dist[room1]=0, dist[room2]=1, dist[room3]=2.
@@ -96,21 +96,21 @@ func TestMapConnectorGlyphs(t *testing.T) {
},
{
name: "bidirectional diagonal NE-SW",
- room1: "name: One\nexits:\n northeast: 2\n",
- room2: "name: Two\nexits:\n southwest: 1\n",
+ room1: "name: One\nexits:\n northeast: {room: 2}\n",
+ room2: "name: Two\nexits:\n southwest: {room: 1}\n",
wantPresent: "/",
wantAbsent: []string{"X", "\\"},
},
{
name: "bidirectional diagonal NW-SE",
- room1: "name: One\nexits:\n northwest: 2\n",
- room2: "name: Two\nexits:\n southeast: 1\n",
+ room1: "name: One\nexits:\n northwest: {room: 2}\n",
+ room2: "name: Two\nexits:\n southeast: {room: 1}\n",
wantPresent: "\\",
wantAbsent: []string{"X", "/"},
},
{
name: "one-way NE arrow",
- room1: "name: One\nexits:\n northeast: 2\n",
+ room1: "name: One\nexits:\n northeast: {room: 2}\n",
room2: "name: Two\n",
wantPresent: "/",
wantAbsent: []string{"X", "\\"},
@@ -134,15 +134,15 @@ func TestMapConnectorGlyphs(t *testing.T) {
{
name: "diagonal conditional unblocked -> bar",
room1: condNE,
- room2: "name: Two\nexits:\n southwest: 1\n",
+ room2: "name: Two\nexits:\n southwest: {room: 1}\n",
flagOpen: true,
wantPresent: "/",
wantAbsent: []string{"X", "\\"},
},
{
name: "criss-crossed diagonal paths show X",
- room1: "name: One\nexits:\n east: 2\n south: 3\n southeast: 4\n",
- room2: "name: Two\nexits:\n southwest: 3\n",
+ room1: "name: One\nexits:\n east: {room: 2}\n south: {room: 3}\n southeast: {room: 4}\n",
+ room2: "name: Two\nexits:\n southwest: {room: 3}\n",
room3: "name: Three\n",
room4: "name: Four\n",
wantPresent: "X",
@@ -187,10 +187,10 @@ func TestMapConnectorGlyphs(t *testing.T) {
// both bar glyphs and arrow glyphs.
func TestMapDiagonalOneWayCrossing(t *testing.T) {
dir := t.TempDir()
- writeTempRoom(t, dir, 1, "name: One\nexits:\n north: 3\n northeast: 2\n")
+ writeTempRoom(t, dir, 1, "name: One\nexits:\n north: {room: 3}\n northeast: {room: 2}\n")
writeTempRoom(t, dir, 2, "name: Two\n")
- writeTempRoom(t, dir, 3, "name: Three\nexits:\n southeast: 4\n")
- writeTempRoom(t, dir, 4, "name: Four\nexits:\n northwest: 3\n")
+ writeTempRoom(t, dir, 3, "name: Three\nexits:\n southeast: {room: 4}\n")
+ writeTempRoom(t, dir, 4, "name: Four\nexits:\n northwest: {room: 3}\n")
g := &Game{Deps: Deps{World: world.New(dir)}}
sess := &net.Session{Player: &player.Player{Options: map[string]any{"unicode": true}}}
@@ -396,10 +396,10 @@ func TestMap3DDisconnectedComponent(t *testing.T) {
// tower2base→up→tower2 (1,0,0). Tower 2 is at the same z=0 as tower 1 but
// unreachable via horizontal exits. The 3D map should still show it.
dir := t.TempDir()
- writeTempRoom(t, dir, 1, "name: Tower One\nexits:\n down: 2\n")
- writeTempRoom(t, dir, 2, "name: Bridge\nexits:\n east: 3\n up: 1\n")
- writeTempRoom(t, dir, 3, "name: Tower Two Base\nexits:\n up: 4\n west: 2\n")
- writeTempRoom(t, dir, 4, "name: Tower Two\nexits:\n down: 3\n")
+ writeTempRoom(t, dir, 1, "name: Tower One\nexits:\n down: {room: 2}\n")
+ writeTempRoom(t, dir, 2, "name: Bridge\nexits:\n east: {room: 3}\n up: {room: 1}\n")
+ writeTempRoom(t, dir, 3, "name: Tower Two Base\nexits:\n up: {room: 4}\n west: {room: 2}\n")
+ writeTempRoom(t, dir, 4, "name: Tower Two\nexits:\n down: {room: 3}\n")
g := &Game{Deps: Deps{World: world.New(dir)}, GlobalFlags: NewGlobalFlagStore()}
mg := mapGlyphsForPlayer(false)
@@ -423,8 +423,8 @@ func TestMap3DDifferentZExcluded(t *testing.T) {
// Room 1 at (0,0,0) with up to room 2 at (0,0,1). Room 2 is at z=1,
// different from the player's z=0 level — should NOT show on the map.
dir := t.TempDir()
- writeTempRoom(t, dir, 1, "name: Ground\nexits:\n up: 2\n")
- writeTempRoom(t, dir, 2, "name: Upper\nexits:\n down: 1\n")
+ writeTempRoom(t, dir, 1, "name: Ground\nexits:\n up: {room: 2}\n")
+ writeTempRoom(t, dir, 2, "name: Upper\nexits:\n down: {room: 1}\n")
g := &Game{Deps: Deps{World: world.New(dir)}, GlobalFlags: NewGlobalFlagStore()}
mg := mapGlyphsForPlayer(false)
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 2e8b877..6a1e224 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -8,7 +8,6 @@ import (
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
func (g *Game) TriggerSeqTick() {
@@ -150,7 +149,7 @@ func (g *Game) executeGlobalTriggerStep(step *behavior.StepAction, roomID int, f
g.applyStepAction(nil, nil, step, roomID, scopeGlobal, flagValue)
}
-func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.SpawnMobConfig, roomID int) {
+func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *behavior.SpawnMobConfig, roomID int) {
if cfg.ID == "" {
return
}
@@ -166,7 +165,7 @@ func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.S
sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!"))
}
-func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
+func (g *Game) spawnWorldTriggerMob(cfg *behavior.SpawnMobConfig, roomID int) {
if cfg.ID == "" {
return
}
diff --git a/internal/item/item.go b/internal/item/item.go
index 93f736b..1536006 100644
--- a/internal/item/item.go
+++ b/internal/item/item.go
@@ -1,10 +1,8 @@
package item
import (
- "fmt"
"strings"
- "gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
)
@@ -216,21 +214,6 @@ func (d *ItemDef) Stats() ItemStats {
type CraftList []CraftDef
-func (cl *CraftList) UnmarshalYAML(value *yaml.Node) error {
- switch value.Kind {
- case yaml.SequenceNode:
- return value.Decode((*[]CraftDef)(cl))
- case yaml.MappingNode:
- var single CraftDef
- if err := value.Decode(&single); err != nil {
- return err
- }
- *cl = []CraftDef{single}
- return nil
- }
- return fmt.Errorf("craft: expected mapping or sequence")
-}
-
func (d *ItemDef) FirstCraft() *CraftDef {
if len(d.Craft) == 0 {
return nil
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index e40c50b..0e1d2dd 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -42,9 +42,9 @@ func containsMsg(issues []Issue, substr string) bool {
func TestGridCleanLayout(t *testing.T) {
// A consistent 2x2 block: 4 is reached the same way from 2 and from 3.
rooms := map[int]string{
- 1: "exits:\n south: 3\n east: 2\n",
- 2: "exits:\n south: 4\n",
- 3: "exits:\n east: 4\n",
+ 1: "exits:\n south: {room: 3}\n east: {room: 2}\n",
+ 2: "exits:\n south: {room: 4}\n",
+ 3: "exits:\n east: {room: 4}\n",
4: "name: corner\n",
}
if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
@@ -55,9 +55,9 @@ func TestGridCleanLayout(t *testing.T) {
func TestGridOverlap(t *testing.T) {
// rooms 4 and 5 both resolve to grid (1,1).
rooms := map[int]string{
- 1: "exits:\n south: 3\n east: 2\n",
- 2: "exits:\n south: 5\n",
- 3: "exits:\n east: 4\n",
+ 1: "exits:\n south: {room: 3}\n east: {room: 2}\n",
+ 2: "exits:\n south: {room: 5}\n",
+ 3: "exits:\n east: {room: 4}\n",
4: "name: four\n",
5: "name: five\n",
}
@@ -75,9 +75,9 @@ func TestGridOverlap(t *testing.T) {
func TestGridTwist(t *testing.T) {
// room 3 is forced onto two different cells.
rooms := map[int]string{
- 1: "exits:\n south: 4\n east: 2\n",
- 2: "exits:\n east: 3\n",
- 4: "exits:\n east: 3\n",
+ 1: "exits:\n south: {room: 4}\n east: {room: 2}\n",
+ 2: "exits:\n east: {room: 3}\n",
+ 4: "exits:\n east: {room: 3}\n",
3: "name: three\n",
}
issues := runGridCheck(t, rooms, 1)
@@ -90,10 +90,10 @@ func TestGridMultiPlaneViaUpDown(t *testing.T) {
// Room 1 at (0,0,0) goes up to room 10 at (0,0,1). Rooms 13 and 14 both
// resolve to (1,1,1) — 3D overlap on the upper level.
rooms := map[int]string{
- 1: "exits:\n up: 10\n",
- 10: "exits:\n south: 12\n east: 11\n",
- 11: "exits:\n south: 14\n",
- 12: "exits:\n east: 13\n",
+ 1: "exits:\n up: {room: 10}\n",
+ 10: "exits:\n south: {room: 12}\n east: {room: 11}\n",
+ 11: "exits:\n south: {room: 14}\n",
+ 12: "exits:\n east: {room: 13}\n",
13: "name: thirteen\n",
14: "name: fourteen\n",
}
@@ -107,11 +107,11 @@ func TestGrid3DCleanViaUpDown(t *testing.T) {
// Clean 3D layout: up/down maintain same (x,y) across z-levels.
// 1(0,0,0) up→2(0,0,1) east→3(1,0,1) down→4(1,0,0) south→5(1,1,0)
rooms := map[int]string{
- 1: "exits:\n up: 2\n",
- 2: "exits:\n east: 3\n down: 1\n",
- 3: "exits:\n down: 4\n west: 2\n",
- 4: "exits:\n south: 5\n up: 3\n",
- 5: "name: five\n exits:\n north: 4\n",
+ 1: "exits:\n up: {room: 2}\n",
+ 2: "exits:\n east: {room: 3}\n down: {room: 1}\n",
+ 3: "exits:\n down: {room: 4}\n west: {room: 2}\n",
+ 4: "exits:\n south: {room: 5}\n up: {room: 3}\n",
+ 5: "name: five\n exits:\n north: {room: 4}\n",
}
if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
t.Errorf("expected no grid issues, got: %+v", issues)
@@ -123,10 +123,10 @@ func TestGrid3DOverlapUpDown(t *testing.T) {
// 1(0,0,0) up→2(0,0,1).
// 1(0,0,0) east→3(1,0,0) up→4(1,0,1) west→5 wants (0,0,1) — already room 2.
rooms := map[int]string{
- 1: "exits:\n up: 2\n east: 3\n",
+ 1: "exits:\n up: {room: 2}\n east: {room: 3}\n",
2: "name: two\n",
- 3: "exits:\n up: 4\n west: 1\n",
- 4: "exits:\n west: 5\n down: 3\n",
+ 3: "exits:\n up: {room: 4}\n west: {room: 1}\n",
+ 4: "exits:\n west: {room: 5}\n down: {room: 3}\n",
5: "name: five\n",
}
issues := runGridCheck(t, rooms, 1)
@@ -141,13 +141,13 @@ func TestGrid3DTwistUpDown(t *testing.T) {
// path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1)
// 4 already at (1,1,1) from path1 → twist.
rooms := map[int]string{
- 1: "exits:\n up: 2\n east: 6\n",
- 2: "exits:\n south: 3\n down: 1\n",
- 3: "exits:\n east: 4\n north: 2\n",
+ 1: "exits:\n up: {room: 2}\n east: {room: 6}\n",
+ 2: "exits:\n south: {room: 3}\n down: {room: 1}\n",
+ 3: "exits:\n east: {room: 4}\n north: {room: 2}\n",
4: "name: four\n",
- 6: "exits:\n up: 7\n west: 1\n",
- 7: "exits:\n east: 8\n down: 6\n",
- 8: "exits:\n south: 4\n west: 7\n",
+ 6: "exits:\n up: {room: 7}\n west: {room: 1}\n",
+ 7: "exits:\n east: {room: 8}\n down: {room: 6}\n",
+ 8: "exits:\n south: {room: 4}\n west: {room: 7}\n",
}
issues := runGridCheck(t, rooms, 1)
if !containsMsg(issues, "Grid twist") {
@@ -158,8 +158,8 @@ func TestGrid3DTwistUpDown(t *testing.T) {
func TestGridDiagonalClean(t *testing.T) {
// Room 1 -> NE -> Room 2 (at 1,-1). Clean diagonal placement.
rooms := map[int]string{
- 1: "exits:\n northeast: 2\n",
- 2: "name: two\n exits:\n southwest: 1\n",
+ 1: "exits:\n northeast: {room: 2}\n",
+ 2: "name: two\n exits:\n southwest: {room: 1}\n",
}
if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
t.Errorf("expected no grid issues, got: %+v", issues)
@@ -171,9 +171,9 @@ func TestGridDiagonalOverlap(t *testing.T) {
// 1→SE→2→E→4 lands 4 at (2,1).
// 1→E→3→SE→5 lands 5 at (2,1). Room 4 != 5 → overlap.
rooms := map[int]string{
- 1: "exits:\n southeast: 2\n east: 3\n",
- 2: "exits:\n east: 4\n",
- 3: "exits:\n southeast: 5\n",
+ 1: "exits:\n southeast: {room: 2}\n east: {room: 3}\n",
+ 2: "exits:\n east: {room: 4}\n",
+ 3: "exits:\n southeast: {room: 5}\n",
4: "name: four\n",
5: "name: five\n",
}
@@ -189,11 +189,11 @@ func TestGridDiagonalTwist(t *testing.T) {
// path2: 1→N→3 (3 at 0,-1). 3→E→5 (5 at 1,-1). 5→N→4 wants 4 at (1,-2).
// 4 already placed at (2,-1) but wanted at (1,-2) → twist.
rooms := map[int]string{
- 1: "exits:\n east: 2\n north: 3\n",
- 2: "exits:\n northeast: 4\n",
- 3: "exits:\n east: 5\n",
+ 1: "exits:\n east: {room: 2}\n north: {room: 3}\n",
+ 2: "exits:\n northeast: {room: 4}\n",
+ 3: "exits:\n east: {room: 5}\n",
4: "name: four\n",
- 5: "exits:\n north: 4\n",
+ 5: "exits:\n north: {room: 4}\n",
}
issues := runGridCheck(t, rooms, 1)
if !containsMsg(issues, "Grid twist") {
diff --git a/internal/validate/local_test.go b/internal/validate/local_test.go
index 337f3d2..f0bc0a1 100644
--- a/internal/validate/local_test.go
+++ b/internal/validate/local_test.go
@@ -78,9 +78,11 @@ func TestValidateRoomsSameNameCollision(t *testing.T) {
objects:
- id: ghost_object
- name: sign
- description: "one"
+ description:
+ - text: "one"
- name: sign
- description: "two"
+ description:
+ - text: "two"
`)
issues := validateRooms(newSource(dir))
@@ -102,7 +104,8 @@ objects:
- id: copper_rock
- id: anvil
- name: anvil
- description: "a local thing that collides with the file id"
+ description:
+ - text: "a local thing that collides with the file id"
`)
issues := validateRooms(newSource(dir))
@@ -136,11 +139,13 @@ func TestValidateExitReciprocityMismatch(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Room One
exits:
- down: 2
+ down:
+ room: 2
`)
writeFile(t, filepath.Join(dir, "rooms", "2.yaml"), `name: Room Two
exits:
- south: 1
+ south:
+ room: 1
`)
issues := validateExitReciprocity(newSource(dir))
if !containsMsg(issues, "not the expected opposite direction") {
@@ -152,7 +157,8 @@ func TestValidateExitReciprocityOneWay(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Room One
exits:
- east: 2
+ east:
+ room: 2
`)
writeFile(t, filepath.Join(dir, "rooms", "2.yaml"), `name: Room Two
`)
@@ -167,9 +173,11 @@ func TestValidateRoomsPartialNameSiblingsOK(t *testing.T) {
writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Test Room
objects:
- name: rusty sign
- description: "rusty"
+ description:
+ - text: "rusty"
- name: shiny sign
- description: "shiny"
+ description:
+ - text: "shiny"
`)
issues := validateRooms(newSource(dir))
for _, iss := range issues {
diff --git a/internal/world/grid_test.go b/internal/world/grid_test.go
index c777f90..005f60f 100644
--- a/internal/world/grid_test.go
+++ b/internal/world/grid_test.go
@@ -39,9 +39,9 @@ func hasConflictKind(conflicts []GridConflict, kind string) bool {
func TestBuildGridConflictsClean(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n south: 3\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n south: 4\n")
- writeGridTestRoom(t, dir, 3, "exits:\n east: 4\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n south: {room: 3}\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n south: {room: 4}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n east: {room: 4}\n")
writeGridTestRoom(t, dir, 4, "name: corner\n")
w := New(dir)
@@ -52,9 +52,9 @@ func TestBuildGridConflictsClean(t *testing.T) {
func TestBuildGridConflictsOverlap(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n south: 3\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n south: 5\n")
- writeGridTestRoom(t, dir, 3, "exits:\n east: 4\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n south: {room: 3}\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n south: {room: 5}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n east: {room: 4}\n")
writeGridTestRoom(t, dir, 4, "name: four\n")
writeGridTestRoom(t, dir, 5, "name: five\n")
@@ -67,9 +67,9 @@ func TestBuildGridConflictsOverlap(t *testing.T) {
func TestBuildGridConflictsTwist(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n south: 4\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n")
- writeGridTestRoom(t, dir, 4, "exits:\n east: 3\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n south: {room: 4}\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: {room: 3}\n")
writeGridTestRoom(t, dir, 3, "name: three\n")
w := New(dir)
@@ -81,8 +81,8 @@ func TestBuildGridConflictsTwist(t *testing.T) {
func TestBuildGridConflictsDiagonalClean(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
- writeGridTestRoom(t, dir, 2, "name: two\n exits:\n southwest: 1\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "name: two\n exits:\n southwest: {room: 1}\n")
w := New(dir)
if conflicts := BuildGridConflicts(1, loadGridRoom(w)); len(conflicts) != 0 {
@@ -97,8 +97,8 @@ func TestBuildGridConflictsDiagonalClean(t *testing.T) {
// confirm the (clean) post-edit world has no conflicts.
func TestBuildGridConflictsHypotheticalInsert(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "name: one\nexits:\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "name: two\nexits:\n west: 1\n")
+ writeGridTestRoom(t, dir, 1, "name: one\nexits:\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "name: two\nexits:\n west: {room: 1}\n")
w := New(dir)
newRoom := &Room{Name: "new", Exits: map[ExitDir]ExitDef{
@@ -145,12 +145,12 @@ func TestBuildGridConflictsHypotheticalInsert(t *testing.T) {
// (1,1,0). The helper must report an overlap without anything being written.
func TestBuildGridConflictsHypotheticalRemoveOverlap(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n south: 6\n west: 2\n")
- writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
- writeGridTestRoom(t, dir, 5, "exits:\n west: 4\n")
- writeGridTestRoom(t, dir, 6, "exits:\n north: 3\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n south: {room: 4}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n south: {room: 6}\n west: {room: 2}\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: {room: 5}\n north: {room: 1}\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n west: {room: 4}\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n north: {room: 3}\n")
w := New(dir)
load := func(id int) (*Room, bool) {
diff --git a/internal/world/insert_remove_test.go b/internal/world/insert_remove_test.go
index 3be3222..15d752e 100644
--- a/internal/world/insert_remove_test.go
+++ b/internal/world/insert_remove_test.go
@@ -9,8 +9,8 @@ const insertSentinel = 9999
func TestInsertGridConflictsCleanNSEW(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n west: 1\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n west: {room: 1}\n")
w := New(dir)
if c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
t.Errorf("clean east insert: expected no conflicts, got %+v", c)
@@ -19,8 +19,8 @@ func TestInsertGridConflictsCleanNSEW(t *testing.T) {
func TestInsertGridConflictsCleanDiagonal(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n southwest: 1\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n southwest: {room: 1}\n")
w := New(dir)
if c := InsertGridConflicts(1, Northeast, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
t.Errorf("clean NE insert: expected no conflicts, got %+v", c)
@@ -29,8 +29,8 @@ func TestInsertGridConflictsCleanDiagonal(t *testing.T) {
func TestInsertGridConflictsCleanUpDown(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n up: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n down: 1\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n up: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n down: {room: 1}\n")
w := New(dir)
if c := InsertGridConflicts(1, Up, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
t.Errorf("clean up insert: expected no conflicts, got %+v", c)
@@ -73,14 +73,14 @@ func TestInsertGridConflictsCleanUpDown(t *testing.T) {
// beyond-set (reachable from 1 via south, not via east/2), so it's an overlap.
func TestInsertGridConflictsOverlap(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n")
- writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
- writeGridTestRoom(t, dir, 5, "exits:\n east: 6\n west: 4\n")
- writeGridTestRoom(t, dir, 6, "exits:\n east: 7\n west: 5\n")
- writeGridTestRoom(t, dir, 7, "exits:\n north: 8\n west: 6\n")
- writeGridTestRoom(t, dir, 8, "exits:\n south: 7\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n south: {room: 4}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: {room: 2}\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: {room: 5}\n north: {room: 1}\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n east: {room: 6}\n west: {room: 4}\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n east: {room: 7}\n west: {room: 5}\n")
+ writeGridTestRoom(t, dir, 7, "exits:\n north: {room: 8}\n west: {room: 6}\n")
+ writeGridTestRoom(t, dir, 8, "exits:\n south: {room: 7}\n")
w := New(dir)
// Sanity: the pre-edit world must be clean.
@@ -101,11 +101,11 @@ func TestInsertGridConflictsOverlap(t *testing.T) {
// insert helper must surface a twist conflict.
func TestInsertGridConflictsTwist(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n south: 5\n")
- writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
- writeGridTestRoom(t, dir, 5, "exits:\n north: 3\n west: 4\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n south: {room: 4}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: {room: 2}\n south: {room: 5}\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: {room: 5}\n north: {room: 1}\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n north: {room: 3}\n west: {room: 4}\n")
w := New(dir)
c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w))
@@ -120,7 +120,7 @@ func TestInsertGridConflictsTwist(t *testing.T) {
// geometrically clean insert.
func TestInsertGridConflictsOneWayFarReciprocal(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n")
writeGridTestRoom(t, dir, 2, "exits:\n") // no west exit back to 1
w := New(dir)
if c := InsertGridConflicts(1, East, 2, insertSentinel, loadGridRoom(w)); len(c) != 0 {
@@ -131,9 +131,9 @@ func TestInsertGridConflictsOneWayFarReciprocal(t *testing.T) {
func TestRemoveGridConflictsCleanPull(t *testing.T) {
dir := t.TempDir()
// 1 east→2 east→3, with reciprocals. Removing 2 pulls 3 to (1,0,0). Clean.
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n west: 2\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n west: {room: 2}\n")
w := New(dir)
if c := RemoveGridConflicts(1, East, loadGridRoom(w)); len(c) != 0 {
t.Errorf("clean pull should produce no conflicts, got %+v", c)
@@ -142,9 +142,9 @@ func TestRemoveGridConflictsCleanPull(t *testing.T) {
func TestRemoveGridConflictsDiagonalPull(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n northeast: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n northeast: 3\n southwest: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n southwest: 2\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n northeast: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n northeast: {room: 3}\n southwest: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n southwest: {room: 2}\n")
w := New(dir)
if c := RemoveGridConflicts(1, Northeast, loadGridRoom(w)); len(c) != 0 {
t.Errorf("clean diagonal pull should produce no conflicts, got %+v", c)
@@ -155,12 +155,12 @@ func TestRemoveGridConflictsDiagonalPull(t *testing.T) {
// removing 2 pulls 6 onto 5's cell.
func TestRemoveGridConflictsOverlap(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n south: 4\n")
- writeGridTestRoom(t, dir, 2, "exits:\n east: 3\n west: 1\n")
- writeGridTestRoom(t, dir, 3, "exits:\n south: 6\n west: 2\n")
- writeGridTestRoom(t, dir, 4, "exits:\n east: 5\n north: 1\n")
- writeGridTestRoom(t, dir, 5, "exits:\n west: 4\n")
- writeGridTestRoom(t, dir, 6, "exits:\n north: 3\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n south: {room: 4}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n east: {room: 3}\n west: {room: 1}\n")
+ writeGridTestRoom(t, dir, 3, "exits:\n south: {room: 6}\n west: {room: 2}\n")
+ writeGridTestRoom(t, dir, 4, "exits:\n east: {room: 5}\n north: {room: 1}\n")
+ writeGridTestRoom(t, dir, 5, "exits:\n west: {room: 4}\n")
+ writeGridTestRoom(t, dir, 6, "exits:\n north: {room: 3}\n")
w := New(dir)
if c := BuildGridConflicts(1, loadGridRoom(w)); len(c) != 0 {
@@ -177,8 +177,8 @@ func TestRemoveGridConflictsOverlap(t *testing.T) {
// world this cannot introduce a conflict.
func TestRemoveGridConflictsDeadEnd(t *testing.T) {
dir := t.TempDir()
- writeGridTestRoom(t, dir, 1, "exits:\n east: 2\n")
- writeGridTestRoom(t, dir, 2, "exits:\n west: 1\n")
+ writeGridTestRoom(t, dir, 1, "exits:\n east: {room: 2}\n")
+ writeGridTestRoom(t, dir, 2, "exits:\n west: {room: 1}\n")
w := New(dir)
if c := RemoveGridConflicts(1, East, loadGridRoom(w)); len(c) != 0 {
t.Errorf("dead-end removal should produce no conflicts, got %+v", c)
diff --git a/internal/world/mob.go b/internal/world/mob.go
index a9e2f7e..45b0fb8 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -645,7 +645,7 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) {
inst.WanderTickCounter = 0
}
-func (s *MobStore) SpawnTransient(def *MobDef, cfg *SpawnMobConfig, roomID int, owner string) *MobInstance {
+func (s *MobStore) SpawnTransient(def *MobDef, cfg *behavior.SpawnMobConfig, roomID int, owner string) *MobInstance {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/internal/world/room.go b/internal/world/room.go
index 08e18a4..8ae2d13 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -83,19 +83,6 @@ type ExitDef struct {
AlwaysBlocked bool `yaml:"always_blocked,omitempty"`
}
-func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var n int
- if err := value.Decode(&n); err != nil {
- return err
- }
- e.Room = n
- return nil
- }
- type raw ExitDef
- return value.Decode((*raw)(e))
-}
-
type Room struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
@@ -117,19 +104,6 @@ type RoomMob struct {
WanderInterval float64 `yaml:"wander_interval,omitempty"`
}
-func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var s string
- if err := value.Decode(&s); err != nil {
- return err
- }
- rm.ID = s
- return nil
- }
- type raw RoomMob
- return value.Decode((*raw)(rm))
-}
-
// RoomObject is either a reference to a file-backed object definition (only
// `id`/wander fields set) or a fully local object definition (Local != nil).
// An entry is treated as local when it carries any passive content field
diff --git a/internal/world/room_test.go b/internal/world/room_test.go
index 1ad974e..855eba5 100644
--- a/internal/world/room_test.go
+++ b/internal/world/room_test.go
@@ -27,11 +27,13 @@ objects:
- id: workbench
- name: window
hidden: true
- description: "A small window."
+ description:
+ - text: "A small window."
- id: control
name: instrument panel
aliases: [cockpit]
- description: "A panel."
+ description:
+ - text: "A panel."
`
// rebuild the path index so LoadRoom can find the room file
writeRoom(t, dir, body)
@@ -98,7 +100,8 @@ func TestExitDefUnmarshalAlwaysBlocked(t *testing.T) {
north:
room: 5
always_blocked: true
- south: 3
+ south:
+ room: 3
east:
room: 7
hidden: true
diff --git a/internal/world/trigger.go b/internal/world/trigger.go
index e7f3b75..de07c18 100644
--- a/internal/world/trigger.go
+++ b/internal/world/trigger.go
@@ -2,10 +2,6 @@ package world
import "thehouseoficarus/internal/behavior"
-// SpawnMobConfig is an alias for the behavior.SpawnMobConfig so callers that
-// historically referenced world.SpawnMobConfig continue to compile.
-type SpawnMobConfig = behavior.SpawnMobConfig
-
type TriggerDef struct {
ID string `yaml:"id"`
OnPlayerFlag string `yaml:"on_player_flag"`
diff --git a/internal/world/trigger_store.go b/internal/world/trigger_store.go
index 04d77f4..e50ae81 100644
--- a/internal/world/trigger_store.go
+++ b/internal/world/trigger_store.go
@@ -9,6 +9,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/engine"
)
type TriggerStore struct {
@@ -262,7 +263,7 @@ func (ts *TriggerStore) valueMatches(want, got any) bool {
if want == nil {
return true
}
- return valuesEqual(want, got)
+ return engine.ValuesEqual(want, got)
}
func (ts *TriggerStore) SnapshotPlayerSeqs() []*PlayerTriggerSeq {
@@ -311,27 +312,6 @@ func (ts *TriggerStore) AllGlobalTriggers() []*TriggerDef {
return out
}
-func valuesEqual(a, b any) bool {
- ai, aok := numericValue(a)
- bi, bok := numericValue(b)
- if aok && bok {
- return ai == bi
- }
- return a == b
-}
-
-func numericValue(v any) (float64, bool) {
- switch x := v.(type) {
- case int:
- return float64(x), true
- case int64:
- return float64(x), true
- case float64:
- return x, true
- }
- return 0, false
-}
-
func (ts *TriggerStore) AllRoomTriggers() map[int][]*TriggerDef {
ts.mu.Lock()
defer ts.mu.Unlock()