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:
2026-03-25 15:39:21 +09:00
parent 7f29995833
commit 65c062a1f7
5 changed files with 64 additions and 30 deletions

View File

@@ -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) {

View File

@@ -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])
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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))