diff options
| author | historia <[not public]> | 2026-07-09 05:27:10 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-09 05:27:10 -0400 |
| commit | c705ae942573984784ef501bf8198f61f5206ddd (patch) | |
| tree | c964066f3a244002bf75cb50a877630f46496f81 /internal | |
| parent | 74153c7814fc4cef988066ef04e38731a943bc12 (diff) | |
| download | thehouseoficarus-c705ae942573984784ef501bf8198f61f5206ddd.tar.gz | |
refactor: simplify yaml, remove support for old scalar fields
Diffstat (limited to 'internal')
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) + ' → #' + 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() |
