~bigbes/game-prototype-ftl

8170df95ade2fe233b86c4405bd92e28fc95c7ff — Eugene Blikh 30 days ago ef9c93c
crew-movement: walkability + BFS pathfinding with tests
2 files changed, 162 insertions(+), 0 deletions(-)

A tiles.go
A tiles_test.go
A tiles.go => tiles.go +59 -0
@@ 0,0 1,59 @@
package main

func walkableTiles(s Ship) map[[2]int]bool {
	walk := make(map[[2]int]bool)
	for _, r := range s.Rooms {
		for y := r.GridY; y < r.GridY+r.GridH; y++ {
			for x := r.GridX; x < r.GridX+r.GridW; x++ {
				walk[[2]int{x, y}] = true
			}
		}
	}
	return walk
}

func bfsPath(walk map[[2]int]bool, from, to [2]int) [][2]int {
	if from == to {
		return nil
	}
	if !walk[to] {
		return nil
	}

	neighbours := [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}
	visited := map[[2]int]bool{from: true}
	parent := map[[2]int][2]int{}
	queue := [][2]int{from}

	for len(queue) > 0 {
		cur := queue[0]
		queue = queue[1:]
		for _, d := range neighbours {
			next := [2]int{cur[0] + d[0], cur[1] + d[1]}
			if !walk[next] || visited[next] {
				continue
			}
			visited[next] = true
			parent[next] = cur
			if next == to {
				return reconstructPath(parent, from, to)
			}
			queue = append(queue, next)
		}
	}
	return nil
}

func reconstructPath(parent map[[2]int][2]int, from, to [2]int) [][2]int {
	var path [][2]int
	cur := to
	for cur != from {
		path = append(path, cur)
		cur = parent[cur]
	}
	// reverse
	for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
		path[i], path[j] = path[j], path[i]
	}
	return path
}

A tiles_test.go => tiles_test.go +103 -0
@@ 0,0 1,103 @@
package main

import (
	"reflect"
	"testing"
)

func TestWalkableTiles_coversAllRooms(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	if len(walk) != 54 {
		t.Fatalf("expected 54 walkable tiles, got %d", len(walk))
	}
	// Spot-check one tile from each room
	checks := [][2]int{
		{4, 10}, // Pilot center
		{7, 7},  // Weapons
		{7, 10}, // Shields
		{7, 13}, // MedBay
		{11, 10}, // Engines center
	}
	for _, tc := range checks {
		if !walk[tc] {
			t.Errorf("expected tile %v to be walkable", tc)
		}
	}
	// Off-ship tile must be absent
	if walk[[2]int{0, 0}] {
		t.Error("tile (0,0) must not be walkable")
	}
}

func TestWalkableTiles_ignoresOutside(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	if walk[[2]int{0, 0}] {
		t.Error("tile (0,0) must not be walkable")
	}
	if walk[[2]int{20, 20}] {
		t.Error("tile (20,20) must not be walkable")
	}
}

func TestBFSPath_sameTile(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	path := bfsPath(walk, [2]int{4, 10}, [2]int{4, 10})
	if len(path) != 0 {
		t.Errorf("expected empty path for same-tile, got %v", path)
	}
}

func TestBFSPath_adjacentTile(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	path := bfsPath(walk, [2]int{4, 10}, [2]int{5, 10})
	if len(path) != 1 {
		t.Fatalf("expected path of length 1, got %d: %v", len(path), path)
	}
	if path[0] != ([2]int{5, 10}) {
		t.Errorf("expected path[0] == {5,10}, got %v", path[0])
	}
}

func TestBFSPath_unreachable(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	path := bfsPath(walk, [2]int{4, 10}, [2]int{0, 0})
	if len(path) != 0 {
		t.Errorf("expected empty path for unreachable target, got %v", path)
	}
}

func TestBFSPath_throughShields(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	from := [2]int{4, 10}
	to := [2]int{11, 10}
	path := bfsPath(walk, from, to)
	if len(path) != 7 {
		t.Fatalf("expected path length 7, got %d: %v", len(path), path)
	}
	if path[len(path)-1] != to {
		t.Errorf("expected last tile %v, got %v", to, path[len(path)-1])
	}
	for _, tile := range path {
		if !walk[tile] {
			t.Errorf("path contains non-walkable tile %v", tile)
		}
	}
}

func TestBFSPath_deterministic(t *testing.T) {
	s := NewPlayerShip()
	walk := walkableTiles(s)
	from := [2]int{4, 10}
	to := [2]int{11, 10}
	p1 := bfsPath(walk, from, to)
	p2 := bfsPath(walk, from, to)
	if !reflect.DeepEqual(p1, p2) {
		t.Errorf("non-deterministic: first=%v second=%v", p1, p2)
	}
}