package game import ( "os" "path/filepath" "strconv" "strings" "testing" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func writeTempRoom(t *testing.T, dir string, id int, body string) { t.Helper() roomsDir := filepath.Join(dir, "rooms") if err := os.MkdirAll(roomsDir, 0755); err != nil { t.Fatal(err) } path := filepath.Join(roomsDir, strconv.Itoa(id)+".yaml") if err := os.WriteFile(path, []byte(body), 0644); err != nil { t.Fatal(err) } } // TestMapConnectorGlyphs verifies the directional link rendering: bidirectional // links draw a bar, one-way links draw a directional arrow, outward-blocked // links draw a blocked 'X', and links with no traversable direction draw 'X'. func TestMapConnectorGlyphs(t *testing.T) { const condEast = "name: One\nexits:\n east:\n room: 2\n condition:\n global_flag: gate_open\n" const condSouth = "name: Three\nexits:\n south:\n room: 2\n condition:\n global_flag: gate_open\n" const condNE = "name: One\nexits:\n northeast:\n room: 2\n condition:\n global_flag: gate_open\n" const condSE = "name: One\nexits:\n southeast:\n room: 2\n condition:\n global_flag: gate_open\n" cases := []struct { name string room1 string room2 string room3 string room4 string flagOpen bool wantPresent string wantAbsent []string }{ { name: "bidirectional bar", 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: {room: 2}\n", room2: "name: Two\n", wantPresent: ">", wantAbsent: []string{"X", "-", "<"}, }, { name: "forward blocked, reverse open -> X (shortest path is blocked)", room1: condEast, room2: "name: Two\nexits:\n west: {room: 1}\n", flagOpen: false, wantPresent: "X", wantAbsent: []string{"-", "<", ">"}, }, { name: "forward blocked, reverse absent -> X", room1: condEast, room2: "name: Two\n", flagOpen: false, wantPresent: "X", wantAbsent: []string{"-", "<", ">"}, }, { name: "conditional unblocked -> bar", room1: condEast, 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: {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. // Link 2↔3: near=2 (dist1), far=3 (dist2). // out = 2→north→3 = open → '^' wantPresent: "^", wantAbsent: []string{"X", "v", "<", ">"}, }, { name: "bidirectional diagonal NE-SW", 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: {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: {room: 2}\n", room2: "name: Two\n", wantPresent: "/", wantAbsent: []string{"X", "\\"}, }, { name: "diagonal NE blocked conditional -> X", room1: condNE, room2: "name: Two\n", flagOpen: false, wantPresent: "X", wantAbsent: []string{"\\", "/"}, }, { name: "diagonal SE blocked conditional -> X", room1: condSE, room2: "name: Two\n", flagOpen: false, wantPresent: "X", wantAbsent: []string{"\\", "/"}, }, { name: "diagonal conditional unblocked -> bar", room1: condNE, 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: {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", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() writeTempRoom(t, dir, 1, tc.room1) writeTempRoom(t, dir, 2, tc.room2) if tc.room3 != "" { writeTempRoom(t, dir, 3, tc.room3) } if tc.room4 != "" { writeTempRoom(t, dir, 4, tc.room4) } g := &Game{Deps: Deps{World: world.New(dir)}, GlobalFlags: NewGlobalFlagStore()} if tc.flagOpen { g.GlobalFlags.Set("gate_open", true) } sess := &net.Session{Player: &player.Player{Flags: map[string]any{}}} out := strings.Join(buildTinyMap(g, sess, 1, mapGlyphsForPlayer(false)), "\n") if !strings.Contains(out, tc.wantPresent) { t.Errorf("want %q present:\n%s", tc.wantPresent, out) } for _, a := range tc.wantAbsent { if strings.Contains(out, a) { t.Errorf("want %q absent:\n%s", a, out) } } }) } } // TestMapDiagonalOneWayCrossing verifies that when a one-way diagonal arrow // (↗) crosses a bidirectional diagonal bar (/) at the same grid cell, the // cell renders as X rather than silently overwriting the arrow. This covers // the unicode-mode criss-cross path where isDiagonalGlyph must recognize // both bar glyphs and arrow glyphs. func TestMapDiagonalOneWayCrossing(t *testing.T) { dir := t.TempDir() 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: {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}}} out := strings.Join(buildTinyMap(g, sess, 1, mapGlyphsForPlayer(true)), "\n") if !strings.Contains(out, "X") { t.Errorf("expected X for crossed one-way NE arrow and SE bar:\n%s", out) } } func TestBuildTinyMap(t *testing.T) { g := &Game{ Deps: Deps{World: world.New("../../data")}, } mg := mapGlyphsForPlayer(true) tests := []struct { name string roomID int want []string // expected lines, or nil to just check count }{ { name: "room 999991 has east exit", roomID: 999991, }, { name: "room 999992 has west/north/east", roomID: 999992, }, { name: "room 999994 has west/north/east", roomID: 999994, }, { name: "room 9999921 has west only", roomID: 9999921, }, { name: "room 9999922 west of town square", roomID: 9999922, }, { name: "room 9999925 east of south of west", roomID: 9999925, }, { name: "room 999998 hacking lab", roomID: 999998, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { lines := buildTinyMap(g, nil, tt.roomID, mg) if len(lines) != 7 { t.Fatalf("expected 7 lines, got %d", len(lines)) } if lines[0] != "╔═════╗" { t.Errorf("line 0: want ╔═════╗, got %s", lines[0]) } if lines[6] != "╚═════╝" { t.Errorf("line 6: want ╚═════╝, got %s", lines[6]) } for i := 1; i <= 5; i++ { if !strings.HasPrefix(lines[i], "║") || !strings.HasSuffix(lines[i], "║") { t.Errorf("line %d: should have ║ borders, got %s", i, lines[i]) } if color.VisibleLen(lines[i]) != 7 { t.Errorf("line %d: expected 7 visible runes, got %d in %q", i, color.VisibleLen(lines[i]), lines[i]) } } t.Logf("Room %d map:\n%s", tt.roomID, strings.Join(lines, "\n")) }) } } func TestWrapText(t *testing.T) { tests := []struct { text string width int want int // expected number of lines }{ {"hello world", 70, 1}, {"hello world", 5, 2}, {"", 70, 0}, {"a b c d e f g h i j", 5, 4}, } for _, tt := range tests { result := wrapText(tt.text, tt.width) if len(result) != tt.want { t.Errorf("wrapText(%q, %d) = %d lines, want %d: %v", tt.text, tt.width, len(result), tt.want, result) } } } func TestBuildFullMap(t *testing.T) { g := &Game{ Deps: Deps{World: world.New("../../data")}, } mg := mapGlyphsForPlayer(true) tests := []struct { name string roomID int width int height int }{ {"room 999991 30x20", 999991, 30, 20}, {"room 999991 20x10", 999991, 20, 10}, {"room 9999925 30x20", 9999925, 30, 20}, {"min size 5x5", 999991, 5, 5}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { lines := buildFullMap(g, nil, tt.roomID, tt.width, tt.height, mg) if len(lines) != tt.height { t.Errorf("expected %d lines, got %d", tt.height, len(lines)) } for i, line := range lines { if color.VisibleLen(line) != tt.width { t.Errorf("line %d: expected %d visible runes, got %d in %q", i, tt.width, color.VisibleLen(line), line) } } t.Logf("Room %d %dx%d map:\n%s", tt.roomID, tt.width, tt.height, strings.Join(lines, "\n")) }) } } func TestStripBlankRows(t *testing.T) { input := []string{" ", "hello", "", " world ", " "} want := []string{"hello", "", " world "} got := stripBlankRows(input) if len(got) != len(want) { t.Fatalf("expected %d lines, got %d", len(want), len(got)) } for i := range want { if got[i] != want[i] { t.Errorf("line %d: want %q, got %q", i, want[i], got[i]) } } } func TestStripBlankRowsPreservesInternal(t *testing.T) { // Internal blank rows (between non-blank content) should be preserved. // They represent real vertical distance between disconnected components. input := []string{" ", "hello", "", "", "world", " "} want := []string{"hello", "", "", "world"} got := stripBlankRows(input) if len(got) != len(want) { t.Fatalf("expected %d lines, got %d", len(want), len(got)) } for i := range want { if got[i] != want[i] { t.Errorf("line %d: want %q, got %q", i, want[i], got[i]) } } } func TestLeftTrimCommon(t *testing.T) { input := []string{" hello", " world", " ", " foo"} got := leftTrimCommon(input) // min leading spaces across non-blank lines: " hello"=4, " world"=4, " foo"=2 → min=2 // trim 2 from all lines if got[0] != " hello" { t.Errorf("line 0: want %q, got %q", " hello", got[0]) } if got[1] != " world" { t.Errorf("line 1: want %q, got %q", " world", got[1]) } if got[2] != "" { t.Errorf("line 2: want %q, got %q", "", got[2]) } if got[3] != "foo" { t.Errorf("line 3: want %q, got %q", "foo", got[3]) } } func TestLeftTrimCommonEmpty(t *testing.T) { input := []string{" ", "", " "} got := leftTrimCommon(input) if len(got) != 3 { t.Fatalf("expected 3 lines, got %d", len(got)) } } func TestLeftTrimCommonZero(t *testing.T) { input := []string{"hello", "world"} got := leftTrimCommon(input) if got[0] != "hello" { t.Errorf("line 0: want %q, got %q", "hello", got[0]) } if got[1] != "world" { t.Errorf("line 1: want %q, got %q", "world", got[1]) } } func TestMap3DDisconnectedComponent(t *testing.T) { // Tower 1 (0,0,0) has down→bridge (0,0,-1). bridge→east→tower2base (1,0,-1). // 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: {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) // Player has only visited tower 1. Tower 2 should appear but dimmed (unvisited). sess := &net.Session{Player: &player.Player{ Stats: player.PlayerStats{RoomsVisited: player.NewRoomSetFrom(1)}, Flags: map[string]any{}, }} out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n") // Tower 2 at (1,0,0) should appear on the same z-level map. // Unvisited rooms render as □ (unicode) or o (ASCII). if !strings.Contains(out, "□") && !strings.Contains(out, "o") { t.Errorf("expected tower 2 (unvisited) to appear on the map:\n%s", out) } } 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: {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) sess := &net.Session{Player: &player.Player{ Stats: player.PlayerStats{RoomsVisited: player.NewRoomSetFrom(1, 2)}, Flags: map[string]any{}, }} out := strings.Join(buildTinyMap(g, sess, 1, mg), "\n") // Room 2 is at a different z — it should NOT render as a room symbol. // Unvisited rooms render as □ (unicode) or o (ASCII). if strings.Contains(out, "□") || strings.Contains(out, " o ") { t.Errorf("room at different z should not appear:\n%s", out) } }