From 5cd49e4fceece2a8e124de64cbc422fe8b309874 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:08:25 -0700 Subject: [PATCH] feat: extend dungeonwright --- .gitattributes | 1 + .github/dependabot.yml | 10 +++ .github/workflows/ci.yml | 11 +++- CHANGELOG.md | 41 ++++++++++++ README.md | 50 ++++++++++----- docs/architecture.md | 23 ++++++- scripts/actors/player.gd | 13 ++++ scripts/combat/item_spec.gd | 20 ++++++ scripts/combat/item_spec.gd.uid | 1 + scripts/combat/items.gd | 100 +++++++++++++++++++++++++++++ scripts/combat/items.gd.uid | 1 + scripts/combat/monster_specs.gd | 31 +++++++-- scripts/dungeon/biomes.gd | 46 ++++++++++++- scripts/main.gd | 1 + scripts/ui/hud.gd | 16 +++++ scripts/world/tile_art.gd | 65 ++++++++++++++++++- tests/integration/test_full_run.gd | 17 +++++ tests/unit/test_biomes.gd | 16 +++++ tests/unit/test_drop_table.gd | 11 ++++ tests/unit/test_items.gd | 57 ++++++++++++++++ tests/unit/test_items.gd.uid | 1 + tools/smoke.gd | 23 ++++++- 22 files changed, 528 insertions(+), 27 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 CHANGELOG.md create mode 100644 scripts/combat/item_spec.gd create mode 100644 scripts/combat/item_spec.gd.uid create mode 100644 scripts/combat/items.gd create mode 100644 scripts/combat/items.gd.uid create mode 100644 tests/unit/test_items.gd create mode 100644 tests/unit/test_items.gd.uid diff --git a/.gitattributes b/.gitattributes index 66fb254..ddc6f21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,5 @@ *.md text eol=lf *.sh text eol=lf *.ps1 text eol=lf +*.yml text eol=lf *.svg text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fe8d274 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "ci" + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4245a21..8ad39e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,22 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Godot 4.6 uses: chickensoft-games/setup-godot@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2ade19a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes are listed here. The format follows Keep a Changelog. +Versions follow Semantic Versioning. + +## [Unreleased] + +### Added + +- A fourth biome: the Sunken Ruins. + The biome uses wide winding corridors and a deep teal palette. +- A new monster: the Riptide. + The Riptide is a fast stalker that drops upgrade items. +- Two permanent upgrade items. + A whetstone raises attack damage by 2. + A relic raises max health by 10 and heals part of the gap. +- An item registry with ids, names, categories, and upgrade math. +- A HUD readout that shows collected stat bonuses. +- A unit and integration suite for items and the new biome. + +### Changed + +- Monster drop tables can now carry optional extra entries. +- The hero tracks permanent damage and max-health bonuses per run. +- The headless smoke test also verifies the Sunken Ruins + and the upgrade effects. + +### Fixed + +- None. + +## [1.0.0] - 2026-08-03 + +### Added + +- Seeded dungeon generation with a guaranteed reachable exit. +- Three biomes with different generation rules. +- Rooms, corridors, locked doors, keys, and monsters. +- Procedural pixel art generated from code. +- A minimap, a health bar, and run summary overlays. +- A GUT test suite and headless CI pipeline. diff --git a/README.md b/README.md index c0b321a..8761a36 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,23 @@ The same seed always builds the same dungeon. - A new map for every run, driven by a seed. - Rooms, corridors, locked doors, and keys. -- Three biomes with different generation rules. +- Four biomes with different generation rules. - Monsters with simple combat and balanced drops. +- Permanent upgrade items dropped by monsters. - Procedural pixel art with no bundled image files. - A minimap, a health bar, and run summary overlays. - Deterministic generation for replayable runs. -## 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 a fourth biome and permanent upgrades. +The Sunken Ruins flood the map with wide winding corridors. +Riptides, a new fast monster, patrol the flooded halls. + +Monsters rarely drop upgrade items. +A whetstone raises your attack damage by 2. +A relic raises your max health by 10 and heals you. +The HUD shows the bonuses you have collected. ## Requirements @@ -90,10 +95,16 @@ 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. +Items use a small registry. +Each item has an id, a name, and a category. +Loot tables roll a weighted entry. +Upgrades mutate the hero stats through one pure function. +The same seed always produces the same drops. + ## Project layout - `scripts/dungeon` holds the generator and map logic. -- `scripts/combat` holds stats, monsters, and loot tables. +- `scripts/combat` holds stats, monsters, items, and loot tables. - `scripts/world` renders tiles and builds the minimap. - `scripts/actors` holds the hero, monsters, and pickups. - `scripts/ui` builds the HUD and overlays. @@ -107,25 +118,32 @@ A seed always produces the same map. Doors never block the exit permanently. Every key sits on the reachable side of its door. Monsters never cross a locked door. +Upgrade items never change the generated map. ## Evaluation evidence -The suite has 56 tests. -It covers generation, biomes, combat, drops, and pathfinding. -All 56 tests pass in a headless run. -A smoke test loads the game and spawns a fixed-seed run. +The suite has 70 tests. +It covers generation, biomes, combat, drops, pathfinding, +and the item registry. +All 70 tests pass in a headless run. +A smoke test loads the game, spawns a fixed-seed run, +and checks the Sunken Ruins and the upgrade effects. ## Roadmap -- Add multi-floor descent and a depth counter. -- Add ranged monsters and projectiles. -- Add sound and music. -- Add more biomes and items. +Complete: + +- More biomes and items. +- The Sunken Ruins biome and the Riptide monster. +- Whetstone and relic permanent upgrades. + +Next: + - Add controller support. ## Limitations -The demo has three biomes. +The demo has four biomes. Each run is a single floor. All monsters use melee attacks. The game has no audio yet. diff --git a/docs/architecture.md b/docs/architecture.md index 1b7dde2..6d9e4dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,12 @@ Each biome picks a carving style. - Winding: a path of short, jittered runs. - Straight: a path that favors one axis at a time. +Four biomes share these three styles. +The crypt uses elbows, the drowned forest and the sunken ruins +use winding paths, and the ember stronghold uses straight halls. +Each biome also sets its own room sizes, door counts, monster +pressure, and palette. + All carvers keep every carved cell orthogonally adjacent to the path. This rule prevents floating single-cell islands. @@ -83,6 +89,20 @@ Monsters chase, stalk, or hold ground according to their spec. Each monster rolls loot from a weighted `DropTable`. Coins drop often, shards sometimes, potions rarely. +## Items + +Every collectible lives in a small registry. +`ItemSpec` holds the id, name, description, and category. +The `Items` class is the single source of truth for ids. +Treasure, consumables, and upgrades share the same table. + +Upgrade items use one pure function. +`Items.apply_upgrade` mutates a `CombatStats` block and returns the change. +A whetstone adds 2 to damage. +A relic adds 10 to max health and heals toward the new maximum. +The hero tracks the bonuses so the HUD can show them. +The registry keeps loot logic testable and consistent. + ## Scene flow The `Main` scene owns the game loop. @@ -98,7 +118,8 @@ The biome palette recolors the tiles at run time. ## 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 the item registry. Integration tests run many seeds across all biomes. Every generated dungeon must be solvable. diff --git a/scripts/actors/player.gd b/scripts/actors/player.gd index 1ee672f..6de10a7 100644 --- a/scripts/actors/player.gd +++ b/scripts/actors/player.gd @@ -11,6 +11,7 @@ signal hp_changed(current: int, max: int) signal coins_changed(count: int) signal shards_changed(count: int) signal keys_changed(count: int) +signal upgrades_changed(bonus_damage: int, bonus_max_health: int) signal attacked(origin: Vector2i, facing: Vector2i, range: float, damage: int) signal moved(grid: Vector2i) signal died @@ -26,6 +27,10 @@ var facing: Vector2i = Vector2i.DOWN var coins := 0 var shards := 0 var keys_held := 0 +## Permanent damage bonus from picked-up upgrades. +var damage_bonus := 0 +## Permanent max-health bonus from picked-up upgrades. +var max_health_bonus := 0 var view: DungeonView = null var occupancy: Dictionary = {} @@ -137,6 +142,13 @@ func apply_pickup(p_item: StringName, p_count: int) -> void: &"key": keys_held += p_count keys_changed.emit(keys_held) + &"whetstone", &"relic": + for i in p_count: + var change := Items.apply_upgrade(p_item, stats) + damage_bonus += int(change.get("damage", 0)) + max_health_bonus += int(change.get("max_health", 0)) + hp_changed.emit(stats.health, stats.max_health) + upgrades_changed.emit(damage_bonus, max_health_bonus) ## Consumes one key, used when the hero opens a locked door. func spend_key() -> void: @@ -153,6 +165,7 @@ func emit_hud() -> void: coins_changed.emit(coins) shards_changed.emit(shards) keys_changed.emit(keys_held) + upgrades_changed.emit(damage_bonus, max_health_bonus) func _flash() -> void: var tween := create_tween() diff --git a/scripts/combat/item_spec.gd b/scripts/combat/item_spec.gd new file mode 100644 index 0000000..d84dda0 --- /dev/null +++ b/scripts/combat/item_spec.gd @@ -0,0 +1,20 @@ +class_name ItemSpec +extends RefCounted +## Static definition of a collectible item. +## +## An item has an id, a display name, a description, a category, and the +## art key used to draw its sprite. The registry is the single source of +## truth for item ids, so the player, the loot tables, and the renderer +## all agree on what exists. + +enum Category { + TREASURE, + CONSUMABLE, + UPGRADE, +} + +var id: StringName = &"" +var display_name: String = "" +var description: String = "" +var category: Category = Category.TREASURE +var sprite_key: StringName = &"" diff --git a/scripts/combat/item_spec.gd.uid b/scripts/combat/item_spec.gd.uid new file mode 100644 index 0000000..566e680 --- /dev/null +++ b/scripts/combat/item_spec.gd.uid @@ -0,0 +1 @@ +uid://csw8i2yhjhewt diff --git a/scripts/combat/items.gd b/scripts/combat/items.gd new file mode 100644 index 0000000..8e69a13 --- /dev/null +++ b/scripts/combat/items.gd @@ -0,0 +1,100 @@ +class_name Items +extends RefCounted +## Registry of every item in the game. +## +## Treasure items add to the score. Consumables heal the hero. Upgrades +## change the hero stats for the rest of the run. The registry is the +## single source of truth for ids, names, categories, and upgrade math, +## which keeps loot logic testable and consistent. + +## Returns every item in a stable order. +static func all() -> Array[ItemSpec]: + return [coin(), shard(), potion(), key(), whetstone(), relic()] + +## Returns the item with the given id, or coins for an unknown id. +static func by_id(p_id: StringName) -> ItemSpec: + for item in all(): + if item.id == p_id: + return item + return coin() + +## True when the registry knows the given item id. +static func has(p_id: StringName) -> bool: + return by_id(p_id).id == p_id + +## True when the item is a permanent upgrade. +static func is_upgrade(p_id: StringName) -> bool: + return by_id(p_id).category == ItemSpec.Category.UPGRADE + +## Applies an upgrade to the given stats. +## +## Mutates the stats and returns the change as a dictionary. Optional +## keys are "damage", "max_health", and "health". Unknown items change +## nothing and return an empty dictionary. +static func apply_upgrade(p_id: StringName, p_stats: CombatStats) -> Dictionary: + match p_id: + &"whetstone": + p_stats.damage += 2 + return { "damage": 2 } + &"relic": + var old_max := p_stats.max_health + p_stats.max_health += 10 + var health_gain := mini(10, p_stats.max_health - p_stats.health) + p_stats.health += health_gain + return { "max_health": p_stats.max_health - old_max, "health": health_gain } + _: + return {} + +static func coin() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"coin" + item.display_name = "Coin" + item.description = "Shiny treasure from a fallen monster." + item.category = ItemSpec.Category.TREASURE + item.sprite_key = &"coin" + return item + +static func shard() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"shard" + item.display_name = "Shard" + item.description = "A fragment of a shattered relic." + item.category = ItemSpec.Category.TREASURE + item.sprite_key = &"shard" + return item + +static func potion() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"potion" + item.display_name = "Potion" + item.description = "Restores 25 health when picked up." + item.category = ItemSpec.Category.CONSUMABLE + item.sprite_key = &"potion" + return item + +static func key() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"key" + item.display_name = "Key" + item.description = "Opens one locked door." + item.category = ItemSpec.Category.CONSUMABLE + item.sprite_key = &"key" + return item + +static func whetstone() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"whetstone" + item.display_name = "Whetstone" + item.description = "Raises attack damage by 2 for this run." + item.category = ItemSpec.Category.UPGRADE + item.sprite_key = &"whetstone" + return item + +static func relic() -> ItemSpec: + var item := ItemSpec.new() + item.id = &"relic" + item.display_name = "Relic" + item.description = "Raises max health by 10 and heals for this run." + item.category = ItemSpec.Category.UPGRADE + item.sprite_key = &"relic" + return item diff --git a/scripts/combat/items.gd.uid b/scripts/combat/items.gd.uid new file mode 100644 index 0000000..1385298 --- /dev/null +++ b/scripts/combat/items.gd.uid @@ -0,0 +1 @@ +uid://eic26tscy2o8 diff --git a/scripts/combat/monster_specs.gd b/scripts/combat/monster_specs.gd index f39e62d..fdba098 100644 --- a/scripts/combat/monster_specs.gd +++ b/scripts/combat/monster_specs.gd @@ -13,6 +13,7 @@ static func all() -> Array[MonsterSpec]: wisp(), shambler(), golem(), + brine(), ] static func by_id(p_id: StringName) -> MonsterSpec: @@ -54,7 +55,9 @@ static func shambler() -> MonsterSpec: "max_health": 38, "health": 38, "damage": 12, "speed": 1.5, "attack_range": 1.4, "attack_cooldown": 1.4, }) - spec.drop_table = DropTable.from_entries(_entries([5.0, 1.0, 2.0]), null) + spec.drop_table = DropTable.from_entries(_entries([5.0, 1.0, 2.0], [ + { "item": &"relic", "weight": 0.6, "min": 1, "max": 1 }, + ]), null) return spec static func golem() -> MonsterSpec: @@ -63,7 +66,21 @@ static func golem() -> MonsterSpec: "max_health": 60, "health": 60, "damage": 15, "speed": 1.2, "attack_range": 1.4, "attack_cooldown": 1.6, }) - spec.drop_table = DropTable.from_entries(_entries([4.0, 3.0, 2.0]), null) + spec.drop_table = DropTable.from_entries(_entries([4.0, 3.0, 2.0], [ + { "item": &"whetstone", "weight": 0.7, "min": 1, "max": 1 }, + ]), null) + return spec + +static func brine() -> MonsterSpec: + var spec := _base(&"brine", "Riptide", MonsterSpec.AI.stalker, "brine", 7.0) + spec.stats = CombatStats.make({ + "max_health": 22, "health": 22, "damage": 7, + "speed": 3.0, "attack_range": 1.1, "attack_cooldown": 0.8, + }) + spec.drop_table = DropTable.from_entries(_entries([3.0, 2.0, 0.8], [ + { "item": &"relic", "weight": 0.5, "min": 1, "max": 1 }, + { "item": &"whetstone", "weight": 0.5, "min": 1, "max": 1 }, + ]), null) return spec static func _base( @@ -81,13 +98,17 @@ static func _base( spec.aggro_range = p_aggro return spec -## Builds the three drop entries. Order: coins, shards, potions. -static func _entries(p_weights: Array) -> Array: - return [ +## Builds the three core drop entries and appends optional extras. +## Order: coins, shards, potions, then any extra entries. +static func _entries(p_weights: Array, p_extra: Array = []) -> Array: + var entries := [ { "item": &"coin", "weight": p_weights[0], "min": 1, "max": 3 }, { "item": &"shard", "weight": p_weights[1], "min": 1, "max": 2 }, { "item": &"potion", "weight": p_weights[2], "min": 1, "max": 1 }, ] + for entry in p_extra: + entries.append(entry) + return entries ## 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]: diff --git a/scripts/dungeon/biomes.gd b/scripts/dungeon/biomes.gd index 5711f56..b7bebdc 100644 --- a/scripts/dungeon/biomes.gd +++ b/scripts/dungeon/biomes.gd @@ -7,7 +7,7 @@ extends RefCounted ## Returns every biome in a stable order. static func all() -> Array[DungeonConfig]: - return [crypt(), drowned_forest(), ember_stronghold()] + return [crypt(), drowned_forest(), ember_stronghold(), sunken_ruins()] ## Returns a copy of the biome with the given id. static func by_id(p_id: StringName) -> DungeonConfig: @@ -100,6 +100,33 @@ static func ember_stronghold() -> DungeonConfig: config.palette = _ember_palette() return config +static func sunken_ruins() -> DungeonConfig: + var config := DungeonConfig.new() + config.id = &"sunken_ruins" + config.display_name = "Sunken Ruins" + config.description = "Flooded halls of old stone. Wide winding routes cross dark, drowned chambers." + config.width = 54 + config.height = 34 + config.room_count_min = 6 + config.room_count_max = 9 + config.room_min = 7 + config.room_max = 12 + config.corridor_style = DungeonConfig.CorridorStyle.winding + config.loop_chance = 0.45 + config.door_count_min = 2 + config.door_count_max = 3 + config.monster_density = 0.55 + config.monster_cap = 11 + config.monster_table = [ + { "monster": &"brine", "weight": 2.0 }, + { "monster": &"shambler", "weight": 1.5 }, + { "monster": &"crawler", "weight": 1.0 }, + ] + config.starting_health = 100 + config.player_damage = 13 + config.palette = _sunken_palette() + return config + static func _crypt_palette() -> Dictionary: return { &"wall_outline": Color("#14161d"), @@ -150,3 +177,20 @@ static func _ember_palette() -> Dictionary: &"glow": Color("#ffcf6a"), &"accent": Color("#e8b84c"), } + +static func _sunken_palette() -> Dictionary: + return { + &"wall_outline": Color("#0a1a1c"), + &"wall_fill": Color("#173a3e"), + &"wall_shade": Color("#112c2f"), + &"wall_highlight": Color("#20545a"), + &"floor_base": Color("#143236"), + &"floor_dark": Color("#10282b"), + &"floor_light": Color("#1a4045"), + &"door_bar": Color("#2c5a5e"), + &"door_lock": Color("#9fe8d9"), + &"door_open": Color("#050f10"), + &"rune": Color("#7fd0ff"), + &"glow": Color("#9fe8ff"), + &"accent": Color("#9fe8d9"), + } diff --git a/scripts/main.gd b/scripts/main.gd index 9c87a13..c30aad2 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -41,6 +41,7 @@ func _wire_signals() -> void: player.hp_changed.connect(hud.set_hp) player.coins_changed.connect(hud.set_coins) player.keys_changed.connect(hud.set_keys) + player.upgrades_changed.connect(hud.set_upgrades) player.attacked.connect(_on_player_attack) player.died.connect(_on_player_died) player.moved.connect(_on_player_moved) diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 26a9113..742721b 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -12,6 +12,7 @@ var hp_fill: ColorRect = null var hp_label: Label = null var coin_label: Label = null var key_label: Label = null +var upgrade_label: Label = null var minimap_texture: TextureRect = null var minimap_marker: ColorRect = null var _minimap_frame: PanelContainer = null @@ -45,6 +46,18 @@ func set_coins(p_count: int) -> void: func set_keys(p_count: int) -> void: key_label.text = "x " + str(p_count) +## Shows the permanent stat bonuses from picked-up upgrades. +func set_upgrades(p_bonus_damage: int, p_bonus_max_health: int) -> void: + if p_bonus_damage == 0 and p_bonus_max_health == 0: + upgrade_label.text = "" + return + var parts := PackedStringArray() + if p_bonus_damage > 0: + parts.append("Atk +%d" % p_bonus_damage) + if p_bonus_max_health > 0: + parts.append("HP +%d" % p_bonus_max_health) + upgrade_label.text = " ".join(parts) + func set_minimap(p_image: Image, p_width: int, p_height: int) -> void: _map_width = p_width _map_height = p_height @@ -107,6 +120,9 @@ func _build_bottom_panel() -> void: loot_row.add_child(_icon_counter(&"coin")) loot_row.add_child(_icon_counter(&"key")) hp_box.add_child(loot_row) + + upgrade_label = _label("", 13) + hp_box.add_child(upgrade_label) add_child(hp_panel) func _build_minimap() -> void: diff --git a/scripts/world/tile_art.gd b/scripts/world/tile_art.gd index 2b0115a..7bc8264 100644 --- a/scripts/world/tile_art.gd +++ b/scripts/world/tile_art.gd @@ -18,8 +18,9 @@ const TILE_START := &"start" const TILE_EXIT := &"exit" const ENTITY_KEYS := [ - &"player", &"key", &"potion", &"coin", - &"skeleton", &"crawler", &"wisp", &"shambler", &"golem", + &"player", &"key", &"potion", &"coin", &"shard", + &"skeleton", &"crawler", &"wisp", &"shambler", &"golem", &"brine", + &"whetstone", &"relic", ] static var _tile_cache: Dictionary = {} @@ -90,12 +91,20 @@ static func _entity_pattern(p_key: StringName) -> Array: return _SHAMBLER &"golem": return _GOLEM + &"brine": + return _BRINE &"key": return _KEY &"potion": return _POTION &"coin": return _COIN + &"shard": + return _SHARD + &"whetstone": + return _WHETSTONE + &"relic": + return _RELIC _: return _PLAYER @@ -116,12 +125,20 @@ static func _entity_palette(p_key: StringName) -> Dictionary: return { &"@": Color("#4f7a3f"), &"*": Color("#16240f"), &"+": Color("#6f9a58") } &"golem": return { &"@": Color("#8a6a52"), &"*": Color("#2a1f18"), &"+": Color("#b09070") } + &"brine": + return { &"@": Color("#4f9e9e"), &"*": Color("#1a2a2e"), &"+": Color("#a5e8e8"), &"o": Color("#e8e8c0") } &"key": return { &"o": Color("#e8b84c"), &"@": Color("#b8860b"), &"+": Color("#fff0b0") } &"potion": return { &"@": Color("#c94a4a"), &"+": Color("#e88a7a"), &"o": Color("#3a1a1a") } &"coin": return { &"@": Color("#e8b84c"), &"+": Color("#fff0b0") } + &"shard": + return { &"@": Color("#7fd0ff"), &"+": Color("#d0f4ff"), &"*": Color("#274a5e") } + &"whetstone": + return { &"@": Color("#9aa4b8"), &"*": Color("#4a5268"), &"+": Color("#d6dce8") } + &"relic": + return { &"@": Color("#9fe8d9"), &"*": Color("#2a6a5e"), &"+": Color("#e8fff8"), &"o": Color("#e8b84c") } _: return { &"@": Color.WHITE } @@ -305,3 +322,47 @@ const _COIN := [ ".@@@@@@.", "........", ] + +const _SHARD := [ + "........", + "....*...", + "..*@@...", + "..@@@@..", + "..@@@@@.", + "..@@@@..", + "....*...", + "........", +] + +const _BRINE := [ + "........", + "..@@@...", + ".@*o*@..", + "@@@@@@@.", + ".@@@@@@.", + ".@.+..@.", + ".@.+.@..", + "........", +] + +const _WHETSTONE := [ + "........", + "...++++.", + "..@@@@@.", + "..@@*@@.", + "..@@@*@.", + "..@@@@*.", + "........", + "........", +] + +const _RELIC := [ + "........", + ".@@o@@..", + ".@*+*@..", + ".@+o+@..", + ".@@o@@..", + ".@o@o@..", + "........", + "........", +] diff --git a/tests/integration/test_full_run.gd b/tests/integration/test_full_run.gd index 1aaf900..07c8e45 100644 --- a/tests/integration/test_full_run.gd +++ b/tests/integration/test_full_run.gd @@ -56,3 +56,20 @@ 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_sunken_ruins_stays_solvable_across_seeds() -> void: + var generator := DungeonGenerator.new() + for seed in range(1, 31): + var result := generator.generate(Biomes.sunken_ruins(), seed) + assert_true(result.solvable, "sunken_ruins seed %d is not solvable" % seed) + +func test_upgrade_items_roll_from_monster_drops() -> void: + var rng := SeededRng.new(31337) + var found := { &"whetstone": false, &"relic": false } + for i in 400: + for spec in MonsterSpecs.all(): + for drop in MonsterSpecs.roll_drops(spec, rng): + if Items.is_upgrade(drop.item): + found[drop.item] = true + assert_true(found[&"whetstone"], "whetstone never rolled") + assert_true(found[&"relic"], "relic never rolled") diff --git a/tests/unit/test_biomes.gd b/tests/unit/test_biomes.gd index 8a33804..253ebb1 100644 --- a/tests/unit/test_biomes.gd +++ b/tests/unit/test_biomes.gd @@ -29,6 +29,22 @@ func test_biomes_use_different_generation_rules() -> void: assert_ne(crypt.room_count_max, ember.room_count_max) assert_ne(crypt.loop_chance, forest.loop_chance) +func test_sunken_ruins_is_a_distinct_biome() -> void: + var ruins := Biomes.sunken_ruins() + assert_true(ruins.is_valid()) + assert_eq(ruins.id, &"sunken_ruins") + assert_eq(ruins.corridor_style, DungeonConfig.CorridorStyle.winding) + assert_ne(ruins.loop_chance, Biomes.crypt().loop_chance) + assert_ne(ruins.room_max, Biomes.ember_stronghold().room_max) + assert_gte(Biomes.all().size(), 4) + +func test_sunken_ruins_builds_solvable_dungeons() -> void: + var generator := DungeonGenerator.new() + for seed in [601, 602, 603, 604, 605]: + var result := generator.generate(Biomes.sunken_ruins(), seed) + assert_true(result.solvable, "seed %d is not solvable" % seed) + assert_true(result.depth > 0) + func test_same_seed_different_biome_differs() -> void: var seed := 2468 var crypt_map := DungeonGenerator.new().generate(Biomes.crypt(), seed) diff --git a/tests/unit/test_drop_table.gd b/tests/unit/test_drop_table.gd index 3706563..9aa6102 100644 --- a/tests/unit/test_drop_table.gd +++ b/tests/unit/test_drop_table.gd @@ -52,3 +52,14 @@ func test_every_monster_drop_table_is_balanced() -> void: var table := spec.drop_table assert_gt(table.probability_of(&"coin"), table.probability_of(&"potion"), spec.id) assert_lte(table.probability_of(&"potion"), 0.35, spec.id) + +func test_upgrade_items_are_rarer_than_coins() -> void: + for spec in MonsterSpecs.all(): + var coin_prob := spec.drop_table.probability_of(&"coin") + assert_gt(coin_prob, spec.drop_table.probability_of(&"whetstone"), spec.id) + assert_gt(coin_prob, spec.drop_table.probability_of(&"relic"), spec.id) + +func test_brine_drop_table_carries_both_upgrades() -> void: + var table := MonsterSpecs.by_id(&"brine").drop_table + assert_gt(table.probability_of(&"relic"), 0.0) + assert_gt(table.probability_of(&"whetstone"), 0.0) diff --git a/tests/unit/test_items.gd b/tests/unit/test_items.gd new file mode 100644 index 0000000..6584507 --- /dev/null +++ b/tests/unit/test_items.gd @@ -0,0 +1,57 @@ +extends GutTest +## The item registry and the permanent upgrade effects. + +const KNOWN_IDS := [ + &"coin", &"shard", &"potion", &"key", &"whetstone", &"relic", +] + +func test_registry_holds_every_item_kind() -> void: + var ids := [] + for item in Items.all(): + ids.append(item.id) + for expected in KNOWN_IDS: + assert_true(ids.has(expected), "%s is missing from the registry" % expected) + +func test_every_item_has_a_name_description_and_art() -> void: + for item in Items.all(): + assert_false(item.display_name.is_empty(), item.id) + assert_false(item.description.is_empty(), item.id) + assert_true(TileArt.has_entity(item.sprite_key), "%s has no sprite" % item.id) + +func test_by_id_falls_back_to_coins() -> void: + assert_true(Items.has(&"relic")) + assert_eq(Items.by_id(&"not_an_item").id, &"coin") + +func test_upgrade_category_detection() -> void: + assert_true(Items.is_upgrade(&"whetstone")) + assert_true(Items.is_upgrade(&"relic")) + assert_false(Items.is_upgrade(&"coin")) + assert_false(Items.is_upgrade(&"potion")) + +func test_whetstone_raises_damage() -> void: + var stats := CombatStats.make({ "max_health": 50, "health": 50, "damage": 10 }) + var change := Items.apply_upgrade(&"whetstone", stats) + assert_eq(change.get("damage", 0), 2) + assert_eq(stats.damage, 12) + assert_eq(stats.max_health, 50) + +func test_relic_raises_max_health() -> void: + var stats := CombatStats.make({ "max_health": 50, "health": 50 }) + var change := Items.apply_upgrade(&"relic", stats) + assert_eq(change.get("max_health", 0), 10) + assert_eq(stats.max_health, 60) + assert_eq(stats.health, 60) + +func test_relic_heals_part_of_the_gap() -> void: + var stats := CombatStats.make({ "max_health": 50, "health": 20 }) + var change := Items.apply_upgrade(&"relic", stats) + assert_eq(stats.max_health, 60) + assert_eq(stats.health, 30) + assert_eq(change.get("health", 0), 10) + +func test_unknown_item_changes_nothing() -> void: + var stats := CombatStats.make({ "max_health": 50, "health": 50, "damage": 10 }) + var change := Items.apply_upgrade(&"nope", stats) + assert_eq(change.size(), 0) + assert_eq(stats.damage, 10) + assert_eq(stats.max_health, 50) diff --git a/tests/unit/test_items.gd.uid b/tests/unit/test_items.gd.uid new file mode 100644 index 0000000..2224bd5 --- /dev/null +++ b/tests/unit/test_items.gd.uid @@ -0,0 +1 @@ +uid://v6ijbmxwesw1 diff --git a/tools/smoke.gd b/tools/smoke.gd index dc1d06d..dabb95e 100644 --- a/tools/smoke.gd +++ b/tools/smoke.gd @@ -47,12 +47,33 @@ func _verify() -> void: _fail("exit is not reachable from the start") elif not _main.run.solvable: _fail("dungeon is not solvable") + else: + _verify_sunken_ruins() + _verify_upgrades() + +func _verify_sunken_ruins() -> void: + var result := DungeonGenerator.new().generate(Biomes.sunken_ruins(), 77) + if result == null or not result.solvable: + _fail("sunken_ruins seed 77 is not solvable") + elif result.map == null: + _fail("sunken_ruins produced no map") + +func _verify_upgrades() -> void: + var stats := CombatStats.make({ "max_health": 40, "health": 30, "damage": 8 }) + Items.apply_upgrade(&"whetstone", stats) + Items.apply_upgrade(&"relic", stats) + if stats.damage != 10: + _fail("whetstone did not raise damage") + elif stats.max_health != 50: + _fail("relic did not raise max health") + elif stats.health != 40: + _fail("relic did not heal toward the new max") 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 a solvable dungeon, sunken ruins solvable, upgrades apply.") quit(0) func _fail(p_message: String) -> void: