From 4543e4b8141a04b1a4e11919250b6e92bd0edd9e Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:18:04 -0700 Subject: [PATCH] feat: extend dungeonwright --- .github/workflows/ci.yml | 8 +++ CHANGELOG.md | 16 +++++ CONTRIBUTING.md | 37 ++++++++++ README.md | 88 +++++++++++++++++------ SECURITY.md | 10 +++ docs/architecture.md | 27 ++++++- scripts/actors/player.gd | 13 ++++ scripts/core/run_profile.gd | 49 +++++++++++++ scripts/core/run_profile.gd.uid | 1 + scripts/core/run_state.gd | 1 + scripts/dungeon/dungeon_config.gd | 24 +++++++ scripts/dungeon/dungeon_map.gd | 1 + scripts/main.gd | 69 +++++++++++++----- scripts/ui/hud.gd | 6 +- scripts/ui/result_overlay.gd | 13 +++- scripts/world/dungeon_view.gd | 8 ++- scripts/world/tile_art.gd | 13 ++++ tests/integration/test_multi_floor.gd | 77 ++++++++++++++++++++ tests/integration/test_multi_floor.gd.uid | 1 + tests/unit/test_run_profile.gd | 75 +++++++++++++++++++ tests/unit/test_run_profile.gd.uid | 1 + tools/smoke.gd | 60 ++++++++++++---- 22 files changed, 538 insertions(+), 60 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 scripts/core/run_profile.gd create mode 100644 scripts/core/run_profile.gd.uid create mode 100644 tests/integration/test_multi_floor.gd create mode 100644 tests/integration/test_multi_floor.gd.uid create mode 100644 tests/unit/test_run_profile.gd create mode 100644 tests/unit/test_run_profile.gd.uid diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5e2d..749c1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: - name: Import project resources run: godot --headless --import + - name: Check the working tree stays clean + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "The import generated files that are not committed:" + git status --porcelain + exit 1 + fi + - name: Run the GUT test suite run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..84210fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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 through three floors per run. +- A floor counter in the HUD and the run summary. +- Stairs-down tiles on every floor below the deepest. +- Per-floor difficulty scaling for monsters and hero stats. +- Deterministic floor seeds derived from the run seed. +- A run profile module that drives the descent rules. +- Unit and integration tests for floors and replay. + +Fixed + +- The hero no longer stacks sprites when a run restarts. + ## [0.2.0] - 2026-08-03 Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5956d40 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +Thank you for improving Dungeonwright. +This guide explains how to set up, change, and test the project. + +## Development setup + +1. Install Godot 4.6 from the official site. +2. Clone this repository. +3. Set `GODOT_BIN` to the Godot executable. +4. Install the pinned GUT addon with `tools/install_gut.sh` or `tools/install_gut.ps1`. + +## Make a change + +Keep the generation code pure data. +Add deterministic tests for every behaviour you change. +Run the test suite before you open a pull request. + +## Run the checks + +1. Run `tools/run_tests.sh` on Linux or macOS. +2. Run `tools/run_tests.ps1` on Windows. +3. Run `tools/smoke.gd` headless with Godot. + +The suite must pass with a clean import. + +## Code style + +Use tabs for indentation in GDScript files. +Follow the existing naming and comment style. +Write public documentation with ASD-STE100 rules. + +## Commit messages + +Write a short subject line that states the change. +Add a body that explains why the change is needed. +Reference an issue number when one exists. diff --git a/README.md b/README.md index 9c733e9..23929d1 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 a three-floor dungeon that you can explore and finish. ``` ####.D.############ @@ -17,34 +17,72 @@ Every run builds a new dungeon that you can explore and finish. Dungeonwright generates a connected dungeon on every run. You explore rooms and corridors. -You find keys, open locked doors, and reach the exit. +You find keys, open locked doors, and descend stairs. +A run spans three floors, and the same seed replays every floor. The same seed always builds the same dungeon. ## Features -- A new map for every run, driven by a seed. +- A new three-floor dungeon for every run, driven by a seed. - Rooms, corridors, locked doors, and keys. +- Stairs down and a floor counter in the HUD. - Three biomes with different generation rules. +- Deeper floors add more monsters and tougher hero stats. - Monsters with simple combat and balanced drops. - Procedural pixel art with no bundled image files. - A minimap, a health bar, and run summary overlays. - Deterministic generation for replayable runs. - Full gamepad support with analog movement. -## First release +## This release -This release ships a playable demo. -The generator guarantees the exit is always reachable. -You can walk, fight, collect loot, and finish a run. -You can replay any run from its seed. +This release adds multi-floor descent. +Reaching the exit on a floor brings you to the stairs. +The stairs carry you to the next, harder floor. +A floor counter shows your depth in the top-left panel. +The deepest floor holds the true exit, where the run ends in victory. -## This release +## Sample run + +The generator prints a dungeon as text in the terminal. +Here is floor two of a run, seed `0093CI`. +`#` is a wall, `.` is a floor, `D` is a locked door, and `>` is the stairs down. -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. +``` +############################################## +############################################## +##############......########################## +##############......########################## +##############......########################## +##############......########################## +##############......########################## +##############......###########.......######## +####........#######D###########.......######## +####........#######.###########.......######## +####........###.........#######...>...######## +####........###.........#######.......######## +####........###.........#######.......######## +####........###.........##########.########### +########.######....S.......D..####.########### +########.######.........#####.####.........### +########.######.........##.......#.........### +########.######.........##.......#.........### +#####.........#.........##.......#.........### +#####.........#####.######........D........### +#####.........#####.######.......##........### +#####.........#........###.......##........### +#####.........#........###.......###.######### +#####.........#........###########.....####### +#########..............###########.....####### +###############........###########.....####### +###############........###########.....####### +###############........###########.....####### +############################################## +############################################## +``` + +Floor one starts at `S`, and every floor stays solvable. +Replaying the seed `0093CI` rebuilds the same three floors. ## Requirements @@ -106,8 +144,14 @@ 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. +A run profile drives the whole descent. +It derives a fresh seed for each floor from the run seed. +It scales the biome rules so deeper floors get harder. +Reaching the exit on a shallow floor replaces it with stairs. + ## Project layout +- `scripts/core` holds the run profile and shared run state. - `scripts/dungeon` holds the generator and map logic. - `scripts/combat` holds stats, monsters, and loot tables. - `scripts/input` holds the controls helper. @@ -120,26 +164,26 @@ The scene controller turns the map into a live game. ## Design guarantees -A seed always produces the same map. +A seed always produces the same three floors. Doors never block the exit permanently. Every key sits on the reachable side of its door. Monsters never cross a locked door. +Deeper floors always stay solvable. ## Evaluation evidence -The suite has 68 tests. -It covers generation, biomes, combat, drops, pathfinding, and input. -All 68 tests pass in a headless run. -A smoke test loads the game and spawns a fixed-seed run. +The suite has 83 tests. +It covers generation, biomes, floors, combat, drops, pathfinding, and input. +All 83 tests pass in a headless run. +A smoke test loads the game and descends a fixed-seed run to the bottom. The smoke test also checks every action has a gamepad binding. ## Roadmap Done in this release: -- Full gamepad support. +- Multi-floor descent and a depth counter. Next up: -- Multi-floor descent and a depth counter. - Ranged monsters and projectiles. - Sound and music. - More biomes and items. @@ -147,7 +191,7 @@ Next up: ## Limitations The demo has three biomes. -Each run is a single floor. +Each run is three floors deep. All monsters use melee attacks. The game has no audio yet. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..69e3ae2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security + +Report security issues privately. +Do not open a public issue for a vulnerability. +Use the GitHub security tab to contact the maintainers. +Include a minimal reproduction when you can. + +This project has no secrets or credentials. +The seed field accepts user input, but it only changes map generation. +The seed parser rejects invalid characters and length. diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..9b9e605 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,23 @@ The class wraps the mulberry32 algorithm. The output depends only on the seed, never on the platform. Seeds display as six-character base-36 strings. +## Run profile and floors + +A run spans three floors. +The `RunProfile` class owns this structure. +It derives a fresh seed for each floor from the run seed. +It scales a copy of the biome so deeper floors get harder. + +Each floor uses the scaled biome config. +The generator stays floor-agnostic and always carves the base rules. +The scene controller converts the exit tile into stairs on shallow floors. +The deepest floor keeps the exit tile, so the run ends in victory. + +The scaling is pure math. +It raises monster density, monster count, hero health, and hero damage. +Every scaled config stays inside the biome validation rules. +Replaying a run seed rebuilds the same floor sequence. + ## Combat model The player and each monster carry a `CombatStats` block. @@ -91,6 +108,11 @@ The hero, monsters, and pickups are plain nodes. The hero moves tile to tile with smooth interpolation. Monsters follow short flood-fill paths. +Reaching the exit on a shallow floor starts the next floor. +The controller keeps the loot and rebuilds the hero stats. +It clears the world and reuses the same run seed. +The HUD floor counter and the run summary track the descent. + 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. @@ -111,9 +133,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. -Integration tests run many seeds across all biomes. +Unit tests cover the RNG, generator, biomes, floors, combat, and drops. +Integration tests run many seeds across all biomes and floors. Every generated dungeon must be solvable. Run the suite with `tools/run_tests`. CI runs the same commands on every push. +A smoke test descends a fixed-seed run through every floor. diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 5fa8967..c94d0bc 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -46,6 +46,7 @@ func setup( p_view: DungeonView, p_occupancy: Dictionary ) -> void: + _clear_visuals() stats = p_stats grid_pos = p_start view = p_view @@ -58,6 +59,18 @@ func setup( _add_light() emit_hud() +## Removes the visuals from a previous setup so a floor change can +## rebuild the hero without stacking sprites. +func _clear_visuals() -> void: + for child in get_children(): + child.queue_free() + +## Resets the loot a hero carries into a brand-new run. +func reset_progress() -> void: + coins = 0 + shards = 0 + keys_held = 0 + func _physics_process(p_delta: float) -> void: if view == null or stats == null: return diff --git a/scripts/core/run_profile.gd b/scripts/core/run_profile.gd new file mode 100644 index 0000000..fa2bdb2 --- /dev/null +++ b/scripts/core/run_profile.gd @@ -0,0 +1,49 @@ +class_name RunProfile +extends RefCounted +## Defines the shape of a full run. +## +## A run spans several floors. Each floor is a fresh dungeon built from +## a seed derived from the run seed. The profile scales the biome rules +## so deeper floors carry more monsters and tougher hero stats. All the +## math is deterministic, so a run seed replays every floor exactly. + +const TOTAL_FLOORS := 3 + +const FLOOR_SEED_TWEAK := 0x9E3779B1 + +## Extra monster density per floor beyond the first. +const MONSTER_DENSITY_STEP := 0.12 +## Extra monster slots per floor beyond the first. +const MONSTER_CAP_STEP := 3 +## Extra hero max health per floor beyond the first. +const HEALTH_STEP := 20 +## Extra hero damage per floor beyond the first. +const DAMAGE_STEP := 2 +## Extra doors per floor beyond the first. +const DOOR_STEP := 1 +## Door count never rises above this bound. +const MAX_DOOR_COUNT := 5 + +## The number of floors in one run. +static func total_floors() -> int: + return TOTAL_FLOORS + +## True when the given floor is the deepest floor of the run. +static func is_final_floor(p_floor: int) -> bool: + return p_floor >= TOTAL_FLOORS + +## Returns the deterministic seed for one floor of a run. +static func floor_seed(p_run_seed: int, p_floor: int) -> int: + return (p_run_seed ^ (p_floor * FLOOR_SEED_TWEAK)) & SeededRng.SEED_MASK + +## Returns a copy of the biome scaled for the given floor. +static func config_for(p_biome: DungeonConfig, p_floor: int) -> DungeonConfig: + var steps := maxi(p_floor, 1) - 1 + var config := p_biome.clone() + config.monster_density = minf(0.9, config.monster_density + MONSTER_DENSITY_STEP * steps) + config.monster_cap += MONSTER_CAP_STEP * steps + config.starting_health += HEALTH_STEP * steps + config.player_damage += DAMAGE_STEP * steps + config.door_count_min = mini(config.door_count_min + DOOR_STEP * steps, MAX_DOOR_COUNT) + config.door_count_max = mini(config.door_count_max + DOOR_STEP * steps, MAX_DOOR_COUNT) + return config diff --git a/scripts/core/run_profile.gd.uid b/scripts/core/run_profile.gd.uid new file mode 100644 index 0000000..e308602 --- /dev/null +++ b/scripts/core/run_profile.gd.uid @@ -0,0 +1 @@ +uid://x2hwv0l3fmi0 diff --git a/scripts/core/run_state.gd b/scripts/core/run_state.gd index 2f8f9ef..6ae83d9 100644 --- a/scripts/core/run_state.gd +++ b/scripts/core/run_state.gd @@ -13,6 +13,7 @@ static var biome_id: StringName = &"" static var status: RunStatus = RunStatus.IDLE static var started_at: float = 0.0 static var finished_at: float = 0.0 +static var floor: int = 1 ## Elapsed play time in seconds for the current run. static func elapsed() -> float: diff --git a/scripts/dungeon/dungeon_config.gd b/scripts/dungeon/dungeon_config.gd index 3e5b9f7..7c03d57 100644 --- a/scripts/dungeon/dungeon_config.gd +++ b/scripts/dungeon/dungeon_config.gd @@ -56,3 +56,27 @@ func validate() -> Array[String]: ## True when the config can generate a valid dungeon. func is_valid() -> bool: return validate().is_empty() + +## Returns an independent copy of this config. +func clone() -> DungeonConfig: + var copy := DungeonConfig.new() + copy.id = id + copy.display_name = display_name + copy.description = description + copy.width = width + copy.height = height + copy.room_count_min = room_count_min + copy.room_count_max = room_count_max + copy.room_min = room_min + copy.room_max = room_max + copy.corridor_style = corridor_style + copy.loop_chance = loop_chance + copy.door_count_min = door_count_min + copy.door_count_max = door_count_max + copy.monster_density = monster_density + copy.monster_cap = monster_cap + copy.monster_table = monster_table.duplicate(true) + copy.starting_health = starting_health + copy.player_damage = player_damage + copy.palette = palette.duplicate(true) + return copy diff --git a/scripts/dungeon/dungeon_map.gd b/scripts/dungeon/dungeon_map.gd index e89efab..78fe171 100644 --- a/scripts/dungeon/dungeon_map.gd +++ b/scripts/dungeon/dungeon_map.gd @@ -12,6 +12,7 @@ enum Tile { DOOR_OPEN, START, EXIT, + STAIRS_DOWN, } var width: int = 0 diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..a634346 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -8,6 +8,7 @@ extends Node2D ## turns its output into a live scene. signal run_started(seed_value: int) +signal floor_started(floor_number: int) @onready var dungeon_view: DungeonView = $World/DungeonView @onready var monsters_root: Node2D = $World/Monsters @@ -27,6 +28,7 @@ var run: DungeonResult = null var biome: DungeonConfig = null var occupancy: Dictionary = {} var run_seed: int = 0 +var floor_number: int = 1 var monster_index := 0 var _ended := false var _beacon_phase := 0.0 @@ -66,7 +68,10 @@ func _physics_process(p_delta: float) -> void: _collect_pickups() _pulse_beacon(p_delta) if not _ended and player.grid_pos == run.exit_pos: - _on_victory() + if RunProfile.is_final_floor(floor_number): + _on_victory() + else: + advance_floor() func _on_start_requested(p_seed_text: String) -> void: var text := p_seed_text.strip_edges() @@ -82,33 +87,54 @@ func _on_start_requested(p_seed_text: String) -> void: func _on_new_run_requested(p_same_seed: bool) -> void: if p_same_seed: - _start_run(run_seed) + _start_run(run_seed, 1, false) else: - _start_run(randi()) + _start_run(randi(), 1, false) ## Starts a run with a chosen seed. Public entry point for tooling. func start_run(p_seed_value: int) -> void: - _start_run(p_seed_value) + _start_run(p_seed_value, 1, false) -func _start_run(p_seed_value: int) -> void: +## Moves the run down one floor. Public entry point for tooling. +## On the deepest floor this is a no-op. +func advance_floor() -> void: + if run == null or _ended: + return + if RunProfile.is_final_floor(floor_number): + return + _start_run(run_seed, floor_number + 1, true) + +func _start_run( + p_seed_value: int, + p_floor: int = 1, + p_carry_progress: bool = false +) -> void: run_seed = p_seed_value + floor_number = p_floor biome = Biomes.random(SeededRng.new(p_seed_value ^ 0x5EED)) - run = DungeonGenerator.new().generate(biome, p_seed_value) + var config := RunProfile.config_for(biome, floor_number) + run = DungeonGenerator.new().generate(config, RunProfile.floor_seed(p_seed_value, floor_number)) _clear_world() occupancy.clear() _ended = false - dungeon_view.configure(run.map, biome) + if not p_carry_progress: + player.reset_progress() + + dungeon_view.configure(run.map, config) var stats := CombatStats.make({ - "max_health": biome.starting_health, - "health": biome.starting_health, - "damage": biome.player_damage, + "max_health": config.starting_health, + "health": config.starting_health, + "damage": config.player_damage, }) player.setup(stats, run.start_pos, dungeon_view, occupancy) occupancy[run.start_pos] = player player.can_enter = _hero_can_enter + if not RunProfile.is_final_floor(floor_number): + run.map.set_tile_cell(run.exit_pos, DungeonMap.Tile.STAIRS_DOWN) + monster_index = 0 for spawn in run.monster_spawns: _spawn_monster(spawn.position, spawn.monster) @@ -121,21 +147,30 @@ func _start_run(p_seed_value: int) -> void: camera.enabled = true camera.position = player.position _apply_camera_limits() - ambient.color = Color(biome.palette.get(&"accent", Color.WHITE)).darkened(0.55) + ambient.color = Color(config.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(p_seed_value), + run.depth, + floor_number + ) 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 + RunState.floor = floor_number + if floor_number == 1: + 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) + floor_started.emit(floor_number) + if floor_number == 1: + 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) @@ -208,7 +243,8 @@ func _on_player_died() -> void: SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + floor_number ) get_tree().paused = true @@ -223,7 +259,8 @@ func _on_victory() -> void: SeededRng.encode_seed(run_seed), run.depth, player.coins, - RunState.elapsed() + RunState.elapsed(), + floor_number ) get_tree().paused = true diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..1232471 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) -> void: biome_label.text = "Biome: " + p_biome seed_label.text = "Seed: " + p_seed + floor_label.text = "Floor %d of %d" % [p_floor, RunProfile.total_floors()] 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..4b66460 100644 --- a/scripts/ui/result_overlay.gd +++ b/scripts/ui/result_overlay.gd @@ -17,14 +17,21 @@ 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\nFloor: %d\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ + p_seed, p_floors, p_depth, p_coins, minutes, seconds, ] _same_seed_button.visible = p_won _new_button.grab_focus() diff --git a/scripts/world/dungeon_view.gd b/scripts/world/dungeon_view.gd index d6c8259..421701d 100644 --- a/scripts/world/dungeon_view.gd +++ b/scripts/world/dungeon_view.gd @@ -14,7 +14,7 @@ var _tile_atlas: Dictionary = {} ## The atlas coordinates for each tile type. const ATLAS_ORDER: Array = [ [0, 0], [1, 0], [2, 0], [3, 0], - [0, 1], [1, 1], [2, 1], + [0, 1], [1, 1], [2, 1], [3, 1], ] func _ready() -> void: @@ -71,7 +71,7 @@ func build_minimap_image() -> Image: color = door_color DungeonMap.Tile.START: color = start_color - DungeonMap.Tile.EXIT: + DungeonMap.Tile.EXIT, DungeonMap.Tile.STAIRS_DOWN: color = exit_color for dx in 2: for dy in 2: @@ -86,7 +86,7 @@ func _build_tileset(p_palette: Dictionary) -> void: var keys: Array = [ TileArt.TILE_WALL, TileArt.TILE_FLOOR_A, TileArt.TILE_FLOOR_B, TileArt.TILE_DOOR_LOCKED, TileArt.TILE_DOOR_OPEN, - TileArt.TILE_START, TileArt.TILE_EXIT, + TileArt.TILE_START, TileArt.TILE_EXIT, TileArt.TILE_STAIRS, ] for i in keys.size(): var tile_texture: Texture2D = textures[keys[i]] @@ -123,6 +123,8 @@ func _atlas_for(p_tile: int) -> Vector2i: return Vector2i(1, 1) DungeonMap.Tile.EXIT: return Vector2i(2, 1) + DungeonMap.Tile.STAIRS_DOWN: + return Vector2i(3, 1) _: return Vector2i(1, 0) diff --git a/scripts/world/tile_art.gd b/scripts/world/tile_art.gd index 2b0115a..fae29ff 100644 --- a/scripts/world/tile_art.gd +++ b/scripts/world/tile_art.gd @@ -16,6 +16,7 @@ const TILE_DOOR_LOCKED := &"door_locked" const TILE_DOOR_OPEN := &"door_open" const TILE_START := &"start" const TILE_EXIT := &"exit" +const TILE_STAIRS := &"stairs_down" const ENTITY_KEYS := [ &"player", &"key", &"potion", &"coin", @@ -45,6 +46,7 @@ static func build_tile_textures(p_palette: Dictionary) -> Dictionary: TILE_DOOR_OPEN: _from_pattern(_DOOR_OPEN, p_palette, palette_roles), TILE_START: _from_pattern(_START, p_palette, palette_roles), TILE_EXIT: _from_pattern(_EXIT, p_palette, palette_roles), + TILE_STAIRS: _from_pattern(_STAIRS, p_palette, palette_roles), } _tile_cache[key] = textures return textures @@ -207,6 +209,17 @@ const _EXIT := [ "........", ] +const _STAIRS := [ + "........", + "..g..g..", + "..gg.gg.", + "..gg.gg.", + "..ggggg.", + "..ggggg.", + "..ggggg.", + "........", +] + const _PLAYER := [ "..oo....", "..o@o...", diff --git a/tests/integration/test_multi_floor.gd b/tests/integration/test_multi_floor.gd new file mode 100644 index 0000000..89f8744 --- /dev/null +++ b/tests/integration/test_multi_floor.gd @@ -0,0 +1,77 @@ +extends GutTest +## A whole run: every floor generates, scales, and stays solvable. + +func test_every_floor_of_every_biome_is_solvable() -> void: + for biome in Biomes.all(): + for run_seed in [11, 22, 33]: + for floor in range(1, RunProfile.total_floors() + 1): + var config := RunProfile.config_for(biome, floor) + var result := DungeonGenerator.new().generate( + config, RunProfile.floor_seed(run_seed, floor) + ) + assert_true(result.solvable, "%s seed %d floor %d" % [ + biome.id, run_seed, floor, + ]) + assert_true( + Pathfinding.reaches(result.map, result.start_pos, result.exit_pos, true), + "exit blocked on %s seed %d floor %d" % [biome.id, run_seed, floor], + ) + +func test_floors_of_one_run_differ() -> void: + var biome := Biomes.crypt() + var floor_one := DungeonGenerator.new().generate( + RunProfile.config_for(biome, 1), RunProfile.floor_seed(4242, 1) + ) + var floor_two := DungeonGenerator.new().generate( + RunProfile.config_for(biome, 2), RunProfile.floor_seed(4242, 2) + ) + var differences := 0 + for y in floor_one.map.height: + for x in floor_one.map.width: + if floor_one.map.get_tile(x, y) != floor_two.map.get_tile(x, y): + differences += 1 + assert_gt(differences, 100) + +func test_every_floor_of_a_run_replays_identically() -> void: + var biome := Biomes.drowned_forest() + var run_seed := 909 + for floor in range(1, RunProfile.total_floors() + 1): + var first := DungeonGenerator.new().generate( + RunProfile.config_for(biome, floor), RunProfile.floor_seed(run_seed, floor) + ) + var second := DungeonGenerator.new().generate( + RunProfile.config_for(biome, floor), RunProfile.floor_seed(run_seed, floor) + ) + assert_eq(first.map._cells, second.map._cells, "floor %d" % floor) + assert_eq(first.exit_pos, second.exit_pos, "floor %d" % floor) + assert_eq(first.start_pos, second.start_pos, "floor %d" % floor) + +func test_hero_stats_ramp_with_floor() -> void: + var biome := Biomes.ember_stronghold() + var health_before := 0 + var damage_before := 0 + for floor in range(1, RunProfile.total_floors() + 1): + var config := RunProfile.config_for(biome, floor) + assert_gt(config.starting_health, health_before, "floor %d" % floor) + assert_gt(config.player_damage, damage_before, "floor %d" % floor) + health_before = config.starting_health + damage_before = config.player_damage + +func test_monster_pressure_ramps_with_floor() -> void: + var biome := Biomes.crypt() + var cap_before := 0 + var density_before := 0.0 + for floor in range(1, RunProfile.total_floors() + 1): + var config := RunProfile.config_for(biome, floor) + assert_gt(config.monster_cap, cap_before, "floor %d" % floor) + assert_gt(config.monster_density, density_before, "floor %d" % floor) + cap_before = config.monster_cap + density_before = config.monster_density + +func test_deeper_floors_stay_within_balance_bounds() -> void: + for biome in Biomes.all(): + for floor in range(1, RunProfile.total_floors() + 1): + var config := RunProfile.config_for(biome, floor) + assert_lte(config.monster_density, 1.0, biome.id) + assert_lte(config.door_count_min, config.door_count_max, biome.id) + assert_lte(config.door_count_max, RunProfile.MAX_DOOR_COUNT, biome.id) diff --git a/tests/integration/test_multi_floor.gd.uid b/tests/integration/test_multi_floor.gd.uid new file mode 100644 index 0000000..da0f3f4 --- /dev/null +++ b/tests/integration/test_multi_floor.gd.uid @@ -0,0 +1 @@ +uid://dsfio07d5xcew diff --git a/tests/unit/test_run_profile.gd b/tests/unit/test_run_profile.gd new file mode 100644 index 0000000..c26c7a0 --- /dev/null +++ b/tests/unit/test_run_profile.gd @@ -0,0 +1,75 @@ +extends GutTest +## The run profile scales biomes across floors and stays deterministic. + +func test_total_floors_is_meaningful() -> void: + assert_gte(RunProfile.total_floors(), 2) + assert_false(RunProfile.is_final_floor(1)) + assert_true(RunProfile.is_final_floor(RunProfile.total_floors())) + +func test_floor_seed_is_deterministic() -> void: + assert_eq( + RunProfile.floor_seed(12345, 2), + RunProfile.floor_seed(12345, 2) + ) + assert_eq( + RunProfile.floor_seed(777, 3), + RunProfile.floor_seed(777, 3) + ) + +func test_floor_seed_differs_by_floor_and_run() -> void: + assert_ne(RunProfile.floor_seed(12345, 1), RunProfile.floor_seed(12345, 2)) + assert_ne(RunProfile.floor_seed(12345, 2), RunProfile.floor_seed(12345, 3)) + assert_ne(RunProfile.floor_seed(12345, 2), RunProfile.floor_seed(54321, 2)) + +func test_floor_seed_stays_in_seed_mask() -> void: + for run_seed in [0, 1, 999999, 16777215]: + for floor in range(1, RunProfile.total_floors() + 1): + assert_eq( + RunProfile.floor_seed(run_seed, floor) & SeededRng.SEED_MASK, + RunProfile.floor_seed(run_seed, floor) + ) + +func test_config_for_scales_with_floor() -> void: + var base := Biomes.crypt() + var floor_one := RunProfile.config_for(base, 1) + var floor_two := RunProfile.config_for(base, 2) + assert_gt(floor_two.monster_cap, floor_one.monster_cap) + assert_gt(floor_two.starting_health, floor_one.starting_health) + assert_gt(floor_two.player_damage, floor_one.player_damage) + assert_gt(floor_two.monster_density, floor_one.monster_density) + assert_true(floor_two.is_valid()) + +func test_config_for_never_invalidates() -> void: + for biome in Biomes.all(): + for floor in range(1, RunProfile.total_floors() + 1): + var config := RunProfile.config_for(biome, floor) + assert_true(config.is_valid(), "%s floor %d: %s" % [ + biome.id, floor, str(config.validate()), + ]) + +func test_config_for_keeps_identity_and_palette() -> void: + var base := Biomes.drowned_forest() + var scaled := RunProfile.config_for(base, 3) + assert_eq(scaled.id, base.id) + assert_eq(scaled.display_name, base.display_name) + assert_eq(scaled.palette, base.palette) + assert_eq(scaled.width, base.width) + assert_eq(scaled.height, base.height) + +func test_config_for_does_not_mutate_base() -> void: + var base := Biomes.ember_stronghold() + var health_before := base.starting_health + var damage_before := base.player_damage + var density_before := base.monster_density + RunProfile.config_for(base, 3) + assert_eq(base.starting_health, health_before) + assert_eq(base.player_damage, damage_before) + assert_eq(base.monster_density, density_before) + +func test_config_for_is_deterministic() -> void: + var first := RunProfile.config_for(Biomes.crypt(), 2) + var second := RunProfile.config_for(Biomes.crypt(), 2) + assert_eq(first.monster_density, second.monster_density) + assert_eq(first.starting_health, second.starting_health) + assert_eq(first.door_count_min, second.door_count_min) + assert_eq(first.door_count_max, second.door_count_max) diff --git a/tests/unit/test_run_profile.gd.uid b/tests/unit/test_run_profile.gd.uid new file mode 100644 index 0000000..2b2116c --- /dev/null +++ b/tests/unit/test_run_profile.gd.uid @@ -0,0 +1 @@ +uid://inspx2waovpf diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..b3dba5a 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -34,21 +34,55 @@ func _run() -> void: if _failed: quit(1) return - _verify() + _verify_floor(1) + if _failed: + quit(1) + return + + print("[smoke] descending to floor 2") + _main.advance_floor() + for i in 30: + await physics_frame + _verify_floor(2) + if _failed: + quit(1) + return + + print("[smoke] descending to floor 3") + _main.advance_floor() + for i in 30: + await physics_frame + _verify_floor(3) -func _verify() -> void: - if _main.run == null: - _fail("no run was generated") - elif _main.player == null: - _fail("player was not spawned") - elif _main.player.grid_pos != _main.run.start_pos: - _fail("player is not at the start tile") - elif not Pathfinding.reaches(_main.run.map, _main.run.start_pos, _main.run.exit_pos, true): - _fail("exit is not reachable from the start") - elif not _main.run.solvable: - _fail("dungeon is not solvable") _verify_input_bindings() +func _verify_floor(p_floor: int) -> void: + if _main.floor_number != p_floor: + _fail("expected floor %d, got %d" % [p_floor, _main.floor_number]) + if RunState.floor != p_floor: + _fail("RunState.floor is %d, expected %d" % [RunState.floor, p_floor]) + if _main.run == null: + _fail("no run was generated on floor %d" % p_floor) + return + if _main.player == null: + _fail("player was not spawned on floor %d" % p_floor) + return + if _main.player.grid_pos != _main.run.start_pos: + _fail("player is not at the start tile on floor %d" % p_floor) + return + if not _main.run.solvable: + _fail("floor %d is not solvable" % p_floor) + return + if not Pathfinding.reaches(_main.run.map, _main.run.start_pos, _main.run.exit_pos, true): + _fail("exit is not reachable on floor %d" % p_floor) + return + var expected_tile := DungeonMap.Tile.EXIT if RunProfile.is_final_floor(p_floor) \ + else DungeonMap.Tile.STAIRS_DOWN + if _main.run.map.get_tile_cell(_main.run.exit_pos) != expected_tile: + _fail("floor %d exit tile is wrong" % p_floor) + return + print("[smoke] floor %d verified" % p_floor) + func _verify_input_bindings() -> void: for action in Controls.ACTIONS: var has_joypad := false @@ -65,7 +99,7 @@ func _verify_input_bindings() -> void: print("[smoke] FAILED") quit(1) else: - print("Smoke test passed: seed 12345 spawned a solvable dungeon.") + print("Smoke test passed: seed 12345 descended 3 solvable floors.") quit(0) func _fail(p_message: String) -> void: