From 65c062a1f7270cefcf4bdda5b28d1f79a3dc7ca9 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 15:39:21 +0900 Subject: [PATCH] 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) --- dungeon/generator.go | 38 +++++++++++++++---------------- dungeon/generator_test.go | 47 +++++++++++++++++++++++++++++++++------ dungeon/room.go | 4 ++-- game/session.go | 3 ++- game/turn.go | 2 +- 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/dungeon/generator.go b/dungeon/generator.go index 7e774dc..189644e 100644 --- a/dungeon/generator.go +++ b/dungeon/generator.go @@ -19,7 +19,7 @@ type bspNode struct { roomIdx int } -func GenerateFloor(floorNum int) *Floor { +func GenerateFloor(floorNum int, rng *rand.Rand) *Floor { // Create tile map filled with walls tiles := make([][]Tile, MapHeight) for y := 0; y < MapHeight; y++ { @@ -29,21 +29,21 @@ func GenerateFloor(floorNum int) *Floor { // BSP tree root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight} - splitBSP(root, 0) + splitBSP(root, 0, rng) // Collect leaf nodes var leaves []*bspNode collectLeaves(root, &leaves) // 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] }) // 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). // Cap at 8 rooms. - targetRooms := 5 + rand.Intn(4) // 5..8 + targetRooms := 5 + rng.Intn(4) // 5..8 if len(leaves) > targetRooms { leaves = leaves[:targetRooms] } @@ -64,21 +64,21 @@ func GenerateFloor(floorNum int) *Floor { rw := MinRoomW if maxW > MinRoomW { - rw = MinRoomW + rand.Intn(maxW-MinRoomW+1) + rw = MinRoomW + rng.Intn(maxW-MinRoomW+1) } rh := MinRoomH if maxH > MinRoomH { - rh = MinRoomH + rand.Intn(maxH-MinRoomH+1) + rh = MinRoomH + rng.Intn(maxH-MinRoomH+1) } // Position room within the leaf rx := leaf.x + RoomPad 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 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 @@ -95,7 +95,7 @@ func GenerateFloor(floorNum int) *Floor { ry = 1 } - rt := RandomRoomType() + rt := RandomRoomType(rng) rooms[i] = &Room{ Type: rt, X: rx, @@ -140,10 +140,10 @@ func GenerateFloor(floorNum int) *Floor { } // Add 1-2 extra connections - extras := 1 + rand.Intn(2) + extras := 1 + rng.Intn(2) for e := 0; e < extras; e++ { - a := rand.Intn(len(rooms)) - b := rand.Intn(len(rooms)) + a := rng.Intn(len(rooms)) + b := rng.Intn(len(rooms)) if a != b && !hasNeighbor(rooms[a], b) { rooms[a].Neighbors = append(rooms[a].Neighbors, b) 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 if depth > 4 { return @@ -171,12 +171,12 @@ func splitBSP(node *bspNode, depth int) { } // 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 } // Decide split direction - horizontal := rand.Float64() < 0.5 + horizontal := rng.Float64() < 0.5 if node.w < MinLeafW*2 { horizontal = true } @@ -188,20 +188,20 @@ func splitBSP(node *bspNode, depth int) { if node.h < MinLeafH*2 { 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.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split} } else { if node.w < MinLeafW*2 { 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.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h} } - splitBSP(node.left, depth+1) - splitBSP(node.right, depth+1) + splitBSP(node.left, depth+1, rng) + splitBSP(node.right, depth+1, rng) } func collectLeaves(node *bspNode, leaves *[]*bspNode) { diff --git a/dungeon/generator_test.go b/dungeon/generator_test.go index 1010e9d..4d1dcb5 100644 --- a/dungeon/generator_test.go +++ b/dungeon/generator_test.go @@ -1,9 +1,16 @@ package dungeon -import "testing" +import ( + "math/rand" + "testing" +) + +func newTestRng() *rand.Rand { + return rand.New(rand.NewSource(rand.Int63())) +} func TestGenerateFloor(t *testing.T) { - floor := GenerateFloor(1) + floor := GenerateFloor(1, newTestRng()) if len(floor.Rooms) < 5 || len(floor.Rooms) > 8 { 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) { counts := make(map[RoomType]int) n := 10000 + rng := rand.New(rand.NewSource(12345)) for i := 0; i < n; i++ { - counts[RandomRoomType()]++ + counts[RandomRoomType(rng)]++ } combatPct := float64(counts[RoomCombat]) / float64(n) * 100 if combatPct < 40 || combatPct > 50 { @@ -44,8 +52,9 @@ func TestRoomTypeProbability(t *testing.T) { func TestSecretRoomInRandomType(t *testing.T) { counts := make(map[RoomType]int) n := 10000 + rng := rand.New(rand.NewSource(12345)) for i := 0; i < n; i++ { - counts[RandomRoomType()]++ + counts[RandomRoomType(rng)]++ } secretPct := float64(counts[RoomSecret]) / float64(n) * 100 if secretPct < 2 || secretPct > 8 { @@ -59,7 +68,7 @@ func TestSecretRoomInRandomType(t *testing.T) { func TestMiniBossRoomPlacement(t *testing.T) { for _, floorNum := range []int{4, 9, 14, 19} { - floor := GenerateFloor(floorNum) + floor := GenerateFloor(floorNum, newTestRng()) found := false for _, r := range floor.Rooms { if r.Type == RoomMiniBoss { @@ -73,7 +82,7 @@ func TestMiniBossRoomPlacement(t *testing.T) { } // Non-miniboss floors should not have mini-boss rooms for _, floorNum := range []int{1, 3, 5, 10} { - floor := GenerateFloor(floorNum) + floor := GenerateFloor(floorNum, newTestRng()) for _, r := range floor.Rooms { if r.Type == RoomMiniBoss { 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) { - floor := GenerateFloor(1) + floor := GenerateFloor(1, newTestRng()) if floor.Tiles == nil { 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) } } + +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]) + } + } + } +} diff --git a/dungeon/room.go b/dungeon/room.go index 1c59491..b0c7939 100644 --- a/dungeon/room.go +++ b/dungeon/room.go @@ -46,8 +46,8 @@ type Floor struct { Height int } -func RandomRoomType() RoomType { - r := rand.Float64() * 100 +func RandomRoomType(rng *rand.Rand) RoomType { + r := rng.Float64() * 100 switch { case r < 5: return RoomSecret diff --git a/game/session.go b/game/session.go index f48b692..c98182a 100644 --- a/game/session.go +++ b/game/session.go @@ -3,6 +3,7 @@ package game import ( "fmt" "log/slog" + "math/rand" "sync" "time" @@ -204,7 +205,7 @@ func (s *GameSession) AddPlayer(p *entity.Player) { func (s *GameSession) StartFloor() { s.mu.Lock() 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.TurnNum = 0 diff --git a/game/turn.go b/game/turn.go index bfbf6f4..f6d5679 100644 --- a/game/turn.go +++ b/game/turn.go @@ -357,7 +357,7 @@ func (s *GameSession) advanceFloor() { return } 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.CombatTurn = 0 s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum))