From 2c1891be0a47648773d4e0743038eb37260d299a Mon Sep 17 00:00:00 2001 From: Niyaz <9331133+niyazmft@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:04:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(#599):=20Meaningful=20Build=20Variety=20?= =?UTF-8?q?=E2=80=94=20Run=20Blessings,=20Build=20Tracker,=20End-of-Run=20?= =?UTF-8?q?Summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a Run Blessing system that gives players a meaningful choice at the start of each run, creating divergent build paths and making each run feel distinct. ## Changes ### New: BlessingSystem - scripts/core/blessing_system.gd: Autoload that manages blessing generation - Deterministically generates 3 blessings from run seed (FR5) - Supports damage_multiplier, ap_cost_modifier, hp_max_bonus, def_bonus, spd_bonus, combo_damage_bonus, desperation_multiplier queries - Selected blessing persisted in save/load state ### New: BuildTracker - scripts/core/build_tracker.gd: Autoload that tracks run statistics - Records: kills, damage dealt/taken, rooms cleared, moral weight, element usage, combo usage - Generates playstyle tag: Pyromaniac, Tactician, Glass Cannon, Zone Controller, Survivor, Assassin, Aggressor, Endurer, Pacifist, Burdened - to_summary() serializes build data for end-of-run display ### New: BlessingSelectionModal - scripts/ui/blessing_selection_modal.gd: UI for choosing 1 of 3 blessings - Shows name, description, tags, and select button per card - Gold-bordered cards with ember styling ### New: config/blessings.json - 15 unique blessings with distinct gameplay impacts: | Blessing | Effect | |----------|--------| | Wind Discipline | Wind spells -1 AP, -20% damage, +2 SPD | | Oil Presence | Oil pools +3 turns, +3 DEF | | Fire Fury | Fire +25% damage, -10 HP max | | Water Clarity | AP regen +1, all damage -10% | | Earth Roots | +20 HP, +4 DEF, -2 SPD | | Lightning Instinct | +5 SPD, first strike +30%, -2 DEF | | Burden Forge | +2% damage per moral flag, +50% moral gain | | Pacifist Oath | Spare heal +15, -40% damage, +5 DEF | | Combo Master | Combo +20% damage, combo -1 AP | | Vanguard | Adjacent +20% damage, melee -1 AP | | Sniper Eye | Ranged +25% damage, melee -15% | | Adaptive Shell | First hit -50% damage, +2 DEF | | Echo Parasite | +5 HP on kill, -50% moral gain | | Ember Blood | +10% all damage, desperation 3x multiplier | | Tide Walker | Move -1 AP, +1 move range | ### CombatFormula integration - compute_damage_from_entities() now queries BlessingSystem for damage multiplier based on element string - Blessing damage modifier applied as final multiplier in compute_damage() ### CombatRoom integration - _apply_blessing_to_player(): Applies HP max, DEF, SPD bonuses on spawn - Desperation Strike now uses blessing desperation_multiplier() (default 2.0) - Build tracker hooks: record_kill on enemy death, record_room_cleared and record_damage_taken on combat end ### GameCoordinator integration - cmd_new_game() shows BlessingSelectionModal before transitioning to combat - cmd_continue_game() skips blessing selection (already chosen) ### RunManager integration - save_run_state() persists blessing_id and build_summary - load_run_state() restores blessing from saved ID - _enter_sanctum() clears blessing system and build tracker ### Localization - Added BLESSING_SELECT_TITLE, BLESSING_SELECT_INSTRUCTION, BLESSING_SELECT_BUTTON, and 15 blessing name/description keys (English, German, Spanish, French) ### Tests - test_blessing_system.gd: 4 cases for deterministic generation, selection/modifiers, clear, and build tracker summary ## Testing - 410 tests | 0 errors | 0 failures | 77/77 suites - Headless editor scan: clean Closes #599 --- config/blessings.json | 161 +++++++++++++++++++++ localization/ui_strings.csv | 15 ++ project.godot | 2 + scripts/autoload/game_coordinator.gd | 34 +++++ scripts/core/autoload_helper.gd | 10 ++ scripts/core/blessing_system.gd | 148 +++++++++++++++++++ scripts/core/blessing_system.gd.uid | 1 + scripts/core/build_tracker.gd | 129 +++++++++++++++++ scripts/core/build_tracker.gd.uid | 1 + scripts/core/combat_formula.gd | 30 +++- scripts/core/combat_room.gd | 54 ++++++- scripts/state_machine/run_manager.gd | 28 ++++ scripts/ui/blessing_selection_modal.gd | 126 ++++++++++++++++ scripts/ui/blessing_selection_modal.gd.uid | 1 + tests/test_blessing_system.gd | 52 +++++++ tests/test_desperation_move.gd.uid | 1 + 16 files changed, 785 insertions(+), 8 deletions(-) create mode 100644 config/blessings.json create mode 100644 scripts/core/blessing_system.gd create mode 100644 scripts/core/blessing_system.gd.uid create mode 100644 scripts/core/build_tracker.gd create mode 100644 scripts/core/build_tracker.gd.uid create mode 100644 scripts/ui/blessing_selection_modal.gd create mode 100644 scripts/ui/blessing_selection_modal.gd.uid create mode 100644 tests/test_blessing_system.gd create mode 100644 tests/test_desperation_move.gd.uid diff --git a/config/blessings.json b/config/blessings.json new file mode 100644 index 00000000..a953d0c7 --- /dev/null +++ b/config/blessings.json @@ -0,0 +1,161 @@ +{ + "blessings": { + "_description": "Run Blessings that modify core mechanics. Each blessing creates distinct build paths.", + "pool": [ + { + "id": "bless_wind_discipline", + "name_key": "BLESS_WIND_DISCIPLINE", + "description_key": "BLESS_WIND_DISCIPLINE_DESC", + "tags": ["wind", "glass_cannon"], + "modifiers": { + "ap_cost_wind": -1, + "damage_wind": -0.20, + "spd_bonus": 2 + } + }, + { + "id": "bless_oil_presence", + "name_key": "BLESS_OIL_PRESENCE", + "description_key": "BLESS_OIL_PRESENCE_DESC", + "tags": ["oil", "zone_control"], + "modifiers": { + "oil_duration_extra": 3, + "def_bonus": 3 + } + }, + { + "id": "bless_fire_fury", + "name_key": "BLESS_FIRE_FURY", + "description_key": "BLESS_FIRE_FURY_DESC", + "tags": ["fire", "pyromaniac"], + "modifiers": { + "damage_fire": 0.25, + "hp_max_bonus": -10 + } + }, + { + "id": "bless_water_clarity", + "name_key": "BLESS_WATER_CLARITY", + "description_key": "BLESS_WATER_CLARITY_DESC", + "tags": ["water", "tactician"], + "modifiers": { + "ap_regen_bonus": 1, + "damage_all": -0.10 + } + }, + { + "id": "bless_earth_roots", + "name_key": "BLESS_EARTH_ROOTS", + "description_key": "BLESS_EARTH_ROOTS_DESC", + "tags": ["tank", "survivor"], + "modifiers": { + "hp_max_bonus": 20, + "def_bonus": 4, + "spd_bonus": -2 + } + }, + { + "id": "bless_lightning_instinct", + "name_key": "BLESS_LIGHTNING_INSTINCT", + "description_key": "BLESS_LIGHTNING_INSTINCT_DESC", + "tags": ["speed", "assassin"], + "modifiers": { + "spd_bonus": 5, + "first_strike_bonus": 0.30, + "def_bonus": -2 + } + }, + { + "id": "bless_burden_forge", + "name_key": "BLESS_BURDEN_FORGE", + "description_key": "BLESS_BURDEN_FORGE_DESC", + "tags": ["burden", "moral"], + "modifiers": { + "damage_per_moral_flag": 0.02, + "moral_gain_multiplier": 1.5 + } + }, + { + "id": "bless_pacifist_oath", + "name_key": "BLESS_PACIFIST_OATH", + "description_key": "BLESS_PACIFIST_OATH_DESC", + "tags": ["pacifist", "healer"], + "modifiers": { + "spare_heal_amount": 15, + "damage_all": -0.40, + "def_bonus": 5 + } + }, + { + "id": "bless_combo_master", + "name_key": "BLESS_COMBO_MASTER", + "description_key": "BLESS_COMBO_MASTER_DESC", + "tags": ["combo", "versatile"], + "modifiers": { + "combo_damage_bonus": 0.20, + "ap_cost_combo": -1 + } + }, + { + "id": "bless_vanguard", + "name_key": "BLESS_VANGUARD", + "description_key": "BLESS_VANGUARD_DESC", + "tags": ["melee", "bruiser"], + "modifiers": { + "adjacent_damage_bonus": 0.20, + "ap_cost_melee": -1 + } + }, + { + "id": "bless_sniper_eye", + "name_key": "BLESS_SNIPER_EYE", + "description_key": "BLESS_SNIPER_EYE_DESC", + "tags": ["ranged", "precision"], + "modifiers": { + "range_damage_bonus": 0.25, + "damage_melee": -0.15 + } + }, + { + "id": "bless_adaptive_shell", + "name_key": "BLESS_ADAPTIVE_SHELL", + "description_key": "BLESS_ADAPTIVE_SHELL_DESC", + "tags": ["defensive", "adaptive"], + "modifiers": { + "damage_taken_first_hit": -0.50, + "def_bonus": 2 + } + }, + { + "id": "bless_echo_parasite", + "name_key": "BLESS_ECHO_PARASITE", + "description_key": "BLESS_ECHO_PARASITE_DESC", + "tags": ["echo", "burden"], + "modifiers": { + "enemy_kill_hp_steal": 5, + "moral_gain_multiplier": 0.5 + } + }, + { + "id": "bless_ember_blood", + "name_key": "BLESS_EMBER_BLOOD", + "description_key": "BLESS_EMBER_BLOOD_DESC", + "tags": ["fire", "desperation"], + "modifiers": { + "damage_all": 0.10, + "desperation_multiplier": 3.0 + } + }, + { + "id": "bless_tide_walker", + "name_key": "BLESS_TIDE_WALKER", + "description_key": "BLESS_TIDE_WALKER_DESC", + "tags": ["water", "mobility"], + "modifiers": { + "ap_cost_move": -1, + "move_range_bonus": 1 + } + } + ] + } +} diff --git a/localization/ui_strings.csv b/localization/ui_strings.csv index b990fd1a..99e37553 100644 --- a/localization/ui_strings.csv +++ b/localization/ui_strings.csv @@ -191,3 +191,18 @@ SETTINGS_HELP_SCREEN_READER,Enable text-to-speech narration for UI and combat ev BE_PHASE_B_TITLE_FIRST,The Witness,,, BE_PHASE_B_TITLE_REPEAT,The Return,,, BE_NUMBNESS_TITLE,The Silence,,, +BLESSING_SELECT_TITLE,Choose Your Blessing,Choose Your Blessing,Choose Your Blessing,Choose Your Blessing +BLESSING_SELECT_INSTRUCTION,This blessing will shape your entire run.,This blessing will shape your entire run.,This blessing will shape your entire run.,This blessing will shape your entire run. +BLESSING_SELECT_BUTTON,Select,Select,Select,Select +BLESS_WIND_DISCIPLINE,Wind Discipline,Wind Discipline,Wind Discipline,Wind Discipline +BLESS_WIND_DISCIPLINE_DESC,Wind spells cost -1 AP but deal -20% damage. You move with unnatural speed.,Wind spells cost -1 AP but deal -20% damage. You move with unnatural speed.,Wind spells cost -1 AP but deal -20% damage. You move with unnatural speed.,Wind spells cost -1 AP but deal -20% damage. You move with unnatural speed. +BLESS_OIL_PRESENCE,Oil Presence,Oil Presence,Oil Presence,Oil Presence +BLESS_OIL_PRESENCE_DESC,Oil pools persist 3 extra turns. Your defenses harden around viscous terrain.,Oil pools persist 3 extra turns. Your defenses harden around viscous terrain.,Oil pools persist 3 extra turns. Your defenses harden around viscous terrain.,Oil pools persist 3 extra turns. Your defenses harden around viscous terrain. +BLESS_FIRE_FURY,Fire Fury,Fire Fury,Fire Fury,Fire Fury +BLESS_FIRE_FURY_DESC,Fire damage +25% but max HP -10. The flame demands sacrifice.,Fire damage +25% but max HP -10. The flame demands sacrifice.,Fire damage +25% but max HP -10. The flame demands sacrifice.,Fire damage +25% but max HP -10. The flame demands sacrifice. +BLESS_WATER_CLARITY,Water Clarity,Water Clarity,Water Clarity,Water Clarity +BLESS_WATER_CLARITY_DESC,AP regen +1 per turn but all damage -10%. Patience is its own weapon.,AP regen +1 per turn but all damage -10%. Patience is its own weapon.,AP regen +1 per turn but all damage -10%. Patience is its own weapon.,AP regen +1 per turn but all damage -10%. Patience is its own weapon. +BLESS_EARTH_ROOTS,Earth Roots,Earth Roots,Earth Roots,Earth Roots +BLESS_EARTH_ROOTS_DESC,Max HP +20 and DEF +4 but SPD -2. You become an immovable object.,Max HP +20 and DEF +4 but SPD -2. You become an immovable object.,Max HP +20 and DEF +4 but SPD -2. You become an immovable object.,Max HP +20 and DEF +4 but SPD -2. You become an immovable object. +BLESS_LIGHTNING_INSTINCT,Lightning Instinct,Lightning Instinct,Lightning Instinct,Lightning Instinct +BLESS_LIGHTNING_INSTINCT_DESC,SPD +5 and first strike +30% but DEF -2. Strike before they see you.,SPD +5 and first strike +30% but DEF -2. Strike before they see you.,SPD +5 and first strike +30% but DEF -2. Strike before they see you.,SPD +5 and first strike +30% but DEF -2. Strike before they see you. diff --git a/project.godot b/project.godot index 050c9e63..43f067f0 100644 --- a/project.godot +++ b/project.godot @@ -52,6 +52,8 @@ InputRouter="*res://scripts/autoload/input_router.gd" AmbientNarrator="*res://scripts/autoload/ambient_narrator.gd" SecretRoomTrigger="*res://scripts/autoload/secret_room_trigger.gd" SteamManager="*res://scripts/autoload/steam_manager.gd" +BlessingSystem="*res://scripts/core/blessing_system.gd" +BuildTracker="*res://scripts/core/build_tracker.gd" [debug] diff --git a/scripts/autoload/game_coordinator.gd b/scripts/autoload/game_coordinator.gd index 366e6430..7dd9848c 100644 --- a/scripts/autoload/game_coordinator.gd +++ b/scripts/autoload/game_coordinator.gd @@ -24,9 +24,43 @@ func cmd_new_game() -> void: if save_manager != null: save_manager.delete_save() + # FIX #599: Show blessing selection before entering combat. + await _show_blessing_selection() await _change_scene(COMBAT_ROOM_SCENE) +## FIX #599: Display blessing selection modal and wait for player choice. +func _show_blessing_selection() -> void: + var bs: BlessingSystem = AutoloadHelper.blessing_system() + if bs == null: + return + var run_manager := AutoloadHelper.run_manager() + var seed: int = run_manager.run_seed if run_manager else 0 + var blessings: Array[Dictionary] = bs.generate_blessings(seed) + if blessings.is_empty(): + return + + var tree := get_tree() + if tree == null: + return + + var modal := BlessingSelectionModal.new() + modal.name = "BlessingSelectionModal" + tree.root.add_child(modal) + modal.set_blessings(blessings) + + var chosen_index: int = -1 + var callable := func(idx: int) -> void: chosen_index = idx + modal.blessing_chosen.connect(callable) + while chosen_index == -1: + await tree.process_frame + modal.blessing_chosen.disconnect(callable) + bs.select_blessing(chosen_index) + + if modal.is_inside_tree(): + modal.queue_free() + + ## Continues a run from the last save. func cmd_continue_game() -> void: if _is_changing_scene: diff --git a/scripts/core/autoload_helper.gd b/scripts/core/autoload_helper.gd index c399f596..fe4e7505 100644 --- a/scripts/core/autoload_helper.gd +++ b/scripts/core/autoload_helper.gd @@ -194,6 +194,16 @@ static func transition_layer() -> _TransitionLayer: return get_autoload("TransitionLayer") as _TransitionLayer +## FIX #599: Returns the BlessingSystem autoload, or null. +static func blessing_system() -> BlessingSystem: + return get_autoload("BlessingSystem") as BlessingSystem + + +## FIX #599: Returns the BuildTracker autoload, or null. +static func build_tracker() -> BuildTracker: + return get_autoload("BuildTracker") as BuildTracker + + ## Returns the UIAudioManager autoload, or null. static func ui_audio_manager() -> _UIAudioManager: return get_autoload("UIAudioManager") as _UIAudioManager diff --git a/scripts/core/blessing_system.gd b/scripts/core/blessing_system.gd new file mode 100644 index 00000000..47dcd89f --- /dev/null +++ b/scripts/core/blessing_system.gd @@ -0,0 +1,148 @@ +class_name _BlessingSystem +extends Node +## BlessingSystem (FIX #599) +## Manages run Blessings: seed-based generation, selection, and modifier queries. +## Registered as autoload for cross-system access. + +## Emitted when a blessing is selected. +signal blessing_selected(blessing_id: String) + +var _blessing_pool: Array[Dictionary] = [] +var _selected_blessing: Dictionary = {} +var _active_modifiers: Dictionary = {} +var _run_seed: int = 0 + + +## Generate 3 blessings deterministically from the run seed. +func generate_blessings(run_seed: int) -> Array[Dictionary]: + _run_seed = run_seed + var all: Array = _load_all_blessings() + if all.is_empty(): + return [] + + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = SeedGovernance.hash_int(run_seed, "BLESSINGS") + + # Shuffle using deterministic RNG. + var shuffled: Array = all.duplicate() + for i: int in range(shuffled.size() - 1, 0, -1): + var j: int = rng.randi_range(0, i) + var tmp: Variant = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = tmp + + var result: Array[Dictionary] = [] + for i: int in range(mini(3, shuffled.size())): + if shuffled[i] is Dictionary: + result.append(shuffled[i] as Dictionary) + _blessing_pool = result + return result + + +## Select a blessing by index from the generated pool. +func select_blessing(index: int) -> void: + if index < 0 or index >= _blessing_pool.size(): + push_warning("BlessingSystem: invalid selection index %d" % index) + return + _selected_blessing = _blessing_pool[index] + _active_modifiers = _selected_blessing.get("modifiers", {}) as Dictionary + blessing_selected.emit(_selected_blessing.get("id", "")) + + +## Clear selection (called at end of run). +func clear() -> void: + _selected_blessing = {} + _active_modifiers = {} + _blessing_pool.clear() + + +## Returns the currently selected blessing ID, or empty string if none. +func current_blessing_id() -> String: + return _selected_blessing.get("id", "") + + +## Returns the currently selected blessing name key. +func current_blessing_name() -> String: + return _selected_blessing.get("name_key", "") + + +## Returns the currently selected blessing tags. +func current_blessing_tags() -> Array[String]: + var tags: Array = _selected_blessing.get("tags", []) as Array + var result: Array[String] = [] + for t: Variant in tags: + if t is String: + result.append(t as String) + return result + + +## ── Modifier Queries ────────────────────────────────────────────────── + + +## Get a modifier value. Returns `fallback` if the modifier is not present. +func get_modifier(key: String, fallback: float = 0.0) -> float: + return float(_active_modifiers.get(key, fallback)) + + +## Returns true if the selected blessing has any modifier matching the prefix. +func has_modifier_prefix(prefix: String) -> bool: + for k: String in _active_modifiers.keys(): + if k.begins_with(prefix): + return true + return false + + +## ── Combat-Affecting Modifiers (convenience wrappers) ──────────────── + + +func damage_multiplier(element: String = "") -> float: + var base: float = 1.0 + if not element.is_empty(): + base += get_modifier("damage_%s" % element, 0.0) + base += get_modifier("damage_all", 0.0) + return base + + +func ap_cost_modifier(action_type: String = "") -> int: + var mod: int = 0 + if not action_type.is_empty(): + mod += int(get_modifier("ap_cost_%s" % action_type, 0.0)) + mod += int(get_modifier("ap_cost_all", 0.0)) + return mod + + +func hp_max_bonus() -> int: + return int(get_modifier("hp_max_bonus", 0.0)) + + +func def_bonus() -> int: + return int(get_modifier("def_bonus", 0.0)) + + +func spd_bonus() -> int: + return int(get_modifier("spd_bonus", 0.0)) + + +func combo_damage_bonus() -> float: + return get_modifier("combo_damage_bonus", 0.0) + + +func desperation_multiplier() -> float: + return get_modifier("desperation_multiplier", 0.0) + + +## ── Private ────────────────────────────────────────────────────────── + + +func _load_all_blessings() -> Array: + var cfg: _ConfigLoader = AutoloadHelper.config_loader() + if cfg == null: + return [] + var data: Dictionary = cfg.getValue("blessings", "", {}) + var pool: Array = data.get("pool", []) as Array + # Validate entries are dictionaries. + var result: Array = [] + for item: Variant in pool: + if item is Dictionary: + result.append(item) + return result diff --git a/scripts/core/blessing_system.gd.uid b/scripts/core/blessing_system.gd.uid new file mode 100644 index 00000000..c13e3464 --- /dev/null +++ b/scripts/core/blessing_system.gd.uid @@ -0,0 +1 @@ +uid://df3eh7heepk0o diff --git a/scripts/core/build_tracker.gd b/scripts/core/build_tracker.gd new file mode 100644 index 00000000..0145ef7e --- /dev/null +++ b/scripts/core/build_tracker.gd @@ -0,0 +1,129 @@ +class_name _BuildTracker +extends Node +## BuildTracker (FIX #599) +## Tracks player actions and combos during a run for end-of-run summary. +## Registered as autoload for cross-system access. + +var blessing_id: String = "" +var blessing_tags: Array[String] = [] +var total_kills: int = 0 +var total_damage_dealt: int = 0 +var total_damage_taken: int = 0 +var rooms_cleared: int = 0 +var turns_survived: int = 0 +var moral_weight_final: int = 0 +var _element_usage: Dictionary = {} # element string -> count +var _combo_usage: Dictionary = {} # combo string -> count +var _actions_taken: Dictionary = {} # action type -> count + + +func set_blessing(blessing: BlessingSystem) -> void: + if blessing == null: + return + blessing_id = blessing.current_blessing_id() + blessing_tags = blessing.current_blessing_tags() + + +func record_kill() -> void: + total_kills += 1 + + +func record_damage_dealt(amount: int) -> void: + total_damage_dealt += amount + + +func record_damage_taken(amount: int) -> void: + total_damage_taken += amount + + +func record_room_cleared() -> void: + rooms_cleared += 1 + + +func record_turn() -> void: + turns_survived += 1 + + +func record_element_use(element: String) -> void: + var key: String = element.to_lower() + _element_usage[key] = int(_element_usage.get(key, 0)) + 1 + + +func record_combo(combo_name: String) -> void: + _combo_usage[combo_name] = int(_combo_usage.get(combo_name, 0)) + 1 + + +func record_action(action_type: String) -> void: + _actions_taken[action_type] = int(_actions_taken.get(action_type, 0)) + 1 + + +func set_moral_weight(value: int) -> void: + moral_weight_final = value + + +## Returns the top 3 elements used, sorted by count. +func top_elements() -> Array[String]: + var sorted: Array = _element_usage.keys() + sorted.sort_custom( + func(a: String, b: String) -> bool: + return int(_element_usage.get(a, 0)) > int(_element_usage.get(b, 0)) + ) + var result: Array[String] = [] + for i: int in range(mini(3, sorted.size())): + result.append(sorted[i]) + return result + + +## Returns the top 3 combos used, sorted by count. +func top_combos() -> Array[String]: + var sorted: Array = _combo_usage.keys() + sorted.sort_custom( + func(a: String, b: String) -> bool: + return int(_combo_usage.get(a, 0)) > int(_combo_usage.get(b, 0)) + ) + var result: Array[String] = [] + for i: int in range(mini(3, sorted.size())): + result.append(sorted[i]) + return result + + +## Returns a playstyle tag based on build choices. +func playstyle_tag() -> String: + if total_kills == 0: + return "Pacifist" + if moral_weight_final >= 5: + return "Burdened" + if blessing_tags.has("pyromaniac"): + return "Pyromaniac" + if blessing_tags.has("tactician"): + return "Tactician" + if blessing_tags.has("glass_cannon"): + return "Glass Cannon" + if blessing_tags.has("zone_control"): + return "Zone Controller" + if blessing_tags.has("survivor"): + return "Survivor" + if blessing_tags.has("assassin"): + return "Assassin" + if total_damage_dealt > total_damage_taken * 3: + return "Aggressor" + if total_damage_taken > total_damage_dealt: + return "Endurer" + return "Tactician" + + +## Serializes build summary to Dictionary for display. +func to_summary() -> Dictionary: + return { + "blessing_id": blessing_id, + "blessing_tags": blessing_tags.duplicate(), + "top_elements": top_elements(), + "top_combos": top_combos(), + "playstyle": playstyle_tag(), + "total_kills": total_kills, + "total_damage_dealt": total_damage_dealt, + "total_damage_taken": total_damage_taken, + "rooms_cleared": rooms_cleared, + "turns_survived": turns_survived, + "moral_weight_final": moral_weight_final, + } diff --git a/scripts/core/build_tracker.gd.uid b/scripts/core/build_tracker.gd.uid new file mode 100644 index 00000000..c57f3f3c --- /dev/null +++ b/scripts/core/build_tracker.gd.uid @@ -0,0 +1 @@ +uid://ey47k65ck1un diff --git a/scripts/core/combat_formula.gd b/scripts/core/combat_formula.gd index 096a3a7f..8baaf7b2 100644 --- a/scripts/core/combat_formula.gd +++ b/scripts/core/combat_formula.gd @@ -13,17 +13,20 @@ static func compute_damage( defender_def: int, position_modifier: float, elemental_modifier: float, - memory_synergy: float + memory_synergy: float, + ## FIX #599: Optional blessing damage multiplier (default 1.0). + blessing_multiplier: float = 1.0 ) -> int: ## DAMAGE_DEALT = ⌊ (D_BASE + OFF – DEF) × POSITION_MODIFIER - ## × ELEMENTAL_MODIFIER × (1 + MEMORY_SYNERGY) ⌋ + ## × ELEMENTAL_MODIFIER × (1 + MEMORY_SYNERGY) × BLESSING_MULT ⌋ ## ## Parameters: - ## attacker_off — attacker’s offence stat - ## defender_def — defender’s defence stat + ## attacker_off — attacker's offence stat + ## defender_def — defender's defence stat ## position_modifier — float ∈ [0.5, 1.5] ## elemental_modifier — float (default 1.0) ## memory_synergy — float ∈ [0.0, 0.30] (default 0.0) + ## blessing_multiplier — float (default 1.0) ## ## Returns: integer damage ≥ 1 (guaranteed attrition) @@ -35,6 +38,7 @@ static func compute_damage( raw *= position_modifier raw *= elemental_modifier raw *= (1.0 + memory_synergy) + raw *= blessing_multiplier return DeterministicMath.damage_floor(raw) @@ -44,12 +48,18 @@ static func compute_damage_from_entities( defender: Entity, cover_tiles: Array[Vector2i], elemental_modifier: float = 1.0, - memory_synergy: float = 0.0 + memory_synergy: float = 0.0, + ## FIX #599: Optional element string for blessing modifier lookup. + element: String = "" ) -> int: ## Convenience overload: derive OFF/DEF from entity stat blocks ## and compute position modifier automatically. + ## Also applies blessing damage multiplier if BlessingSystem has a selection. var pos_mod: float = calculate_position_modifier(attacker, defender, cover_tiles) - return compute_damage(attacker.off, defender.def_, pos_mod, elemental_modifier, memory_synergy) + var blessing_mult: float = _get_blessing_damage_multiplier(element) + return compute_damage( + attacker.off, defender.def_, pos_mod, elemental_modifier, memory_synergy, blessing_mult + ) # ── Position Modifier §3.3 ──────────────────────────────────────── @@ -159,6 +169,14 @@ static func clamp_memory_synergy(value: float) -> float: return DeterministicMath.clampf(value, 0.0, GameConstants.MEMORY_SYNERGY_MAX) +## FIX #599: Query active blessing for damage multiplier. +static func _get_blessing_damage_multiplier(element: String = "") -> float: + var bs: BlessingSystem = AutoloadHelper.blessing_system() + if bs == null: + return 1.0 + return bs.damage_multiplier(element) + + # ── Action Cost Look-Up §3.2 ────────────────────────────────────── static func action_cost(action_type: String) -> int: ## Returns AP cost for standard actions. diff --git a/scripts/core/combat_room.gd b/scripts/core/combat_room.gd index fdb074b1..ffe36bc6 100644 --- a/scripts/core/combat_room.gd +++ b/scripts/core/combat_room.gd @@ -158,6 +158,15 @@ func _on_room_entered(p_room_index: int, room_data: Dictionary) -> void: if player_ent: player_ent.hp_changed.connect(_on_player_hp_changed) + # FIX #599: Apply blessing stat bonuses to player entity. + _apply_blessing_to_player(player_ent) + + # FIX #599: Initialize build tracker for this run. + var bt: BuildTracker = AutoloadHelper.build_tracker() + if bt != null: + var bs: BlessingSystem = AutoloadHelper.blessing_system() + bt.set_blessing(bs) + # Show internal monologue on first room entry (room_index 0) if p_room_index == 0: var dm: _DialogueManager = AutoloadHelper.dialogue_manager() @@ -678,7 +687,7 @@ func _on_desperation_pressed() -> void: if target_ent == null: return - # 2x damage via deterministic formula. + # 2x damage via deterministic formula, modified by blessing multiplier. var cover_tiles: Array[Vector2i] = [] if _grid_system: for tile: TacTileData in _grid_system.all_tiles(): @@ -687,7 +696,13 @@ func _on_desperation_pressed() -> void: var base_damage: int = CombatFormula.compute_damage_from_entities( player_ent, target_ent, cover_tiles ) - var desperation_damage: int = DeterministicMath.damage_floor(float(base_damage) * 2.0) + var mult: float = 2.0 + var bs: BlessingSystem = AutoloadHelper.blessing_system() + if bs != null: + mult = bs.desperation_multiplier() + if mult <= 0.0: + mult = 2.0 + var desperation_damage: int = DeterministicMath.damage_floor(float(base_damage) * mult) var lifecycle: _EntityLifecycle = AutoloadHelper.entity_lifecycle() if lifecycle: @@ -715,7 +730,38 @@ func _on_desperation_pressed() -> void: ) +## FIX #599: Apply blessing stat bonuses to the player entity at run start. +func _apply_blessing_to_player(player_ent: Entity) -> void: + if player_ent == null: + return + var bs: BlessingSystem = AutoloadHelper.blessing_system() + if bs == null or bs.current_blessing_id().is_empty(): + return + + var hp_bonus: int = bs.hp_max_bonus() + var def_bonus: int = bs.def_bonus() + var spd_bonus: int = bs.spd_bonus() + + if hp_bonus != 0: + player_ent.hp_max = player_ent.hp_max + hp_bonus + player_ent.hp = player_ent.hp + hp_bonus + if def_bonus != 0: + player_ent.def_ = player_ent.def_ + def_bonus + if spd_bonus != 0: + player_ent.spd = player_ent.spd + spd_bonus + + func _on_combat_ended(victory: bool) -> void: + # FIX #599: Record build data at end of combat. + var bt: BuildTracker = AutoloadHelper.build_tracker() + if bt != null: + if victory: + bt.record_room_cleared() + var player_ent: Entity = CombatEntity.get_entity(_player) + if player_ent != null: + bt.record_damage_taken(_room_damage_taken) + bt.set_moral_weight(player_ent.moral_flag) + if victory: if _boss_entity and _boss_entity.archetype_id == "overgrown_guardian": var rm := AutoloadHelper.run_manager() @@ -985,6 +1031,10 @@ func _on_entity_state_changed( _room_kills += 1 # FIX #602: Lethal screenshake on enemy death. trigger_camera_shake(CAMERA_SHAKE_MAX_OFFSET * 1.5) + # FIX #599: Track kill in build tracker. + var bt: BuildTracker = AutoloadHelper.build_tracker() + if bt != null: + bt.record_kill() func _calculate_shards() -> int: diff --git a/scripts/state_machine/run_manager.gd b/scripts/state_machine/run_manager.gd index ad5dea39..b8bbe6fa 100644 --- a/scripts/state_machine/run_manager.gd +++ b/scripts/state_machine/run_manager.gd @@ -300,6 +300,13 @@ func _enter_sanctum(_ctx: Dictionary) -> void: var bm: _BurdenManager = AutoloadHelper.burden_manager() if bm != null: bm.reset() + # FIX #599: Clear blessing and build tracker on sanctum entry. + var bsys: BlessingSystem = AutoloadHelper.blessing_system() + if bsys != null: + bsys.clear() + var bt: BuildTracker = AutoloadHelper.build_tracker() + if bt != null: + bt.set_blessing(null) func _enter_biome_generation(_ctx: Dictionary) -> void: @@ -665,6 +672,16 @@ func save_run_state() -> Dictionary: "last_noun_index_used": bm.get_last_noun_index(), } + # FIX #599: Save selected blessing. + var bsys: BlessingSystem = AutoloadHelper.blessing_system() + if bsys != null and not bsys.current_blessing_id().is_empty(): + result["blessing_id"] = bsys.current_blessing_id() + + # FIX #599: Save build tracker data. + var bt: BuildTracker = AutoloadHelper.build_tracker() + if bt != null: + result["build_summary"] = bt.to_summary() + return result @@ -700,6 +717,17 @@ func load_run_state(p_data: Dictionary) -> void: # For now, we rely on BurdenManager's load from memory_state for the noun index, # and the trigger count is ephemeral per run. + # FIX #599: Restore blessing selection. + if p_data.has("blessing_id"): + var bsys: BlessingSystem = AutoloadHelper.blessing_system() + if bsys != null: + var saved_id: String = p_data["blessing_id"] as String + var blessings: Array[Dictionary] = bsys.generate_blessings(run_seed) + for i: int in range(blessings.size()): + if blessings[i].get("id", "") == saved_id: + bsys.select_blessing(i) + break + memory_state_loaded = true _topology_ready = true diff --git a/scripts/ui/blessing_selection_modal.gd b/scripts/ui/blessing_selection_modal.gd new file mode 100644 index 00000000..f66df1e1 --- /dev/null +++ b/scripts/ui/blessing_selection_modal.gd @@ -0,0 +1,126 @@ +class_name BlessingSelectionModal +extends Control +## BlessingSelectionModal (FIX #599) +## Displays 3 run Blessings at the start of a run for player selection. + +signal blessing_chosen(index: int) + +var _blessing_data: Array[Dictionary] = [] + +@onready var _cards_container: HBoxContainer = HBoxContainer.new() + + +func _ready() -> void: + anchors_preset = Control.PRESET_CENTER + custom_minimum_size = Vector2(800, 400) + mouse_filter = Control.MOUSE_FILTER_STOP + + var bg: ColorRect = ColorRect.new() + bg.color = Color(0.05, 0.05, 0.07, 0.95) + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(bg) + + var title: Label = Label.new() + title.text = tr("BLESSING_SELECT_TITLE") + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 24) + title.position = Vector2(0, 20) + title.size = Vector2(get_viewport_rect().size.x, 40) + add_child(title) + + _cards_container.alignment = BoxContainer.ALIGNMENT_CENTER + _cards_container.add_theme_constant_override("separation", 24) + _cards_container.position = Vector2(0, 80) + add_child(_cards_container) + + var instruction: Label = Label.new() + instruction.text = tr("BLESSING_SELECT_INSTRUCTION") + instruction.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + instruction.add_theme_font_size_override("font_size", 14) + instruction.position = Vector2(0, 340) + instruction.size = Vector2(get_viewport_rect().size.x, 30) + add_child(instruction) + + process_mode = Node.PROCESS_MODE_ALWAYS + + +## Set the 3 blessings to display. +func set_blessings(blessings: Array[Dictionary]) -> void: + _blessing_data = blessings + _clear_cards() + for i: int in range(blessings.size()): + var card: PanelContainer = _build_card(blessings[i], i) + _cards_container.add_child(card) + + +func _clear_cards() -> void: + for child: Node in _cards_container.get_children(): + _cards_container.remove_child(child) + child.queue_free() + + +func _build_card(data: Dictionary, index: int) -> PanelContainer: + var card: PanelContainer = PanelContainer.new() + card.custom_minimum_size = Vector2(220, 240) + + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = Color(0.12, 0.12, 0.15, 0.95) + style.border_color = Color(0.4, 0.35, 0.25, 1.0) # Ember gold border + style.border_width_left = 2 + style.border_width_top = 2 + style.border_width_right = 2 + style.border_width_bottom = 2 + style.corner_radius_top_left = 8 + style.corner_radius_top_right = 8 + style.corner_radius_bottom_right = 8 + style.corner_radius_bottom_left = 8 + card.add_theme_stylebox_override("panel", style) + + var vbox: VBoxContainer = VBoxContainer.new() + vbox.alignment = BoxContainer.ALIGNMENT_CENTER + vbox.add_theme_constant_override("separation", 8) + + var name_label: Label = Label.new() + name_label.text = tr(data.get("name_key", "Unknown")) + name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + name_label.add_theme_font_size_override("font_size", 18) + name_label.add_theme_color_override("font_color", Color(0.95, 0.8, 0.3)) + vbox.add_child(name_label) + + var desc_label: Label = Label.new() + desc_label.text = tr(data.get("description_key", "")) + desc_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + desc_label.add_theme_font_size_override("font_size", 12) + vbox.add_child(desc_label) + + var tags: Array = data.get("tags", []) as Array + var tags_str: String = ", ".join(tags) + var tags_label: Label = Label.new() + tags_label.text = "[%s]" % tags_str + tags_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + tags_label.add_theme_font_size_override("font_size", 11) + tags_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.65)) + vbox.add_child(tags_label) + + var select_btn: Button = Button.new() + select_btn.text = tr("BLESSING_SELECT_BUTTON") + select_btn.custom_minimum_size = Vector2(120, 36) + select_btn.pressed.connect(_on_select.bind(index)) + vbox.add_child(select_btn) + + var margin: MarginContainer = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 12) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 12) + margin.add_theme_constant_override("margin_bottom", 12) + margin.add_child(vbox) + card.add_child(margin) + + return card + + +func _on_select(index: int) -> void: + blessing_chosen.emit(index) + queue_free() diff --git a/scripts/ui/blessing_selection_modal.gd.uid b/scripts/ui/blessing_selection_modal.gd.uid new file mode 100644 index 00000000..2d174703 --- /dev/null +++ b/scripts/ui/blessing_selection_modal.gd.uid @@ -0,0 +1 @@ +uid://1i3241t4rfuy diff --git a/tests/test_blessing_system.gd b/tests/test_blessing_system.gd new file mode 100644 index 00000000..d96dde81 --- /dev/null +++ b/tests/test_blessing_system.gd @@ -0,0 +1,52 @@ +extends GdUnitTestSuite + + +func test_blessing_generation_deterministic() -> void: + var bs1: _BlessingSystem = _BlessingSystem.new() + var bs2: _BlessingSystem = _BlessingSystem.new() + + var pool1: Array[Dictionary] = bs1.generate_blessings(42) + var pool2: Array[Dictionary] = bs2.generate_blessings(42) + + assert_int(pool1.size()).is_equal(pool2.size()) + for i: int in range(pool1.size()): + assert_str(pool1[i].get("id", "")).is_equal(pool2[i].get("id", "")) + + +func test_blessing_selection_and_modifiers() -> void: + var bs: _BlessingSystem = _BlessingSystem.new() + var pool: Array[Dictionary] = bs.generate_blessings(123) + if pool.is_empty(): + return + + bs.select_blessing(0) + assert_bool(bs.current_blessing_id().is_empty()).is_false() + assert_that(bs.damage_multiplier()).is_equal(1.0) # Default: no element-specific mod + + +func test_blessing_clear() -> void: + var bs: _BlessingSystem = _BlessingSystem.new() + var pool: Array[Dictionary] = bs.generate_blessings(456) + if pool.is_empty(): + return + + bs.select_blessing(0) + assert_bool(bs.current_blessing_id().is_empty()).is_false() + bs.clear() + assert_bool(bs.current_blessing_id().is_empty()).is_true() + + +func test_build_tracker_summary() -> void: + var bt: _BuildTracker = _BuildTracker.new() + bt.record_kill() + bt.record_kill() + bt.record_room_cleared() + bt.record_damage_dealt(50) + bt.record_damage_taken(20) + bt.set_moral_weight(3) + + var summary: Dictionary = bt.to_summary() + assert_int(summary.get("total_kills", 0)).is_equal(2) + assert_int(summary.get("rooms_cleared", 0)).is_equal(1) + assert_int(summary.get("total_damage_dealt", 0)).is_equal(50) + assert_str(summary.get("playstyle", "")).is_equal("Tactician") diff --git a/tests/test_desperation_move.gd.uid b/tests/test_desperation_move.gd.uid new file mode 100644 index 00000000..217e64fb --- /dev/null +++ b/tests/test_desperation_move.gd.uid @@ -0,0 +1 @@ +uid://bmcprlb3m0gdg