aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-27 16:50:41 -0400
committerhistoria <[not public]>2026-06-27 16:50:41 -0400
commit988b006a5bb9edb6465653566e7bdf8175347b8c (patch)
tree3a1fceff2ee80c11b5dafcc49c13ababe021e880 /internal/validate
parent1cab9ca20e24742f770a676f84e4ed9f1636f297 (diff)
downloadthehouseoficarus-988b006a5bb9edb6465653566e7bdf8175347b8c.tar.gz
feat: one-way map links, blocked paths on map, map grid startup validation, user colors for maps
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go113
-rw-r--r--internal/validate/grid_test.go105
-rw-r--r--internal/validate/validate.go3
3 files changed, 219 insertions, 2 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index f1d6b5e..b2e5449 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -6,6 +6,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/world"
)
@@ -34,6 +35,13 @@ func validateRooms(s Source) []Issue {
Message: fmt.Sprintf("Room %d: has no name", id),
})
}
+ if room.Color != "" && color.Parse(room.Color).Empty() {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: invalid color %q", id, room.Color),
+ })
+ }
for dir, exit := range room.Exits {
switch dir {
@@ -654,7 +662,7 @@ func validateRoomWiring(s Source) []Issue {
roomIndex := s.World.RoomIndex()
sourceSet := make(map[int]bool)
- for _, src := range s.CheckSources {
+ for _, src := range s.RootRooms {
sourceSet[src] = true
}
@@ -705,6 +713,109 @@ func validateRoomWiring(s Source) []Issue {
return issues
}
+// gridDeltas maps the horizontal exits to their grid movement. Up/Down are
+// intentionally excluded: they connect separate horizontal planes rather than
+// moving within one.
+var gridDeltas = map[world.ExitDir][2]int{
+ world.North: {0, -1},
+ world.South: {0, 1},
+ world.East: {1, 0},
+ world.West: {-1, 0},
+}
+
+// validateRoomGrid lays each reachable horizontal plane on a 2D grid starting
+// from the configured root rooms and reports when the exit layout cannot be
+// embedded without conflict:
+// - overlap: two distinct rooms land on the same grid cell.
+// - twist: one room is forced onto two different grid cells.
+//
+// Up/Down exits don't move on the grid; each leads to a new plane that is laid
+// out independently (fresh origin), so one root validates every reachable
+// floor. Exit conditions are ignored (geometry is independent of gating), and
+// exits to nonexistent rooms are skipped (covered by referential checks).
+func validateRoomGrid(s Source) []Issue {
+ var issues []Issue
+ roomIndex := s.World.RoomIndex()
+
+ placed := make(map[int]bool)
+ var seeds []int
+ for _, r := range s.RootRooms {
+ if roomIndex[r] {
+ seeds = append(seeds, r)
+ }
+ }
+
+ for len(seeds) > 0 {
+ origin := seeds[0]
+ seeds = seeds[1:]
+ if placed[origin] {
+ continue
+ }
+
+ coordOf := map[int][2]int{origin: {0, 0}}
+ roomAt := map[[2]int]int{{0, 0}: origin}
+ placed[origin] = true
+ queue := []int{origin}
+
+ for len(queue) > 0 {
+ rid := queue[0]
+ queue = queue[1:]
+ room, err := s.World.LoadRoom(rid)
+ if err != nil {
+ continue
+ }
+ c := coordOf[rid]
+ for _, dir := range world.ExitOrder {
+ exit, ok := room.Exits[dir]
+ if !ok || exit.Room <= 0 || !roomIndex[exit.Room] {
+ continue
+ }
+ target := exit.Room
+
+ if dir == world.Up || dir == world.Down {
+ if !placed[target] {
+ seeds = append(seeds, target)
+ }
+ continue
+ }
+
+ d := gridDeltas[dir]
+ want := [2]int{c[0] + d[0], c[1] + d[1]}
+
+ if existing, ok := coordOf[target]; ok {
+ if existing != want {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid twist: room %d (via %d %s) maps to grid (%d,%d) but was already placed at (%d,%d) [plane origin %d]",
+ target, rid, dir, want[0], want[1], existing[0], existing[1], origin),
+ })
+ }
+ continue
+ }
+ if occupier, ok := roomAt[want]; ok && occupier != target {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "integrity",
+ Message: fmt.Sprintf(
+ "Grid overlap: room %d (via %d %s) wants grid (%d,%d), already used by room %d [plane origin %d]",
+ target, rid, dir, want[0], want[1], occupier, origin),
+ })
+ continue
+ }
+
+ coordOf[target] = want
+ roomAt[want] = target
+ placed[target] = true
+ queue = append(queue, target)
+ }
+ }
+ }
+
+ return issues
+}
+
func validateTechs(s Source) []Issue {
var issues []Issue
seen := make(map[string]bool)
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
new file mode 100644
index 0000000..9a8ef02
--- /dev/null
+++ b/internal/validate/grid_test.go
@@ -0,0 +1,105 @@
+package validate
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+func writeGridRoom(t *testing.T, dir string, id int, body string) {
+ t.Helper()
+ rooms := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(rooms, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rooms, strconv.Itoa(id)+".yaml"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func runGridCheck(t *testing.T, rooms map[int]string, roots ...int) []Issue {
+ t.Helper()
+ dir := t.TempDir()
+ for id, body := range rooms {
+ writeGridRoom(t, dir, id, body)
+ }
+ return validateRoomGrid(Source{World: world.New(dir), RootRooms: roots})
+}
+
+func containsMsg(issues []Issue, substr string) bool {
+ for _, iss := range issues {
+ if strings.Contains(iss.Message, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+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",
+ 4: "name: corner\n",
+ }
+ if issues := runGridCheck(t, rooms, 1); len(issues) != 0 {
+ t.Errorf("expected no grid issues, got: %+v", issues)
+ }
+}
+
+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",
+ 4: "name: four\n",
+ 5: "name: five\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid overlap") {
+ t.Errorf("expected a grid overlap, got: %+v", issues)
+ }
+ for _, iss := range issues {
+ if iss.Level != "ERROR" {
+ t.Errorf("grid issues should be ERROR, got %q", iss.Level)
+ }
+ }
+}
+
+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",
+ 3: "name: three\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "Grid twist") {
+ t.Errorf("expected a grid twist, got: %+v", issues)
+ }
+}
+
+func TestGridMultiPlaneViaUpDown(t *testing.T) {
+ // Plane A is just room 1. Going up seeds plane B (origin 10), which has an
+ // overlap. This proves up/down crosses planes and frames are independent
+ // (room 1 and room 10 both sit at (0,0) without conflicting).
+ 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",
+ 13: "name: thirteen\n",
+ 14: "name: fourteen\n",
+ }
+ issues := runGridCheck(t, rooms, 1)
+ if !containsMsg(issues, "plane origin 10") {
+ t.Errorf("expected an overlap in plane origin 10, got: %+v", issues)
+ }
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 36969ed..56de6f1 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -51,7 +51,7 @@ type Source struct {
Courses []CourseView
Techs []TechView
TechIDs map[string]bool
- CheckSources []int
+ RootRooms []int
IgnoreUnreachable []int
}
@@ -80,6 +80,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateRoomEnterSteps(s)...)
issues = append(issues, validateTechs(s)...)
issues = append(issues, validateRoomWiring(s)...)
+ issues = append(issues, validateRoomGrid(s)...)
return issues
}