From 5ccd6dfbcd5e675e65dc63f400521065ed5587c2 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:08:31 -0700 Subject: [PATCH] feat: extend dungeonwright --- CHANGELOG.md | 13 +++++ README.md | 45 ++++++++++------ docs/architecture.md | 21 +++++++- docs/roadmap.md | 49 +++++++++++++++++ project.godot | 2 +- scripts/actors/monster_actor.gd | 10 ++-- scripts/actors/player.gd | 13 +++++ scripts/core/floor_rules.gd | 46 ++++++++++++++++ scripts/core/floor_rules.gd.uid | 1 + scripts/core/run_state.gd | 2 + scripts/dungeon/dungeon_config.gd | 6 +++ scripts/dungeon/dungeon_result.gd | 4 ++ scripts/main.gd | 86 ++++++++++++++++++++++-------- scripts/ui/hud.gd | 6 ++- scripts/ui/result_overlay.gd | 6 +-- tests/integration/test_full_run.gd | 13 +++++ tests/unit/test_floor_rules.gd | 68 +++++++++++++++++++++++ tests/unit/test_floor_rules.gd.uid | 1 + tools/smoke.gd | 41 +++++++++++--- 19 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 docs/roadmap.md create mode 100644 scripts/core/floor_rules.gd create mode 100644 scripts/core/floor_rules.gd.uid create mode 100644 tests/unit/test_floor_rules.gd create mode 100644 tests/unit/test_floor_rules.gd.uid diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..12a3282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are listed here. The format follows Keep a Changelog. This project uses semantic versioning. +## [0.3.0] - 2026-08-03 + +Added + +- Multi-floor descent with five floors per run. +- A floor counter in the HUD. +- Reaching the exit on a lower floor descends to the next floor. +- Per-floor monster scaling for health and damage. +- Deeper floors raise monster density and door counts. +- Deterministic floor seeds derived from the run seed. +- A floors total in the run summary. +- Unit tests for floor rules and multi-floor replay. + ## [0.2.0] - 2026-08-03 Added diff --git a/README.md b/README.md index 9c733e9..a832b7a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Dungeonwright A seeded dungeon crawler built with Godot and GDScript. -Every run builds a new dungeon that you can explore and finish. +Every run builds new dungeons that you explore floor by floor. ``` ####.D.############ @@ -15,14 +15,15 @@ Every run builds a new dungeon that you can explore and finish. ## What it is -Dungeonwright generates a connected dungeon on every run. +Dungeonwright generates a connected dungeon for every floor. You explore rooms and corridors. You find keys, open locked doors, and reach the exit. -The same seed always builds the same dungeon. +The same seed always replays the same descent. ## Features - A new map for every run, driven by a seed. +- Multi-floor descents with five floors per run. - Rooms, corridors, locked doors, and keys. - Three biomes with different generation rules. - Monsters with simple combat and balanced drops. @@ -40,11 +41,14 @@ You can replay any run from its seed. ## This release -This release adds full gamepad support. -Every action has a gamepad binding. -Movement uses the left stick or the d-pad. -Analog input gets a deadzone and a diagonal speed cap. -Menus show the controls for the active device. +This release adds multi-floor descent. +Each run spans five floors. +Reaching the exit on a lower floor descends to the next one. +Deeper floors grow harder. +Monsters gain health and damage. +They spawn more often and in greater numbers. +The HUD shows the current floor. +The full descent replays from one seed. ## Requirements @@ -106,6 +110,13 @@ The generation code is pure data. It has no scene nodes, so tests run fast and deterministic. The scene controller turns the map into a live game. +Floors use the same pipeline. +Each floor derives its seed from the run seed. +The full descent then replays from one seed. + +Monster stats scale with the floor number. +Deeper floors also add more monsters and doors. + ## Project layout - `scripts/dungeon` holds the generator and map logic. @@ -121,33 +132,37 @@ The scene controller turns the map into a live game. ## Design guarantees A seed always produces the same map. +A run seed replays every floor of the descent. Doors never block the exit permanently. Every key sits on the reachable side of its door. Monsters never cross a locked door. ## Evaluation evidence -The suite has 68 tests. -It covers generation, biomes, combat, drops, pathfinding, and input. -All 68 tests pass in a headless run. +The suite has 77 tests. +It covers generation, biomes, floors, combat, drops, pathfinding, and input. +All 77 tests pass in a headless run. A smoke test loads the game and spawns a fixed-seed run. -The smoke test also checks every action has a gamepad binding. +The smoke test also descends one floor and re-verifies the world. +It also checks every action has a gamepad binding. ## Roadmap Done in this release: -- Full gamepad support. +- Multi-floor descent and a depth counter. +- Per-floor monster scaling. Next up: -- Multi-floor descent and a depth counter. - Ranged monsters and projectiles. - Sound and music. - More biomes and items. +See `docs/roadmap.md` for the full plan. + ## Limitations The demo has three biomes. -Each run is a single floor. +Each run spans five floors. All monsters use melee attacks. The game has no audio yet. diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..512c8b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,24 @@ The world renders from a tile map. A `TileArt` class draws every sprite from pixel patterns. The biome palette recolors the tiles at run time. +## Floor progression + +A run spans five floors. +Each floor is its own generated dungeon. +`FloorRules` derives a floor seed from the run seed. +The whole descent replays from the single run seed. + +The hero reaches the exit on a lower floor and descends. +The `Main` scene keeps the hero health and loot. +Keys reset, because every floor has its own locks. +The final floor ends the run with a victory summary. + +Deeper floors scale the monster pressure. +`FloorRules.scaled` returns a copy of the biome. +Monster health and damage rise each floor. +Monster density, monster cap, and door counts also rise. +The run summary shows the floor count reached. + ## Input handling A `Controls` class reads all movement input. @@ -111,9 +129,10 @@ The hints update when a gamepad connects or disconnects. ## Testing The suite runs headless with GUT. -Unit tests cover the RNG, generator, biomes, combat, and drops. +Unit tests cover the RNG, generator, biomes, combat, drops, and floors. Integration tests run many seeds across all biomes. Every generated dungeon must be solvable. +Every floor of a run must replay from its run seed. Run the suite with `tools/run_tests`. CI runs the same commands on every push. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..feec081 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,49 @@ +# Roadmap + +This roadmap tracks the planned releases. +It shows what is done and what remains. + +## Released + +### 0.1.0 - Foundations + +- Seed-driven dungeon generation with three biomes. +- Rooms, corridors, locked doors, and keys. +- Monsters, melee combat, and drop tables. +- Deterministic replay from a seed string. +- Procedural tile art and a minimap. +- A headless test suite and a smoke test. + +### 0.2.0 - Gamepad support + +- Gamepad bindings for every action. +- Analog stick movement with a deadzone. +- Normalised diagonal movement speed. +- Control hints for the active device. + +### 0.3.0 - Multi-floor descent + +- Five floors per run with a floor counter. +- Reaching the exit on a lower floor descends. +- Per-floor monster scaling. +- Deterministic floor seeds from the run seed. + +## Next up + +### Ranged combat + +- Ranged monsters and projectiles. + +### Audio + +- Sound and music. + +### Content + +- More biomes and items. + +## Scope notes + +- Every feature must keep generation deterministic. +- The exit must stay reachable on every floor. +- New systems need unit or integration tests. diff --git a/project.godot b/project.godot index 0afbba8..682dc1e 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Dungeonwright" -config/description="A seeded dungeon crawler that builds a new, solvable map every run." +config/description="A seeded dungeon crawler that builds new, solvable maps for every floor of a run." run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/scripts/actors/monster_actor.gd b/scripts/actors/monster_actor.gd index 8ceebe2..bb75575 100644 --- a/scripts/actors/monster_actor.gd +++ b/scripts/actors/monster_actor.gd @@ -37,13 +37,15 @@ func setup( p_start: Vector2i, p_view: DungeonView, p_occupancy: Dictionary, - p_target: Node2D + p_target: Node2D, + p_health_scale: float = 1.0, + p_damage_scale: float = 1.0 ) -> void: spec = p_spec stats = CombatStats.make({ - "max_health": p_spec.stats.max_health, - "health": p_spec.stats.max_health, - "damage": p_spec.stats.damage, + "max_health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)), + "health": maxi(1, roundi(p_spec.stats.max_health * p_health_scale)), + "damage": maxi(1, roundi(p_spec.stats.damage * p_damage_scale)), "speed": p_spec.stats.speed, "attack_range": p_spec.stats.attack_range, "attack_cooldown": p_spec.stats.attack_cooldown, diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 5fa8967..0c978b9 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -58,6 +58,19 @@ func setup( _add_light() emit_hud() +## Repositions the hero for a new floor. Keeps health, coins, and +## shards. Keys reset, because every floor has its own locks. +func relocate(p_start: Vector2i, p_view: DungeonView, p_occupancy: Dictionary) -> void: + view = p_view + occupancy = p_occupancy + grid_pos = p_start + position = view.tile_to_world(grid_pos) + keys_held = 0 + _moving = false + _progress = 1.0 + _attack_timer = 0.0 + emit_hud() + func _physics_process(p_delta: float) -> void: if view == null or stats == null: return diff --git a/scripts/core/floor_rules.gd b/scripts/core/floor_rules.gd new file mode 100644 index 0000000..556a422 --- /dev/null +++ b/scripts/core/floor_rules.gd @@ -0,0 +1,46 @@ +class_name FloorRules +extends RefCounted +## Defines the floor progression of a run. +## +## A run spans a fixed number of floors. Every floor derives a fresh seed +## from the run seed, so the whole descent repeats from one seed. Deeper +## floors scale the monster pressure, so the run grows harder as you go. + +const FLOOR_COUNT := 5 +const SEED_MASK := 0xFFFFFF +const SEED_MIX := 2654435761 + +## Returns the deterministic seed of the given floor inside a run. +static func seed_for(p_run_seed: int, p_floor: int) -> int: + return (p_run_seed + p_floor * SEED_MIX) & SEED_MASK + +## True when the floor is the last one of a run. +static func is_final(p_floor: int) -> bool: + return p_floor >= FLOOR_COUNT + +## Returns a copy of a biome scaled for the given floor. +## Monsters grow stronger and denser with every descent. +static func scaled(p_biome: DungeonConfig, p_floor: int) -> DungeonConfig: + var config := DungeonConfig.new() + config.id = p_biome.id + config.display_name = p_biome.display_name + config.description = p_biome.description + config.width = p_biome.width + config.height = p_biome.height + config.room_count_min = p_biome.room_count_min + config.room_count_max = p_biome.room_count_max + config.room_min = p_biome.room_min + config.room_max = p_biome.room_max + config.corridor_style = p_biome.corridor_style + config.loop_chance = p_biome.loop_chance + config.door_count_min = p_biome.door_count_min + config.door_count_max = mini(6, p_biome.door_count_max + (p_floor - 1) / 2) + config.monster_density = minf(0.9, p_biome.monster_density + 0.05 * (p_floor - 1)) + config.monster_cap = p_biome.monster_cap + (p_floor - 1) / 2 + config.monster_table = p_biome.monster_table + config.starting_health = p_biome.starting_health + config.player_damage = p_biome.player_damage + config.palette = p_biome.palette + config.monster_health_scale = 1.0 + 0.15 * (p_floor - 1) + config.monster_damage_scale = 1.0 + 0.1 * (p_floor - 1) + return config diff --git a/scripts/core/floor_rules.gd.uid b/scripts/core/floor_rules.gd.uid new file mode 100644 index 0000000..9f9c33b --- /dev/null +++ b/scripts/core/floor_rules.gd.uid @@ -0,0 +1 @@ +uid://cbf1g1jby6ocv diff --git a/scripts/core/run_state.gd b/scripts/core/run_state.gd index 2f8f9ef..2aa2315 100644 --- a/scripts/core/run_state.gd +++ b/scripts/core/run_state.gd @@ -10,6 +10,8 @@ enum RunStatus { IDLE, ACTIVE, WON, LOST } static var seed_value: int = 0 static var seed_string: String = "" static var biome_id: StringName = &"" +static var floor: int = 1 +static var floor_count: int = FloorRules.FLOOR_COUNT static var status: RunStatus = RunStatus.IDLE static var started_at: float = 0.0 static var finished_at: float = 0.0 diff --git a/scripts/dungeon/dungeon_config.gd b/scripts/dungeon/dungeon_config.gd index 3e5b9f7..749588a 100644 --- a/scripts/dungeon/dungeon_config.gd +++ b/scripts/dungeon/dungeon_config.gd @@ -28,6 +28,8 @@ var door_count_max: int = 3 var monster_density: float = 0.5 var monster_cap: int = 10 var monster_table: Array = [] +var monster_health_scale: float = 1.0 +var monster_damage_scale: float = 1.0 var starting_health: int = 100 var player_damage: int = 12 var palette: Dictionary = {} @@ -45,6 +47,10 @@ func validate() -> Array[String]: problems.append("door count is negative") if monster_density < 0.0 or monster_density > 1.0: problems.append("monster density must be between 0 and 1") + if monster_health_scale < 1.0: + problems.append("monster health scale is below 1") + if monster_damage_scale < 1.0: + problems.append("monster damage scale is below 1") if loop_chance < 0.0 or loop_chance > 1.0: problems.append("loop chance must be between 0 and 1") if monster_table.is_empty(): diff --git a/scripts/dungeon/dungeon_result.gd b/scripts/dungeon/dungeon_result.gd index e70264c..3db5c6e 100644 --- a/scripts/dungeon/dungeon_result.gd +++ b/scripts/dungeon/dungeon_result.gd @@ -8,6 +8,10 @@ extends RefCounted var seed_value: int = 0 var config: DungeonConfig = null var map: DungeonMap = null +## One-based floor number inside the run that owns this map. +var floor: int = 1 +## Total number of floors in the run. +var floor_count: int = 1 var rooms: Array[Room] = [] var corridors: Array[Corridor] = [] var start_room: int = -1 diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..4e63689 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -92,20 +92,58 @@ func start_run(p_seed_value: int) -> void: func _start_run(p_seed_value: int) -> void: run_seed = p_seed_value - biome = Biomes.random(SeededRng.new(p_seed_value ^ 0x5EED)) - run = DungeonGenerator.new().generate(biome, p_seed_value) + RunState.floor = 1 + RunState.floor_count = FloorRules.FLOOR_COUNT + _generate_floor(1) + _setup_world(false) + RunState.seed_value = p_seed_value + RunState.seed_string = SeededRng.encode_seed(p_seed_value) + RunState.status = RunState.RunStatus.ACTIVE + RunState.started_at = Time.get_ticks_msec() / 1000.0 + + menu_overlay.hide_menu() + result_overlay.hide_result() + get_tree().paused = false + run_started.emit(p_seed_value) + +## Starts the next floor of the current run. Public entry point for +## tooling. Mirrors reaching the exit on a non-final floor. +func advance_floor() -> void: + if run == null or _ended: + return + _descend() + +func _descend() -> void: + if RunState.floor >= FloorRules.FLOOR_COUNT: + return + RunState.floor += 1 + _generate_floor(RunState.floor) + _setup_world(true) + +func _generate_floor(p_floor: int) -> void: + var floor_seed := FloorRules.seed_for(run_seed, p_floor) + biome = Biomes.random(SeededRng.new(floor_seed ^ 0x5EED)) + var config := FloorRules.scaled(biome, p_floor) + run = DungeonGenerator.new().generate(config, floor_seed) + run.floor = p_floor + run.floor_count = FloorRules.FLOOR_COUNT + +func _setup_world(p_keep_stats: bool) -> void: _clear_world() occupancy.clear() _ended = false dungeon_view.configure(run.map, biome) - var stats := CombatStats.make({ - "max_health": biome.starting_health, - "health": biome.starting_health, - "damage": biome.player_damage, - }) - player.setup(stats, run.start_pos, dungeon_view, occupancy) + if p_keep_stats: + player.relocate(run.start_pos, dungeon_view, occupancy) + else: + var stats := CombatStats.make({ + "max_health": biome.starting_health, + "health": biome.starting_health, + "damage": biome.player_damage, + }) + player.setup(stats, run.start_pos, dungeon_view, occupancy) occupancy[run.start_pos] = player player.can_enter = _hero_can_enter @@ -123,25 +161,24 @@ func _start_run(p_seed_value: int) -> void: _apply_camera_limits() ambient.color = Color(biome.palette.get(&"accent", Color.WHITE)).darkened(0.55) - hud.set_run(biome.display_name, SeededRng.encode_seed(p_seed_value), run.depth) + hud.set_run( + biome.display_name, + SeededRng.encode_seed(run_seed), + run.depth, + RunState.floor, + RunState.floor_count + ) hud.set_minimap(dungeon_view.build_minimap_image(), run.map.width, run.map.height) - - RunState.seed_value = p_seed_value - RunState.seed_string = SeededRng.encode_seed(p_seed_value) RunState.biome_id = biome.id - RunState.status = RunState.RunStatus.ACTIVE - RunState.started_at = Time.get_ticks_msec() / 1000.0 - - menu_overlay.hide_menu() - result_overlay.hide_result() - get_tree().paused = false - run_started.emit(p_seed_value) func _spawn_monster(p_position: Vector2i, p_monster_id: StringName) -> void: var spec := MonsterSpecs.by_id(p_monster_id) var monster: MonsterActor = MONSTER_SCENE.instantiate() monsters_root.add_child(monster) - monster.setup(spec, p_position, dungeon_view, occupancy, player) + monster.setup( + spec, p_position, dungeon_view, occupancy, player, + run.config.monster_health_scale, run.config.monster_damage_scale + ) occupancy[p_position] = monster monster.attack_player.connect(_on_monster_attack) monster.died.connect(_on_monster_died) @@ -208,13 +245,17 @@ func _on_player_died() -> void: SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + RunState.floor ) get_tree().paused = true func _on_victory() -> void: if _ended: return + if not FloorRules.is_final(RunState.floor): + _descend() + return _ended = true RunState.status = RunState.RunStatus.WON RunState.finished_at = Time.get_ticks_msec() / 1000.0 @@ -223,7 +264,8 @@ func _on_victory() -> void: SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + RunState.floor ) get_tree().paused = true diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..99d55c1 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -7,6 +7,7 @@ extends Control var biome_label: Label = null var seed_label: Label = null +var floor_label: Label = null var depth_label: Label = null var hp_fill: ColorRect = null var hp_label: Label = null @@ -27,9 +28,10 @@ func _ready() -> void: _build_bottom_panel() _build_minimap() -func set_run(p_biome: String, p_seed: String, p_depth: int) -> void: +func set_run(p_biome: String, p_seed: String, p_depth: int, p_floor: int, p_floor_count: int) -> void: biome_label.text = "Biome: " + p_biome seed_label.text = "Seed: " + p_seed + floor_label.text = "Floor %d / %d" % [p_floor, p_floor_count] depth_label.text = "Exit at depth " + str(p_depth) func set_hp(p_current: int, p_max: int) -> void: @@ -72,9 +74,11 @@ func _build_top_panel() -> void: panel.add_child(box) biome_label = _label("", 15) seed_label = _label("", 15) + floor_label = _label("", 15) depth_label = _label("", 15) box.add_child(biome_label) box.add_child(seed_label) + box.add_child(floor_label) box.add_child(depth_label) add_child(panel) diff --git a/scripts/ui/result_overlay.gd b/scripts/ui/result_overlay.gd index b6417e5..5b6353b 100644 --- a/scripts/ui/result_overlay.gd +++ b/scripts/ui/result_overlay.gd @@ -17,14 +17,14 @@ func _ready() -> void: visible = false _build() -func show_result(p_won: bool, p_seed: String, p_depth: int, p_coins: int, p_time: float) -> void: +func show_result(p_won: bool, p_seed: String, p_depth: int, p_coins: int, p_time: float, p_floors: int) -> void: visible = true _title.text = "Victory" if p_won else "Defeat" _title.add_theme_color_override("font_color", Color("#7fe8b9") if p_won else Color("#e07070")) var minutes := int(p_time) / 60 var seconds := int(p_time) % 60 - _summary.text = "Seed: %s\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ - p_seed, p_depth, p_coins, minutes, seconds, + _summary.text = "Seed: %s\nDepth: %d\nFloors: %d\nCoins: %d\nTime: %d:%02d" % [ + p_seed, p_depth, p_floors, p_coins, minutes, seconds, ] _same_seed_button.visible = p_won _new_button.grab_focus() diff --git a/tests/integration/test_full_run.gd b/tests/integration/test_full_run.gd index 1aaf900..99b3caa 100644 --- a/tests/integration/test_full_run.gd +++ b/tests/integration/test_full_run.gd @@ -56,3 +56,16 @@ func test_custom_seed_string_matches_run_seed() -> void: var biome := Biomes.crypt() var result := DungeonGenerator.new().generate(biome, seed) assert_eq(result.seed_value, SeededRng.decode_seed(SeededRng.encode_seed(seed))) + +func test_every_floor_of_a_run_stays_solvable() -> void: + var generator := DungeonGenerator.new() + for run_seed in [1, 7, 42, 818]: + for floor in range(1, FloorRules.FLOOR_COUNT + 1): + var floor_seed := FloorRules.seed_for(run_seed, floor) + var biome := Biomes.random(SeededRng.new(floor_seed ^ 0x5EED)) + var result := generator.generate(FloorRules.scaled(biome, floor), floor_seed) + assert_true(result.solvable, "run %d floor %d is not solvable" % [run_seed, floor]) + assert_true( + Pathfinding.reaches(result.map, result.start_pos, result.exit_pos, true), + "run %d floor %d exit unreachable" % [run_seed, floor] + ) diff --git a/tests/unit/test_floor_rules.gd b/tests/unit/test_floor_rules.gd new file mode 100644 index 0000000..a6f5eba --- /dev/null +++ b/tests/unit/test_floor_rules.gd @@ -0,0 +1,68 @@ +extends GutTest +## Floor progression: seed derivation, scaling, and replayable descents. + +func test_seed_for_floor_is_deterministic() -> void: + assert_eq(FloorRules.seed_for(12345, 2), FloorRules.seed_for(12345, 2)) + assert_eq(FloorRules.seed_for(999, 4), FloorRules.seed_for(999, 4)) + +func test_different_floors_derive_different_seeds() -> void: + var seen := {} + for floor in range(1, FloorRules.FLOOR_COUNT + 1): + var seed := FloorRules.seed_for(12345, floor) + assert_false(seen.has(seed), "floor %d repeats a seed" % floor) + seen[seed] = true + +func test_final_floor_is_recognised() -> void: + assert_eq(FloorRules.FLOOR_COUNT, 5) + assert_true(FloorRules.is_final(FloorRules.FLOOR_COUNT)) + assert_false(FloorRules.is_final(FloorRules.FLOOR_COUNT - 1)) + +func test_scaled_keeps_biome_identity() -> void: + var biome := Biomes.crypt() + var scaled := FloorRules.scaled(biome, 3) + assert_eq(scaled.id, biome.id) + assert_eq(scaled.display_name, biome.display_name) + assert_eq(scaled.palette, biome.palette) + +func test_scaled_stays_valid_on_every_floor() -> void: + for biome in Biomes.all(): + for floor in range(1, FloorRules.FLOOR_COUNT + 1): + var scaled := FloorRules.scaled(biome, floor) + assert_true(scaled.is_valid(), "%s floor %d is invalid" % [biome.id, floor]) + +func test_deeper_floors_scale_monster_pressure() -> void: + var base := Biomes.crypt() + var shallow := FloorRules.scaled(base, 1) + var deep := FloorRules.scaled(base, 5) + assert_eq(shallow.monster_health_scale, 1.0) + assert_gt(deep.monster_health_scale, shallow.monster_health_scale) + assert_gt(deep.monster_damage_scale, shallow.monster_damage_scale) + assert_gt(deep.monster_density, shallow.monster_density) + assert_gte(deep.monster_cap, shallow.monster_cap) + assert_gte(deep.door_count_max, shallow.door_count_max) + +func test_same_floor_scales_the_same() -> void: + var a := FloorRules.scaled(Biomes.crypt(), 4) + var b := FloorRules.scaled(Biomes.crypt(), 4) + assert_eq(a.monster_health_scale, b.monster_health_scale) + assert_eq(a.monster_density, b.monster_density) + +func test_full_descent_replays_identically() -> void: + var first := _descent(777) + var second := _descent(777) + assert_eq(first.size(), second.size()) + for floor in first.size(): + assert_true(first[floor].solvable) + assert_eq( + first[floor].map._cells, second[floor].map._cells, + "floor %d differs" % (floor + 1) + ) + +func _descent(p_run_seed: int) -> Array[DungeonResult]: + var results: Array[DungeonResult] = [] + var generator := DungeonGenerator.new() + for floor in range(1, FloorRules.FLOOR_COUNT + 1): + var floor_seed := FloorRules.seed_for(p_run_seed, floor) + var biome := Biomes.random(SeededRng.new(floor_seed ^ 0x5EED)) + results.append(generator.generate(FloorRules.scaled(biome, floor), floor_seed)) + return results diff --git a/tests/unit/test_floor_rules.gd.uid b/tests/unit/test_floor_rules.gd.uid new file mode 100644 index 0000000..3445f33 --- /dev/null +++ b/tests/unit/test_floor_rules.gd.uid @@ -0,0 +1 @@ +uid://bbd230c2lkfqq diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..db11a30 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -2,8 +2,9 @@ extends SceneTree ## Headless smoke test for CI. ## ## Loads the main scene, starts a fixed-seed run, lets the engine run a -## few frames, then verifies the world is live and solvable. Exits with -## code 0 on success and code 1 on failure. +## few frames, then verifies the world is live and solvable. It also +## descends one floor and re-verifies the new map. Exits with code 0 on +## success and code 1 on failure. var _main: Main = null var _failed := false @@ -31,12 +32,19 @@ func _run() -> void: for i in 90: await physics_frame - if _failed: - quit(1) - return - _verify() + _verify_current_floor(1) -func _verify() -> void: + if not _failed: + print("[smoke] descending to floor 2") + _main.advance_floor() + for i in 90: + await physics_frame + _verify_current_floor(2) + _verify_descended_world() + + _finish() + +func _verify_current_floor(p_floor: int) -> void: if _main.run == null: _fail("no run was generated") elif _main.player == null: @@ -47,8 +55,24 @@ func _verify() -> void: _fail("exit is not reachable from the start") elif not _main.run.solvable: _fail("dungeon is not solvable") + elif RunState.floor != p_floor: + _fail("expected floor %d, got %d" % [p_floor, RunState.floor]) _verify_input_bindings() +## Checks that floor 2 replays identically from the same run seed. +func _verify_descended_world() -> void: + var floor_seed := FloorRules.seed_for(_main.run_seed, 2) + var replay := DungeonGenerator.new().generate( + FloorRules.scaled(Biomes.by_id(_main.run.config.id), 2), + floor_seed + ) + if replay.map._cells != _main.run.map._cells: + _fail("floor 2 does not replay from the run seed") + elif not replay.solvable: + _fail("floor 2 replay is not solvable") + elif _main.run.config.monster_health_scale < 1.0: + _fail("floor 2 does not scale monster health") + func _verify_input_bindings() -> void: for action in Controls.ACTIONS: var has_joypad := false @@ -61,11 +85,12 @@ func _verify_input_bindings() -> void: if Controls.hint_for(false).is_empty() or Controls.hint_for(true).is_empty(): _fail("control hints are empty") +func _finish() -> void: if _failed: print("[smoke] FAILED") quit(1) else: - print("Smoke test passed: seed 12345 spawned a solvable dungeon.") + print("Smoke test passed: seed 12345 spawned solvable floors 1 and 2.") quit(0) func _fail(p_message: String) -> void: