Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions config/blessings.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
}
15 changes: 15 additions & 0 deletions localization/ui_strings.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
34 changes: 34 additions & 0 deletions scripts/autoload/game_coordinator.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions scripts/core/autoload_helper.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions scripts/core/blessing_system.gd
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading