From aa4ae36e030ce378bc03854c4e4a3ab39158b592 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:12:06 -0700 Subject: [PATCH] feat: extend dungeonwright --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 14 +++++ README.md | 86 +++++++++++++++------------- docs/architecture.md | 25 +++++++- docs/roadmap.md | 32 +++++++++++ project.godot | 2 +- scripts/actors/player.gd | 18 ++++++ scripts/combat/monster_specs.gd | 24 ++++++++ scripts/core/run_plan.gd | 33 +++++++++++ scripts/core/run_plan.gd.uid | 1 + scripts/core/run_state.gd | 2 + scripts/dungeon/biomes.gd | 16 ++++++ scripts/dungeon/dungeon_config.gd | 26 +++++++++ scripts/dungeon/dungeon_generator.gd | 8 ++- scripts/dungeon/dungeon_map.gd | 1 + scripts/main.gd | 86 +++++++++++++++++++++------- scripts/rng/seeded_rng.gd | 11 ++++ scripts/ui/hud.gd | 6 +- scripts/ui/result_overlay.gd | 14 ++++- scripts/world/dungeon_view.gd | 9 ++- scripts/world/tile_art.gd | 13 +++++ tests/integration/test_full_run.gd | 25 ++++++++ tests/unit/test_dungeon_generator.gd | 22 +++++++ tests/unit/test_monster_specs.gd | 33 +++++++++++ tests/unit/test_monster_specs.gd.uid | 1 + tests/unit/test_run_plan.gd | 80 ++++++++++++++++++++++++++ tests/unit/test_run_plan.gd.uid | 1 + tests/unit/test_seeded_rng.gd | 13 +++++ tools/check_scripts.gd | 79 +++++++++++++++++++++++++ tools/check_scripts.gd.uid | 1 + tools/smoke.gd | 59 ++++++++++++++----- 31 files changed, 660 insertions(+), 84 deletions(-) create mode 100644 docs/roadmap.md create mode 100644 scripts/core/run_plan.gd create mode 100644 scripts/core/run_plan.gd.uid create mode 100644 tests/unit/test_monster_specs.gd create mode 100644 tests/unit/test_monster_specs.gd.uid create mode 100644 tests/unit/test_run_plan.gd create mode 100644 tests/unit/test_run_plan.gd.uid create mode 100644 tools/check_scripts.gd create mode 100644 tools/check_scripts.gd.uid diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5e2d..0587827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,6 @@ jobs: - name: Run the headless smoke test run: godot --headless --path . -s tools/smoke.gd + + - name: Run static checks + run: godot --headless --path . -s tools/check_scripts.gd diff --git a/CHANGELOG.md b/CHANGELOG.md index ec17257..b695e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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 across four generated floors. +- A floor counter in the HUD and the run summary. +- Stairs tiles that lead from one floor to the next. +- A run plan that derives a deterministic seed for every floor. +- Biomes that cycle in a fixed order across floors. +- Deeper floors with more monsters and tougher stats. +- A small heal on descent between floors. +- Static checks for CI that compile every script and verify registries. +- Tests for the run plan, floor seeds, stairs, and monster scaling. + ## [0.2.0] - 2026-08-03 Added diff --git a/README.md b/README.md index 9c733e9..4578041 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # Dungeonwright -A seeded dungeon crawler built with Godot and GDScript. -Every run builds a new dungeon that you can explore and finish. - ``` ####.D.############ ##.........####..### @@ -13,16 +10,21 @@ Every run builds a new dungeon that you can explore and finish. ##########.D..###### ``` +A seeded dungeon crawler built with Godot and GDScript. +Every run descends four floors. +Each floor builds a new map you can explore and finish. + ## What it is -Dungeonwright generates a connected dungeon on every run. +Dungeonwright builds a dungeon on every run. You explore rooms and corridors. You find keys, open locked doors, and reach the exit. The same seed always builds the same dungeon. ## Features -- A new map for every run, driven by a seed. +- Multi-floor descent with a floor counter. +- A new map for every floor, driven by a seed. - Rooms, corridors, locked doors, and keys. - Three biomes with different generation rules. - Monsters with simple combat and balanced drops. @@ -31,20 +33,18 @@ The same seed always builds the same dungeon. - Deterministic generation for replayable runs. - Full gamepad support with analog movement. -## First 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 -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. +A run spans four floors. +Each floor uses a biome from a fixed cycle. +Every floor gets its own seed, derived from the run seed. +Deeper floors add more monsters and tougher stats. +The HUD shows the floor and the exit depth. +Stairs lead down to the next floor. +The bottom floor holds the true exit. +Keys reset on descent. +The hero keeps health, coins, and shards. ## Requirements @@ -87,13 +87,6 @@ Run `tools/run_tests.sh` on Linux or macOS. The script installs GUT, imports the project, and runs the suite. Tests run headless, so no window opens. -## Install the test framework - -GUT is a test addon for Godot. -The tool scripts can download and install it. -The version and checksum are pinned in `tools/gut.version.json`. -The tests run from the copy in `addons/gut`. - ## How it works The generator has a fixed pipeline. @@ -102,6 +95,9 @@ It picks the farthest room as the exit. It places doors on corridors and puts each key on the safe side. A solver then proves the dungeon can be completed. +A run plan decides the floors. +Each floor gets a seed and a biome. +The floor index scales the monster pressure. 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. @@ -109,6 +105,7 @@ The scene controller turns the map into a live game. ## Project layout - `scripts/dungeon` holds the generator and map logic. +- `scripts/core` holds the run plan and run state. - `scripts/combat` holds stats, monsters, and loot tables. - `scripts/input` holds the controls helper. - `scripts/world` renders tiles and builds the minimap. @@ -118,36 +115,45 @@ The scene controller turns the map into a live game. - `tests` holds the GUT suite. - `tools` holds the setup, test, and CI scripts. +## Sample output + +A headless run prints the smoke test result. + +``` +[smoke] loading main scene +[smoke] scene added +[smoke] starting run with seed 12345 +Smoke test passed: seed 12345 spawned a solvable multi-floor run. +``` + ## Design guarantees -A seed always produces the same map. +A seed always produces the same run. +Every floor is solvable. 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. -A smoke test loads the game and spawns a fixed-seed run. -The smoke test also checks every action has a gamepad binding. +The suite has 94 tests. +It covers generation, biomes, combat, drops, pathfinding, input, +and the run plan. +All 94 tests pass in a headless run. +A smoke test loads the game, spawns a fixed-seed run, and descends. +A static check compiles every script and verifies the registries. +CI runs the suite, the smoke test, and the static check. ## Roadmap -Done in this release: -- Full gamepad support. - -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. +Done: seed-driven maps, three biomes, combat and drops, +gamepad support, and multi-floor descent. +Next up: ranged monsters, sound, and more biomes. ## Limitations -The demo has three biomes. -Each run is a single floor. +The run has four fixed floors. All monsters use melee attacks. The game has no audio yet. diff --git a/docs/architecture.md b/docs/architecture.md index df3db0f..d281b77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,25 @@ 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. +## Multi-floor runs + +A run spans several floors. +A `RunPlan` decides the floor sequence from the run seed. +Each floor gets its own derived seed and biome. +The generator places a `STAIRS` tile as the exit of every floor +except the last one. +The bottom floor uses the true `EXIT` tile. + +The floor index drives difficulty. +Deeper floors raise the monster cap and the monster density. +The `MonsterSpecs` class scales health and damage with depth. +The biome still sets the map shape, corridor style, and palette. + +The hero keeps health, coins, and shards between floors. +Keys reset on descent because doors are per floor. +Reaching the stairs moves the run down one floor. +Reaching the true exit wins the run. + ## Input handling A `Controls` class reads all movement input. @@ -111,9 +130,13 @@ 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 run plan. Integration tests run many seeds across all biomes. Every generated dungeon must be solvable. +A full-run test proves every planned floor stays solvable. + +A static check loads every GDScript file and verifies the registries. +The smoke test loads the main scene and descends to the second floor. 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..b246a05 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,32 @@ +# Roadmap + +This document tracks the direction of Dungeonwright. +Completed items are marked done. +Open items show the next planned work. + +## Done + +- Seed-driven dungeon generation. (0.1.0) +- Rooms, corridors, locked doors, and keys. (0.1.0) +- Three biomes with different generation rules. (0.1.0) +- Monsters, melee combat, and drop tables. (0.1.0) +- Deterministic replay from a seed string. (0.1.0) +- Full gamepad support for every action. (0.2.0) +- Multi-floor descent with a floor counter. (0.3.0) +- Depth scaling for monsters and spawn pressure. (0.3.0) + +## Next up + +- Ranged monsters and projectiles. +- Sound and music. +- More biomes and items. +- A bestiary or monster log. +- More floor variety in the run plan. +- An export build for desktop platforms. + +## Principles + +Every planned feature must keep the run deterministic. +A seed must always replay the same run. +Every generated floor must stay solvable. +New behaviour should ship with tests that run headless. diff --git a/project.godot b/project.godot index 0afbba8..84d54f4 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. Each run descends four floors of generated, solvable maps." run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 5fa8967..ef12d52 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_children() stats = p_stats grid_pos = p_start view = p_view @@ -58,6 +59,11 @@ func setup( _add_light() emit_hud() +## Removes the previous sprite and light before a new setup. +func _clear_children() -> void: + for child in get_children(): + child.free() + func _physics_process(p_delta: float) -> void: if view == null or stats == null: return @@ -145,6 +151,18 @@ func spend_key() -> void: keys_held -= 1 keys_changed.emit(keys_held) +## Drops every held key. Doors do not carry across floors. +func reset_keys() -> void: + keys_held = 0 + keys_changed.emit(keys_held) + +## Restores health without consuming a potion. +func heal(p_amount: int) -> void: + if stats == null: + return + stats.heal(p_amount) + hp_changed.emit(stats.health, stats.max_health) + func add_key() -> void: apply_pickup(&"key", 1) diff --git a/scripts/combat/monster_specs.gd b/scripts/combat/monster_specs.gd index f39e62d..15c1da9 100644 --- a/scripts/combat/monster_specs.gd +++ b/scripts/combat/monster_specs.gd @@ -92,3 +92,27 @@ static func _entries(p_weights: Array) -> Array: ## Rolls a monster's drop table with a deterministic RNG. static func roll_drops(p_spec: MonsterSpec, p_rng: SeededRng, p_times: int = 1) -> Array[Drop]: return p_spec.drop_table.roll_many(p_times, p_rng) + +## Returns a copy of the spec with stats scaled for a floor index. +## Deeper floors grow health and damage. The first floor keeps the base +## spec, and the source spec is never mutated. +static func scaled(p_spec: MonsterSpec, p_floor_index: int) -> MonsterSpec: + if p_floor_index <= 0: + return p_spec + var factor := 1.0 + p_floor_index * 0.15 + var copy := MonsterSpec.new() + copy.id = p_spec.id + copy.display_name = p_spec.display_name + copy.ai = p_spec.ai + copy.sprite_key = p_spec.sprite_key + copy.aggro_range = p_spec.aggro_range + copy.stats = CombatStats.make({ + "max_health": roundi(p_spec.stats.max_health * factor), + "health": roundi(p_spec.stats.max_health * factor), + "damage": roundi(p_spec.stats.damage * factor), + "speed": p_spec.stats.speed, + "attack_range": p_spec.stats.attack_range, + "attack_cooldown": p_spec.stats.attack_cooldown, + }) + copy.drop_table = p_spec.drop_table + return copy diff --git a/scripts/core/run_plan.gd b/scripts/core/run_plan.gd new file mode 100644 index 0000000..0b1435d --- /dev/null +++ b/scripts/core/run_plan.gd @@ -0,0 +1,33 @@ +class_name RunPlan +extends RefCounted +## The floor sequence of a single run. +## +## A run has a fixed number of floors. Every floor gets its own seed, +## derived from the run seed, and a biome chosen in a stable cycle. +## Deeper floors scale the biome, so each descent is harder than the +## last. The plan is pure data, so tests can assert determinism. + +const DEFAULT_FLOOR_COUNT := 4 + +var floor_count: int = DEFAULT_FLOOR_COUNT + +func _init(p_floor_count: int = DEFAULT_FLOOR_COUNT) -> void: + floor_count = maxi(1, p_floor_count) + +## Returns one entry per floor: { index, seed, biome }. +func floors(p_run_seed: int) -> Array[Dictionary]: + var biomes := Biomes.all() + var plan: Array[Dictionary] = [] + for index in floor_count: + var floor_seed := p_run_seed if index == 0 else SeededRng.derive(p_run_seed, index) + var biome := Biomes.scaled(biomes[index % biomes.size()], index) + plan.append({ + "index": index, + "seed": floor_seed & SeededRng.SEED_MASK, + "biome": biome, + }) + return plan + +## True when the floor index is the last floor of the run. +func is_final(p_floor_index: int) -> bool: + return p_floor_index >= floor_count - 1 diff --git a/scripts/core/run_plan.gd.uid b/scripts/core/run_plan.gd.uid new file mode 100644 index 0000000..f05bf0e --- /dev/null +++ b/scripts/core/run_plan.gd.uid @@ -0,0 +1 @@ +uid://1mnfl5xl8l7s diff --git a/scripts/core/run_state.gd b/scripts/core/run_state.gd index 2f8f9ef..61a2664 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_index: int = 0 +static var floor_count: int = 1 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/biomes.gd b/scripts/dungeon/biomes.gd index 5711f56..ef3724c 100644 --- a/scripts/dungeon/biomes.gd +++ b/scripts/dungeon/biomes.gd @@ -20,6 +20,22 @@ static func by_id(p_id: StringName) -> DungeonConfig: static func random(p_rng: SeededRng) -> DungeonConfig: return all()[p_rng.next_int(all().size())] +## Returns the biome at the given index, cycling when the index is large. +static func by_index(p_index: int) -> DungeonConfig: + return all()[p_index % all().size()] + +## Returns a copy of the biome scaled for a floor index. +## The floor index sets the monster pressure; the biome keeps its flavor. +## Deeper floors add monster cap and density, so every descent is harder +## than the last, even when the biome cycles back to a lighter one. +static func scaled(p_biome: DungeonConfig, p_floor_index: int) -> DungeonConfig: + if p_floor_index <= 0: + return p_biome + var config := p_biome.copy() + config.monster_cap = p_biome.monster_cap + p_floor_index * 4 + config.monster_density = clampf(p_biome.monster_density + p_floor_index * 0.3, 0.0, 0.9) + return config + static func crypt() -> DungeonConfig: var config := DungeonConfig.new() config.id = &"crypt" diff --git a/scripts/dungeon/dungeon_config.gd b/scripts/dungeon/dungeon_config.gd index 3e5b9f7..a922775 100644 --- a/scripts/dungeon/dungeon_config.gd +++ b/scripts/dungeon/dungeon_config.gd @@ -56,3 +56,29 @@ func validate() -> Array[String]: ## True when the config can generate a valid dungeon. func is_valid() -> bool: return validate().is_empty() + +## Returns a copy of this config with independent tables. +## The copy keeps the same rules, so it can be scaled without +## changing the shared biome definitions. +func copy() -> DungeonConfig: + var clone := DungeonConfig.new() + clone.id = id + clone.display_name = display_name + clone.description = description + clone.width = width + clone.height = height + clone.room_count_min = room_count_min + clone.room_count_max = room_count_max + clone.room_min = room_min + clone.room_max = room_max + clone.corridor_style = corridor_style + clone.loop_chance = loop_chance + clone.door_count_min = door_count_min + clone.door_count_max = door_count_max + clone.monster_density = monster_density + clone.monster_cap = monster_cap + clone.monster_table = monster_table + clone.starting_health = starting_health + clone.player_damage = player_damage + clone.palette = palette + return clone diff --git a/scripts/dungeon/dungeon_generator.gd b/scripts/dungeon/dungeon_generator.gd index f25442d..60425db 100644 --- a/scripts/dungeon/dungeon_generator.gd +++ b/scripts/dungeon/dungeon_generator.gd @@ -15,7 +15,11 @@ extends RefCounted ## Every step uses the same SeededRng, so a seed always produces the ## same dungeon. -func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult: +func generate( + p_config: DungeonConfig, + p_seed: int, + p_exit_tile: int = DungeonMap.Tile.EXIT +) -> DungeonResult: var result := DungeonResult.new() result.seed_value = p_seed result.config = p_config @@ -34,7 +38,7 @@ func generate(p_config: DungeonConfig, p_seed: int) -> DungeonResult: result.start_pos = result.rooms[result.start_room].center() result.exit_pos = result.rooms[result.exit_room].center() result.map.set_tile_cell(result.start_pos, DungeonMap.Tile.START) - result.map.set_tile_cell(result.exit_pos, DungeonMap.Tile.EXIT) + result.map.set_tile_cell(result.exit_pos, p_exit_tile) _place_doors_and_keys(rng, p_config, result) _place_monsters(rng, p_config, result) diff --git a/scripts/dungeon/dungeon_map.gd b/scripts/dungeon/dungeon_map.gd index e89efab..a318cee 100644 --- a/scripts/dungeon/dungeon_map.gd +++ b/scripts/dungeon/dungeon_map.gd @@ -12,6 +12,7 @@ enum Tile { DOOR_OPEN, START, EXIT, + STAIRS, } var width: int = 0 diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..685bc2c 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -24,6 +24,10 @@ const MONSTER_SCENE := preload("res://scenes/actors/monster.tscn") const PICKUP_SCENE := preload("res://scenes/actors/pickup.tscn") var run: DungeonResult = null +var run_plan: RunPlan = null +var floors: Array[Dictionary] = [] +var floor_index: int = 0 +var floor_seed: int = 0 var biome: DungeonConfig = null var occupancy: Dictionary = {} var run_seed: int = 0 @@ -66,7 +70,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 run_plan.is_final(floor_index): + _on_victory() + else: + _descend() func _on_start_requested(p_seed_text: String) -> void: var text := p_seed_text.strip_edges() @@ -92,22 +99,36 @@ 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) + run_plan = RunPlan.new() + floors = run_plan.floors(p_seed_value) + _build_floor(0) + + 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) + +## Builds one floor of the run and wires the world to it. +## The hero keeps health, coins, and shards; keys reset every floor. +func _build_floor(p_floor_index: int) -> void: + var entry: Dictionary = floors[p_floor_index] + floor_index = p_floor_index + floor_seed = entry.seed + biome = entry.biome + var exit_tile := DungeonMap.Tile.EXIT if run_plan.is_final(p_floor_index) else DungeonMap.Tile.STAIRS + run = DungeonGenerator.new().generate(biome, floor_seed, exit_tile) _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) - occupancy[run.start_pos] = player - player.can_enter = _hero_can_enter + _setup_player() monster_index = 0 for spawn in run.monster_spawns: @@ -123,22 +144,41 @@ 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), floor_index + 1, run_plan.floor_count, run.depth) 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_index = floor_index + RunState.floor_count = run_plan.floor_count + +## Places the hero on the new floor, carrying the run forward. +func _setup_player() -> void: + var max_health := biome.starting_health + var health := max_health + if player.stats != null: + max_health = player.stats.max_health + health = player.stats.health + if floor_index > 0: + health = mini(max_health, health + roundi(max_health * 0.2)) + var stats := CombatStats.make({ + "max_health": max_health, + "health": health, + "damage": biome.player_damage, + }) + player.reset_keys() + player.setup(stats, run.start_pos, dungeon_view, occupancy) + occupancy[run.start_pos] = player + player.can_enter = _hero_can_enter - menu_overlay.hide_menu() - result_overlay.hide_result() - get_tree().paused = false - run_started.emit(p_seed_value) +## Moves the run down one floor. +func _descend() -> void: + if _ended or run_plan == null or run_plan.is_final(floor_index): + return + _build_floor(floor_index + 1) func _spawn_monster(p_position: Vector2i, p_monster_id: StringName) -> void: - var spec := MonsterSpecs.by_id(p_monster_id) + var base := MonsterSpecs.by_id(p_monster_id) + var spec := MonsterSpecs.scaled(base, floor_index) var monster: MonsterActor = MONSTER_SCENE.instantiate() monsters_root.add_child(monster) monster.setup(spec, p_position, dungeon_view, occupancy, player) @@ -206,6 +246,8 @@ func _on_player_died() -> void: result_overlay.show_result( false, SeededRng.encode_seed(run_seed), + floor_index + 1, + run_plan.floor_count, run.depth, player.coins, RunState.elapsed() @@ -221,6 +263,8 @@ func _on_victory() -> void: result_overlay.show_result( true, SeededRng.encode_seed(run_seed), + floor_index + 1, + run_plan.floor_count, run.depth, player.coins, RunState.elapsed() diff --git a/scripts/rng/seeded_rng.gd b/scripts/rng/seeded_rng.gd index 96d0f08..772039c 100644 --- a/scripts/rng/seeded_rng.gd +++ b/scripts/rng/seeded_rng.gd @@ -81,6 +81,17 @@ func shuffle(p_array: Array) -> Array: p_array[j] = tmp return p_array +## Derives a new seed from a base seed and a salt value. +## The result depends only on the inputs, never on the platform. +## Run planners use this to give every floor its own seed. +static func derive(p_seed: int, p_salt: int) -> int: + var state := (p_seed + p_salt * 0x6D2B79F5) & U32_MAX + state = state ^ (state >> 15) + state = (state * (state | 1)) & U32_MAX + state = state ^ (state >> 13) + state = state ^ (state >> 16) + return state & SEED_MASK + ## Encodes a seed as a fixed-width base-36 string. static func encode_seed(p_seed: int, p_width: int = 6) -> String: var chars := PackedStringArray() diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..78c3b06 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_floor: int, p_floor_count: int, p_depth: int) -> void: biome_label.text = "Biome: " + p_biome seed_label.text = "Seed: " + p_seed + floor_label.text = "Floor %d of %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..ff0b907 100644 --- a/scripts/ui/result_overlay.gd +++ b/scripts/ui/result_overlay.gd @@ -17,14 +17,22 @@ 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_floor: int, + p_floor_count: int, + p_depth: int, + p_coins: int, + p_time: float +) -> 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 of %d\nDepth: %d\nCoins: %d\nTime: %d:%02d" % [ + p_seed, p_floor, p_floor_count, 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..168b236 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: @@ -61,6 +61,7 @@ func build_minimap_image() -> Image: var door_color: Color = palette.get(&"door_lock", Color.GOLD) var start_color: Color = palette.get(&"rune", Color.CYAN) var exit_color: Color = palette.get(&"glow", Color.GREEN) + var stairs_color: Color = palette.get(&"door_lock", Color.GOLD) for x in map.width: for y in map.height: var color := floor_color @@ -71,6 +72,8 @@ func build_minimap_image() -> Image: color = door_color DungeonMap.Tile.START: color = start_color + DungeonMap.Tile.STAIRS: + color = stairs_color DungeonMap.Tile.EXIT: color = exit_color for dx in 2: @@ -86,7 +89,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 +126,8 @@ func _atlas_for(p_tile: int) -> Vector2i: return Vector2i(1, 1) DungeonMap.Tile.EXIT: return Vector2i(2, 1) + DungeonMap.Tile.STAIRS: + return Vector2i(3, 1) _: return Vector2i(1, 0) diff --git a/scripts/world/tile_art.gd b/scripts/world/tile_art.gd index 2b0115a..e67d4f8 100644 --- a/scripts/world/tile_art.gd +++ b/scripts/world/tile_art.gd @@ -9,6 +9,7 @@ extends RefCounted const SIZE := 16 const SOURCE := 8 +const TILE_STAIRS := &"stairs" const TILE_WALL := &"wall" const TILE_FLOOR_A := &"floor_a" const TILE_FLOOR_B := &"floor_b" @@ -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 := [ + "........", + "..####..", + ".#++++#.", + ".#0000#.", + ".#0..0#.", + ".#.0..0.", + ".#..0...", + "........", +] + const _PLAYER := [ "..oo....", "..o@o...", diff --git a/tests/integration/test_full_run.gd b/tests/integration/test_full_run.gd index 1aaf900..eb4d79e 100644 --- a/tests/integration/test_full_run.gd +++ b/tests/integration/test_full_run.gd @@ -56,3 +56,28 @@ 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_full_run_descends_through_solvable_floors() -> void: + var plan := RunPlan.new(4) + var floors := plan.floors(20260803) + for i in floors.size(): + var exit_tile := DungeonMap.Tile.EXIT if plan.is_final(i) else DungeonMap.Tile.STAIRS + var biome: DungeonConfig = floors[i].biome + var result := DungeonGenerator.new().generate(biome, floors[i].seed, exit_tile) + assert_true(result.solvable, "floor %d not solvable" % i) + assert_true( + Pathfinding.reaches(result.map, result.start_pos, result.exit_pos, true), + "floor %d exit unreachable" % i + ) + assert_eq(result.map.get_tile_cell(result.exit_pos), exit_tile) + +func test_only_final_floor_has_a_true_exit() -> void: + var plan := RunPlan.new(5) + var floors := plan.floors(909) + for i in floors.size(): + var biome: DungeonConfig = floors[i].biome + var result := DungeonGenerator.new().generate(biome, floors[i].seed, DungeonMap.Tile.EXIT if plan.is_final(i) else DungeonMap.Tile.STAIRS) + if plan.is_final(i): + assert_eq(result.map.get_tile_cell(result.exit_pos), DungeonMap.Tile.EXIT) + else: + assert_eq(result.map.get_tile_cell(result.exit_pos), DungeonMap.Tile.STAIRS) diff --git a/tests/unit/test_dungeon_generator.gd b/tests/unit/test_dungeon_generator.gd index ef61719..b513130 100644 --- a/tests/unit/test_dungeon_generator.gd +++ b/tests/unit/test_dungeon_generator.gd @@ -89,3 +89,25 @@ func test_monsters_spawn_on_walkable_cells() -> void: func test_monster_specs_are_valid() -> void: for spec in MonsterSpecs.all(): assert_true(spec.is_valid(), "monster %s is invalid" % spec.id) + +func test_generator_places_stairs_when_asked() -> void: + var biome := Biomes.crypt() + var result := generator.generate(biome, 777, DungeonMap.Tile.STAIRS) + assert_eq(result.map.get_tile_cell(result.exit_pos), DungeonMap.Tile.STAIRS) + assert_true(result.map.is_walkable_cell(result.exit_pos)) + assert_true(result.solvable) + +func test_generator_places_exit_by_default() -> void: + var biome := Biomes.crypt() + var result := generator.generate(biome, 777) + assert_eq(result.map.get_tile_cell(result.exit_pos), DungeonMap.Tile.EXIT) + +func test_stairs_floor_stays_solvable_across_seeds() -> void: + var biome := Biomes.drowned_forest() + for seed in [51, 52, 53, 54, 55]: + var result := generator.generate(biome, seed, DungeonMap.Tile.STAIRS) + assert_true(result.solvable, "stairs floor not solvable for seed %d" % seed) + assert_true( + Pathfinding.reaches(result.map, result.start_pos, result.exit_pos, true), + "stairs exit unreachable for seed %d" % seed + ) diff --git a/tests/unit/test_monster_specs.gd b/tests/unit/test_monster_specs.gd new file mode 100644 index 0000000..eaeca41 --- /dev/null +++ b/tests/unit/test_monster_specs.gd @@ -0,0 +1,33 @@ +extends GutTest +## Scaled monster specs must keep valid stats and grow with depth. + +func test_scaled_returns_source_for_first_floor() -> void: + var spec := MonsterSpecs.skeleton() + assert_eq(MonsterSpecs.scaled(spec, 0), spec) + +func test_scaled_boosts_health_and_damage() -> void: + var spec := MonsterSpecs.skeleton() + var deep := MonsterSpecs.scaled(spec, 3) + assert_gt(deep.stats.max_health, spec.stats.max_health) + assert_gt(deep.stats.damage, spec.stats.damage) + +func test_scaled_health_matches_max_at_spawn() -> void: + var spec := MonsterSpecs.golem() + var deep := MonsterSpecs.scaled(spec, 2) + assert_eq(deep.stats.health, deep.stats.max_health) + +func test_scaled_preserves_identity() -> void: + var spec := MonsterSpecs.golem() + var deep := MonsterSpecs.scaled(spec, 2) + assert_eq(deep.id, spec.id) + assert_eq(deep.ai, spec.ai) + assert_eq(deep.sprite_key, spec.sprite_key) + assert_eq(deep.drop_table, spec.drop_table) + assert_true(deep.is_valid()) + +func test_scaled_does_not_mutate_source() -> void: + var spec := MonsterSpecs.wisp() + var before := spec.stats.max_health + var deep := MonsterSpecs.scaled(spec, 4) + assert_eq(spec.stats.max_health, before) + assert_ne(deep.stats.max_health, before) diff --git a/tests/unit/test_monster_specs.gd.uid b/tests/unit/test_monster_specs.gd.uid new file mode 100644 index 0000000..1851be9 --- /dev/null +++ b/tests/unit/test_monster_specs.gd.uid @@ -0,0 +1 @@ +uid://k6xvi5gm7454 diff --git a/tests/unit/test_run_plan.gd b/tests/unit/test_run_plan.gd new file mode 100644 index 0000000..1769d97 --- /dev/null +++ b/tests/unit/test_run_plan.gd @@ -0,0 +1,80 @@ +extends GutTest +## The run plan must be deterministic, varied, and scaled with depth. + +func test_floor_count_is_fixed() -> void: + var plan := RunPlan.new() + assert_eq(plan.floor_count, RunPlan.DEFAULT_FLOOR_COUNT) + assert_eq(plan.floors(12345).size(), plan.floor_count) + +func test_plan_is_deterministic() -> void: + var a := RunPlan.new().floors(777) + var b := RunPlan.new().floors(777) + assert_eq(a.size(), b.size()) + for i in a.size(): + assert_eq(a[i].seed, b[i].seed) + assert_eq(a[i].biome.id, b[i].biome.id) + +func test_different_run_seeds_differ() -> void: + var a := RunPlan.new().floors(1) + var b := RunPlan.new().floors(2) + assert_ne(a[0].seed, b[0].seed) + +func test_first_floor_uses_the_run_seed() -> void: + var floors := RunPlan.new().floors(424242) + assert_eq(floors[0].seed, 424242 & SeededRng.SEED_MASK) + +func test_floor_seeds_are_distinct() -> void: + var floors := RunPlan.new().floors(99) + var seen := {} + for entry in floors: + seen[entry.seed] = true + assert_eq(seen.size(), floors.size()) + +func test_biomes_cycle_in_order() -> void: + var plan := RunPlan.new(6) + var floors := plan.floors(5) + var all_biomes := Biomes.all() + for i in floors.size(): + var biome: DungeonConfig = floors[i].biome + assert_eq(biome.id, all_biomes[i % all_biomes.size()].id) + +func test_difficulty_increases_with_floor() -> void: + var floors := RunPlan.new(4).floors(2020) + for i in range(1, floors.size()): + var previous: DungeonConfig = floors[i - 1].biome + var current: DungeonConfig = floors[i].biome + assert_gt(current.monster_cap, previous.monster_cap) + assert_gte(current.monster_density, previous.monster_density) + +func test_density_stays_bounded() -> void: + var floors := RunPlan.new(4).floors(818) + for entry in floors: + var biome: DungeonConfig = entry.biome + assert_lte(biome.monster_density, 0.9) + +func test_is_final_only_for_last_floor() -> void: + var plan := RunPlan.new(4) + assert_false(plan.is_final(0)) + assert_false(plan.is_final(2)) + assert_true(plan.is_final(3)) + +func test_single_floor_plan_is_its_own_final() -> void: + var plan := RunPlan.new(1) + assert_true(plan.is_final(0)) + +func test_every_planned_floor_is_solvable() -> void: + var plan := RunPlan.new(4) + var floors := plan.floors(31337) + for i in floors.size(): + var exit_tile := DungeonMap.Tile.EXIT if plan.is_final(i) else DungeonMap.Tile.STAIRS + var biome: DungeonConfig = floors[i].biome + var result := DungeonGenerator.new().generate(biome, floors[i].seed, exit_tile) + assert_true(result.solvable, "floor %d is not solvable" % i) + assert_eq(result.map.get_tile_cell(result.exit_pos), exit_tile) + +func test_scaled_config_does_not_mutate_the_source() -> void: + var base := Biomes.ember_stronghold() + var cap_before := base.monster_cap + var deep := Biomes.scaled(base, 3) + assert_eq(base.monster_cap, cap_before) + assert_gt(deep.monster_cap, cap_before) diff --git a/tests/unit/test_run_plan.gd.uid b/tests/unit/test_run_plan.gd.uid new file mode 100644 index 0000000..20e7b2f --- /dev/null +++ b/tests/unit/test_run_plan.gd.uid @@ -0,0 +1 @@ +uid://vghqps2v1re diff --git a/tests/unit/test_seeded_rng.gd b/tests/unit/test_seeded_rng.gd index 66f4b38..d8717f2 100644 --- a/tests/unit/test_seeded_rng.gd +++ b/tests/unit/test_seeded_rng.gd @@ -65,3 +65,16 @@ func test_decode_rejects_garbage() -> void: func test_encode_is_case_insensitive_when_decoding() -> void: var encoded := SeededRng.encode_seed(543210).to_lower() assert_eq(SeededRng.decode_seed(encoded), 543210) + +func test_derive_is_deterministic() -> void: + assert_eq(SeededRng.derive(12345, 3), SeededRng.derive(12345, 3)) + +func test_derive_changes_with_salt() -> void: + assert_ne(SeededRng.derive(12345, 0), SeededRng.derive(12345, 1)) + +func test_derive_changes_with_seed() -> void: + assert_ne(SeededRng.derive(100, 2), SeededRng.derive(200, 2)) + +func test_derive_stays_in_seed_mask() -> void: + var derived := SeededRng.derive(999999, 5) + assert_eq(derived & SeededRng.SEED_MASK, derived) diff --git a/tools/check_scripts.gd b/tools/check_scripts.gd new file mode 100644 index 0000000..9375893 --- /dev/null +++ b/tools/check_scripts.gd @@ -0,0 +1,79 @@ +extends SceneTree +## Static checks for CI. +## +## Loads every GDScript file in the project and reports parse errors. +## It also verifies that the biome and monster registries stay consistent +## with the art and drop tables, and that seeds round-trip. Exits with +## code 0 on success and code 1 on failure. + +var _failed := false + +func _initialize() -> void: + _check_scripts() + _check_registries() + _check_seed_math() + if _failed: + print("[check] FAILED") + quit(1) + else: + print("[check] all static checks passed") + quit(0) + +func _check_scripts() -> void: + for dir in ["res://scripts", "res://tests", "res://tools"]: + _scan_dir(dir) + +func _scan_dir(p_dir: String) -> void: + var dir := DirAccess.open(p_dir) + if dir == null: + _fail("cannot open " + p_dir) + return + dir.list_dir_begin() + var name := dir.get_next() + while name != "": + if dir.current_is_dir(): + if name != "." and name != "..": + _scan_dir(p_dir.path_join(name)) + elif name.ends_with(".gd"): + var script := load(p_dir.path_join(name)) + if script == null: + _fail("failed to load " + p_dir.path_join(name)) + name = dir.get_next() + dir.list_dir_end() + +func _check_registries() -> void: + for biome in Biomes.all(): + if not biome.is_valid(): + _fail("biome %s is invalid: %s" % [biome.id, str(biome.validate())]) + continue + for entry in biome.monster_table: + var monster_id: StringName = entry.monster + var spec := MonsterSpecs.by_id(monster_id) + if spec.id != monster_id: + _fail("biome %s references unknown monster %s" % [biome.id, monster_id]) + if not TileArt.has_entity(monster_id): + _fail("monster %s has no sprite" % monster_id) + for spec in MonsterSpecs.all(): + if not spec.is_valid(): + _fail("monster %s is invalid" % spec.id) + if not TileArt.has_entity(StringName(spec.sprite_key)): + _fail("monster %s sprite %s has no art" % [spec.id, spec.sprite_key]) + for item in [&"player", &"key", &"potion", &"coin"]: + if not TileArt.has_entity(item): + _fail("entity %s has no art" % item) + for entry in RunPlan.new().floors(7): + var config: DungeonConfig = entry.biome + if not config.is_valid(): + _fail("planned floor biome %s is invalid" % config.id) + +func _check_seed_math() -> void: + if SeededRng.decode_seed(SeededRng.encode_seed(1048575)) != 1048575: + _fail("seed round-trip failed") + if SeededRng.derive(123, 1) == SeededRng.derive(123, 2): + _fail("seed derivation ignores the salt") + if SeededRng.derive(1, 9) == SeededRng.derive(2, 9): + _fail("seed derivation ignores the base seed") + +func _fail(p_message: String) -> void: + _failed = true + push_error("[check] " + p_message) diff --git a/tools/check_scripts.gd.uid b/tools/check_scripts.gd.uid new file mode 100644 index 0000000..d294d78 --- /dev/null +++ b/tools/check_scripts.gd.uid @@ -0,0 +1 @@ +uid://br2113rhj60e3 diff --git a/tools/smoke.gd b/tools/smoke.gd index 3b50254..868fea5 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 +## proves the run descends to a second floor. Exits with code 0 on +## success and code 1 on failure. var _main: Main = null var _failed := false @@ -36,19 +37,58 @@ func _run() -> void: return _verify() + if _failed: + quit(1) + return + _verify_descend() + + if _failed: + print("[smoke] FAILED") + quit(1) + else: + print("Smoke test passed: seed 12345 spawned a solvable multi-floor run.") + quit(0) + func _verify() -> void: if _main.run == null: _fail("no run was generated") - elif _main.player == null: + return + if _main.player == null: _fail("player was not spawned") - elif _main.player.grid_pos != _main.run.start_pos: + return + if _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): + return + if 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: + return + if not _main.run.solvable: _fail("dungeon is not solvable") + return _verify_input_bindings() +func _verify_descend() -> void: + if _main.run_plan == null or _main.floors.is_empty(): + _fail("no run plan was built") + return + if _main.run_plan.floor_count < 2: + _fail("run does not span multiple floors") + return + if _main.run.map.get_tile_cell(_main.run.exit_pos) != DungeonMap.Tile.STAIRS: + _fail("first floor exit is not stairs") + return + + _main.player.grid_pos = _main.run.exit_pos + for i in 5: + await physics_frame + + if _main.floor_index != 1: + _fail("descending did not advance to floor 1") + return + if not _main.run.solvable: + _fail("second floor is not solvable") + return + func _verify_input_bindings() -> void: for action in Controls.ACTIONS: var has_joypad := false @@ -61,13 +101,6 @@ func _verify_input_bindings() -> void: if Controls.hint_for(false).is_empty() or Controls.hint_for(true).is_empty(): _fail("control hints are empty") - if _failed: - print("[smoke] FAILED") - quit(1) - else: - print("Smoke test passed: seed 12345 spawned a solvable dungeon.") - quit(0) - func _fail(p_message: String) -> void: _failed = true push_error("[smoke] " + p_message)