feat: seed-based dungeon generation for deterministic floors
Thread *rand.Rand through GenerateFloor, splitBSP, and RandomRoomType so floors can be reproduced from a seed. This enables daily challenges in Phase 3. All callers now create a local rng instance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ type bspNode struct {
|
|||||||
roomIdx int
|
roomIdx int
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateFloor(floorNum int) *Floor {
|
func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
|
||||||
// Create tile map filled with walls
|
// Create tile map filled with walls
|
||||||
tiles := make([][]Tile, MapHeight)
|
tiles := make([][]Tile, MapHeight)
|
||||||
for y := 0; y < MapHeight; y++ {
|
for y := 0; y < MapHeight; y++ {
|
||||||
@@ -29,21 +29,21 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
|
|
||||||
// BSP tree
|
// BSP tree
|
||||||
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight}
|
||||||
splitBSP(root, 0)
|
splitBSP(root, 0, rng)
|
||||||
|
|
||||||
// Collect leaf nodes
|
// Collect leaf nodes
|
||||||
var leaves []*bspNode
|
var leaves []*bspNode
|
||||||
collectLeaves(root, &leaves)
|
collectLeaves(root, &leaves)
|
||||||
|
|
||||||
// Shuffle leaves so room assignment is varied
|
// Shuffle leaves so room assignment is varied
|
||||||
rand.Shuffle(len(leaves), func(i, j int) {
|
rng.Shuffle(len(leaves), func(i, j int) {
|
||||||
leaves[i], leaves[j] = leaves[j], leaves[i]
|
leaves[i], leaves[j] = leaves[j], leaves[i]
|
||||||
})
|
})
|
||||||
|
|
||||||
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
|
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
|
||||||
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
|
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
|
||||||
// Cap at 8 rooms.
|
// Cap at 8 rooms.
|
||||||
targetRooms := 5 + rand.Intn(4) // 5..8
|
targetRooms := 5 + rng.Intn(4) // 5..8
|
||||||
if len(leaves) > targetRooms {
|
if len(leaves) > targetRooms {
|
||||||
leaves = leaves[:targetRooms]
|
leaves = leaves[:targetRooms]
|
||||||
}
|
}
|
||||||
@@ -64,21 +64,21 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
|
|
||||||
rw := MinRoomW
|
rw := MinRoomW
|
||||||
if maxW > MinRoomW {
|
if maxW > MinRoomW {
|
||||||
rw = MinRoomW + rand.Intn(maxW-MinRoomW+1)
|
rw = MinRoomW + rng.Intn(maxW-MinRoomW+1)
|
||||||
}
|
}
|
||||||
rh := MinRoomH
|
rh := MinRoomH
|
||||||
if maxH > MinRoomH {
|
if maxH > MinRoomH {
|
||||||
rh = MinRoomH + rand.Intn(maxH-MinRoomH+1)
|
rh = MinRoomH + rng.Intn(maxH-MinRoomH+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position room within the leaf
|
// Position room within the leaf
|
||||||
rx := leaf.x + RoomPad
|
rx := leaf.x + RoomPad
|
||||||
if leaf.w-2*RoomPad > rw {
|
if leaf.w-2*RoomPad > rw {
|
||||||
rx += rand.Intn(leaf.w - 2*RoomPad - rw + 1)
|
rx += rng.Intn(leaf.w - 2*RoomPad - rw + 1)
|
||||||
}
|
}
|
||||||
ry := leaf.y + RoomPad
|
ry := leaf.y + RoomPad
|
||||||
if leaf.h-2*RoomPad > rh {
|
if leaf.h-2*RoomPad > rh {
|
||||||
ry += rand.Intn(leaf.h - 2*RoomPad - rh + 1)
|
ry += rng.Intn(leaf.h - 2*RoomPad - rh + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to map bounds
|
// Clamp to map bounds
|
||||||
@@ -95,7 +95,7 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
ry = 1
|
ry = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
rt := RandomRoomType()
|
rt := RandomRoomType(rng)
|
||||||
rooms[i] = &Room{
|
rooms[i] = &Room{
|
||||||
Type: rt,
|
Type: rt,
|
||||||
X: rx,
|
X: rx,
|
||||||
@@ -140,10 +140,10 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add 1-2 extra connections
|
// Add 1-2 extra connections
|
||||||
extras := 1 + rand.Intn(2)
|
extras := 1 + rng.Intn(2)
|
||||||
for e := 0; e < extras; e++ {
|
for e := 0; e < extras; e++ {
|
||||||
a := rand.Intn(len(rooms))
|
a := rng.Intn(len(rooms))
|
||||||
b := rand.Intn(len(rooms))
|
b := rng.Intn(len(rooms))
|
||||||
if a != b && !hasNeighbor(rooms[a], b) {
|
if a != b && !hasNeighbor(rooms[a], b) {
|
||||||
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
rooms[a].Neighbors = append(rooms[a].Neighbors, b)
|
||||||
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
|
rooms[b].Neighbors = append(rooms[b].Neighbors, a)
|
||||||
@@ -161,7 +161,7 @@ func GenerateFloor(floorNum int) *Floor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitBSP(node *bspNode, depth int) {
|
func splitBSP(node *bspNode, depth int, rng *rand.Rand) {
|
||||||
// Stop conditions
|
// Stop conditions
|
||||||
if depth > 4 {
|
if depth > 4 {
|
||||||
return
|
return
|
||||||
@@ -171,12 +171,12 @@ func splitBSP(node *bspNode, depth int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Random chance to stop splitting (more likely at deeper levels)
|
// Random chance to stop splitting (more likely at deeper levels)
|
||||||
if depth > 2 && rand.Float64() < 0.3 {
|
if depth > 2 && rng.Float64() < 0.3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide split direction
|
// Decide split direction
|
||||||
horizontal := rand.Float64() < 0.5
|
horizontal := rng.Float64() < 0.5
|
||||||
if node.w < MinLeafW*2 {
|
if node.w < MinLeafW*2 {
|
||||||
horizontal = true
|
horizontal = true
|
||||||
}
|
}
|
||||||
@@ -188,20 +188,20 @@ func splitBSP(node *bspNode, depth int) {
|
|||||||
if node.h < MinLeafH*2 {
|
if node.h < MinLeafH*2 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
split := MinLeafH + rand.Intn(node.h-MinLeafH*2+1)
|
split := MinLeafH + rng.Intn(node.h-MinLeafH*2+1)
|
||||||
node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split}
|
node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split}
|
||||||
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
|
node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split}
|
||||||
} else {
|
} else {
|
||||||
if node.w < MinLeafW*2 {
|
if node.w < MinLeafW*2 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
split := MinLeafW + rand.Intn(node.w-MinLeafW*2+1)
|
split := MinLeafW + rng.Intn(node.w-MinLeafW*2+1)
|
||||||
node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h}
|
node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h}
|
||||||
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
|
node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h}
|
||||||
}
|
}
|
||||||
|
|
||||||
splitBSP(node.left, depth+1)
|
splitBSP(node.left, depth+1, rng)
|
||||||
splitBSP(node.right, depth+1)
|
splitBSP(node.right, depth+1, rng)
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
|
func collectLeaves(node *bspNode, leaves *[]*bspNode) {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package dungeon
|
package dungeon
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRng() *rand.Rand {
|
||||||
|
return rand.New(rand.NewSource(rand.Int63()))
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateFloor(t *testing.T) {
|
func TestGenerateFloor(t *testing.T) {
|
||||||
floor := GenerateFloor(1)
|
floor := GenerateFloor(1, newTestRng())
|
||||||
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
|
if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 {
|
||||||
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
|
t.Errorf("Room count: got %d, want 5~8", len(floor.Rooms))
|
||||||
}
|
}
|
||||||
@@ -32,8 +39,9 @@ func TestGenerateFloor(t *testing.T) {
|
|||||||
func TestRoomTypeProbability(t *testing.T) {
|
func TestRoomTypeProbability(t *testing.T) {
|
||||||
counts := make(map[RoomType]int)
|
counts := make(map[RoomType]int)
|
||||||
n := 10000
|
n := 10000
|
||||||
|
rng := rand.New(rand.NewSource(12345))
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
counts[RandomRoomType()]++
|
counts[RandomRoomType(rng)]++
|
||||||
}
|
}
|
||||||
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
|
combatPct := float64(counts[RoomCombat]) / float64(n) * 100
|
||||||
if combatPct < 40 || combatPct > 50 {
|
if combatPct < 40 || combatPct > 50 {
|
||||||
@@ -44,8 +52,9 @@ func TestRoomTypeProbability(t *testing.T) {
|
|||||||
func TestSecretRoomInRandomType(t *testing.T) {
|
func TestSecretRoomInRandomType(t *testing.T) {
|
||||||
counts := make(map[RoomType]int)
|
counts := make(map[RoomType]int)
|
||||||
n := 10000
|
n := 10000
|
||||||
|
rng := rand.New(rand.NewSource(12345))
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
counts[RandomRoomType()]++
|
counts[RandomRoomType(rng)]++
|
||||||
}
|
}
|
||||||
secretPct := float64(counts[RoomSecret]) / float64(n) * 100
|
secretPct := float64(counts[RoomSecret]) / float64(n) * 100
|
||||||
if secretPct < 2 || secretPct > 8 {
|
if secretPct < 2 || secretPct > 8 {
|
||||||
@@ -59,7 +68,7 @@ func TestSecretRoomInRandomType(t *testing.T) {
|
|||||||
|
|
||||||
func TestMiniBossRoomPlacement(t *testing.T) {
|
func TestMiniBossRoomPlacement(t *testing.T) {
|
||||||
for _, floorNum := range []int{4, 9, 14, 19} {
|
for _, floorNum := range []int{4, 9, 14, 19} {
|
||||||
floor := GenerateFloor(floorNum)
|
floor := GenerateFloor(floorNum, newTestRng())
|
||||||
found := false
|
found := false
|
||||||
for _, r := range floor.Rooms {
|
for _, r := range floor.Rooms {
|
||||||
if r.Type == RoomMiniBoss {
|
if r.Type == RoomMiniBoss {
|
||||||
@@ -73,7 +82,7 @@ func TestMiniBossRoomPlacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Non-miniboss floors should not have mini-boss rooms
|
// Non-miniboss floors should not have mini-boss rooms
|
||||||
for _, floorNum := range []int{1, 3, 5, 10} {
|
for _, floorNum := range []int{1, 3, 5, 10} {
|
||||||
floor := GenerateFloor(floorNum)
|
floor := GenerateFloor(floorNum, newTestRng())
|
||||||
for _, r := range floor.Rooms {
|
for _, r := range floor.Rooms {
|
||||||
if r.Type == RoomMiniBoss {
|
if r.Type == RoomMiniBoss {
|
||||||
t.Errorf("Floor %d should not have a mini-boss room", floorNum)
|
t.Errorf("Floor %d should not have a mini-boss room", floorNum)
|
||||||
@@ -84,7 +93,7 @@ func TestMiniBossRoomPlacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFloorHasTileMap(t *testing.T) {
|
func TestFloorHasTileMap(t *testing.T) {
|
||||||
floor := GenerateFloor(1)
|
floor := GenerateFloor(1, newTestRng())
|
||||||
if floor.Tiles == nil {
|
if floor.Tiles == nil {
|
||||||
t.Fatal("Floor should have tile map")
|
t.Fatal("Floor should have tile map")
|
||||||
}
|
}
|
||||||
@@ -98,3 +107,27 @@ func TestFloorHasTileMap(t *testing.T) {
|
|||||||
t.Errorf("Room center should be floor tile, got %d", centerTile)
|
t.Errorf("Room center should be floor tile, got %d", centerTile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeterministicGeneration(t *testing.T) {
|
||||||
|
rng1 := rand.New(rand.NewSource(42))
|
||||||
|
rng2 := rand.New(rand.NewSource(42))
|
||||||
|
f1 := GenerateFloor(5, rng1)
|
||||||
|
f2 := GenerateFloor(5, rng2)
|
||||||
|
if len(f1.Rooms) != len(f2.Rooms) {
|
||||||
|
t.Fatalf("room counts differ: %d vs %d", len(f1.Rooms), len(f2.Rooms))
|
||||||
|
}
|
||||||
|
for i, r := range f1.Rooms {
|
||||||
|
if r.Type != f2.Rooms[i].Type || r.X != f2.Rooms[i].X || r.Y != f2.Rooms[i].Y {
|
||||||
|
t.Errorf("room %d differs: type=%v/%v x=%d/%d y=%d/%d",
|
||||||
|
i, r.Type, f2.Rooms[i].Type, r.X, f2.Rooms[i].X, r.Y, f2.Rooms[i].Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also verify tile maps match
|
||||||
|
for y := 0; y < f1.Height; y++ {
|
||||||
|
for x := 0; x < f1.Width; x++ {
|
||||||
|
if f1.Tiles[y][x] != f2.Tiles[y][x] {
|
||||||
|
t.Errorf("tile at (%d,%d) differs: %d vs %d", x, y, f1.Tiles[y][x], f2.Tiles[y][x])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ type Floor struct {
|
|||||||
Height int
|
Height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func RandomRoomType() RoomType {
|
func RandomRoomType(rng *rand.Rand) RoomType {
|
||||||
r := rand.Float64() * 100
|
r := rng.Float64() * 100
|
||||||
switch {
|
switch {
|
||||||
case r < 5:
|
case r < 5:
|
||||||
return RoomSecret
|
return RoomSecret
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package game
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ func (s *GameSession) AddPlayer(p *entity.Player) {
|
|||||||
func (s *GameSession) StartFloor() {
|
func (s *GameSession) StartFloor() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||||
s.state.Phase = PhaseExploring
|
s.state.Phase = PhaseExploring
|
||||||
s.state.TurnNum = 0
|
s.state.TurnNum = 0
|
||||||
|
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ func (s *GameSession) advanceFloor() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.state.FloorNum++
|
s.state.FloorNum++
|
||||||
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
|
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano())))
|
||||||
s.state.Phase = PhaseExploring
|
s.state.Phase = PhaseExploring
|
||||||
s.state.CombatTurn = 0
|
s.state.CombatTurn = 0
|
||||||
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
|
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))
|
||||||
|
|||||||
Reference in New Issue
Block a user