diff --git a/README.md b/README.md index 3834af6..6079e5a 100644 --- a/README.md +++ b/README.md @@ -171,19 +171,19 @@ XP-based progression tied to generator output. Research tracks level up automati { "save_format_version": 5, "currencies": { - "": { - "current": {"m": 1.0, "e": 0}, - "total": {"m": 1.0, "e": 0}, - "all_time": {"m": 1.0, "e": 0} - } + "": { + "current": {"m": 1.0, "e": 0}, + "total": {"m": 1.0, "e": 0}, + "all_time": {"m": 1.0, "e": 0} + } }, "generator_states": { - "": { - "owned": 0, - "purchased_count": 0, - "unlocked": false, - "available": false - } + "": { + "owned": 0, + "purchased_count": 0, + "unlocked": false, + "available": false + } }, "buff_levels": { "": 0 }, "buff_unlocked": { "": false }, diff --git a/TODO.md b/TODO.md index db244fc..3b1b8c2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,20 +1,20 @@ # TODO -## Ascension Buff Tree — Needs Review Before Implementation +## Ascension Buff Graph — Needs Review Before Implementation ### Goal -Add a permanent buff tree system: +Add a permanent buff graph system: - **Separate** from existing `GeneratorBuffData` (which resets on prestige). - **Permanent** — prestige does NOT reset ascension buff state. - **Unlocked** by spending ascension currency (earned during prestige). -- **Tree/DAG-structured** — nodes have prerequisites (parent_ids) that must be met before a node can be purchased. -- **Multi-level** — nodes can be one-shot unlocks (`max_level=1`) or repeatable upgrades (`max_level>1` or `-1` for infinite). +- **Graph/DAG-structured** — nodes have prerequisites (parent_ids) that must be met before a node can be purchased. +- **One-shot unlocks** — each node is either locked or unlocked. No repeatable levels. ### Data Structure #### `AscensionBuffNode` (Resource) -Single node in the tree. +Single node in the graph. | Field | Type | Notes | |-------|------|-------| @@ -22,17 +22,14 @@ Single node in the tree. | `display_name` | `String` | UI label | | `description` | `String` (multiline) | Tooltip | | `icon` | `Texture2D` | Visual | -| `parent_ids` | `Array[StringName]` | Prerequisites (DAG, not strict tree — multiple parents allowed) | +| `parent_ids` | `Array[StringName]` | Prerequisites (DAG — multiple parents allowed) | | `effect_type` | `enum EffectType` | What this buff does | -| `effect_value` | `float` | Base effect magnitude | -| `effect_per_level` | `float` | Extra effect per level beyond first (default 0.0) | +| `effect_value` | `float` | Effect magnitude | | `target_id` | `StringName` | Target generator/currency for type-specific effects (empty = global) | -| `cost_mantissa` | `float` | Ascension currency cost base | +| `cost_mantissa` | `float` | Ascension currency cost | | `cost_exponent` | `int` | | -| `cost_multiplier` | `float` | Cost scaling per level | -| `max_level` | `int` | How many times it can be upgraded (1 = one-time, -1 = infinite) | -| `tier` | `int` | Row in tree UI (visual grouping) | -| `x_position` | `float` | Column in tree UI | +| `tier` | `int` | Row in graph UI (visual grouping) | +| `x_position` | `float` | Column in graph UI | **Effect types** (extensible enum): @@ -47,7 +44,7 @@ RESEARCH_XP_MULTIPLIER → faster research leveling ``` #### `AscensionBuffCatalogue` (Resource) -Flat list of nodes. The tree emerges from `parent_ids`. Follows existing catalogue pattern (`BuffCatalogue`, `GoalCatalogue`, etc.). +Flat list of nodes. The graph emerges from `parent_ids`. Follows existing catalogue pattern (`BuffCatalogue`, `GoalCatalogue`, etc.). | Field | Type | |-------|------| @@ -57,7 +54,7 @@ Flat list of nodes. The tree emerges from `parent_ids`. Follows existing catalog ```gdscript @export var ascension_buff_catalogue: AscensionBuffCatalogue -var _ascension_buff_levels: Dictionary = {} # {StringName: int} — node_id → unlock count +var _ascension_buff_unlocked: Dictionary = {} # {StringName: bool} — node_id → unlocked ``` - Never cleared by `reset_for_prestige()` (unlike `_buff_levels` for generator buffs, which IS cleared). @@ -66,45 +63,46 @@ var _ascension_buff_levels: Dictionary = {} # {StringName: int} — node_id → ### Save Format - Bump `CURRENT_SAVE_FORMAT_VERSION` from 7 → 8. -- New key: `"ascension_buff_levels"` → `{ "node_id": level_int, ... }`. +- New key: `"ascension_buff_unlocked"` → array of unlocked node_id strings: `["strong_start", "efficiency"]`. ### API on LevelGameState | Method | Purpose | |--------|---------| -| `purchase_ascension_buff(buff_id) → bool` | Spend ascension currency, increment level, emit signal, save | -| `can_purchase_ascension_buff(buff_id) → bool` | All parent_ids met? Under max_level? Enough currency? | -| `get_ascension_buff_cost(buff_id) → BigNumber` | Cost for the NEXT level (scaled by `cost_multiplier`) | -| `get_ascension_buff_level(buff_id) → int` | Current unlock count | -| `get_ascension_buff_multiplier(effect_type, target_id) → float` | Walk tree, compute combined effect | -| `get_available_ascension_buffs() → Array[AscensionBuffNode]` | Nodes with prerequisites met | +| `purchase_ascension_buff(buff_id) → bool` | Spend ascension currency, unlock node, emit signal, save | +| `can_purchase_ascension_buff(buff_id) → bool` | All parent_ids unlocked? Not already unlocked? Enough currency? | +| `is_ascension_buff_unlocked(buff_id) → bool` | Check if node is unlocked | +| `get_ascension_buff_cost(buff_id) → BigNumber` | Fixed cost for the node | +| `get_ascension_buff_multiplier(effect_type, target_id) → float` | Walk graph, compute combined effect from unlocked nodes | +| `get_available_ascension_buffs() → Array[AscensionBuffNode]` | Nodes whose prerequisites are all met | ### Signals ```gdscript -signal ascension_buff_changed(buff_id: StringName, new_level: int) -signal ascension_currency_changed(new_unspent: BigNumber) +signal ascension_buff_unlocked(buff_id: StringName) ``` +Ascension currency balance changes are covered by the existing `currency_changed` signal — no separate signal needed. + ### Prestige Integration -No changes needed to `PrestigeManager._reset_all_buff_levels()` — it only touches `_buff_levels` (generator buffs), not `_ascension_buff_levels`. The new dictionary naturally survives the reset. +No changes needed to `PrestigeManager` — `_reset_all_buff_levels()` only touches `_buff_levels` (generator buffs), not `_ascension_buff_unlocked`. Ascension currency earning already emits `currency_changed` via `add_currency_by_id()`. The new dictionary naturally survives the reset. ### Purchase Flow ``` -Player clicks upgrade node +Player clicks unlock node → can_purchase_ascension_buff(node_id) - → All parent_ids at required level? (max_level for prerequisite, or just ≥1) - → Current level < max_level? + → All parent_ids unlocked? + → Not already unlocked? → Enough ascension currency? → YES: spend_currency_by_id(ascension_currency, cost) - → _ascension_buff_levels[node_id] += 1 - → emit ascension_buff_changed + → _ascension_buff_unlocked[node_id] = true + → emit ascension_buff_unlocked → save_game() ``` -### Example Tree Content (Illustrative) +### Example Graph Content (Illustrative) ``` Tier 0 (roots): @@ -127,15 +125,159 @@ Tier 2: |------|--------|---------| | `core/ascension/ascension_buff_node.gd` | Create | Node resource | | `core/ascension/ascension_buff_catalogue.gd` | Create | Catalogue resource | -| `core/ascension/ascension_buff_tree_panel.gd` | Create | UI panel (or integrate into prestige panel) | -| `core/level_game_state.gd` | Modify | Add catalogue ref, `_ascension_buff_levels`, purchase/query methods, save/load, signals | -| `core/prestige/prestige_manager.gd` | Modify (light) | Emit `ascension_currency_changed` on spend/earn | -| `docs/gyms/tiny_sword/ascension/*.tres` | Create | `.tres` resources for actual tree content | +| `core/ascension/ascension_buff_graph_panel.gd` | Create | Separate-screen UI panel | +| `core/level_game_state.gd` | Modify | Add catalogue ref, `_ascension_buff_unlocked`, purchase/query methods, save/load, signal | +| `docs/gyms/tiny_sword/ascension/*.tres` | Create | `.tres` resources for actual graph content | -### Open Questions for Review +--- -1. **Prerequisite level requirement**: Should a parent need to be at `max_level`, or is level 1 sufficient? (Leaning: `max_level` is required, otherwise the tree loses structural meaning.) -2. **Effect stacking**: For `GLOBAL_PRODUCTION_MULTIPLIER`, should separate nodes multiply together or add? (Leaning: multiply — consistent with the existing generator buff multiplier pattern.) -3. **UI placement**: Should the tree panel be a tab in the prestige panel, a separate screen, or inlined somewhere? -4. **Ascension currency unspent tracking**: Currently `PrestigeManager.current_prestige_unspent` tracks unspent prestige currency. Should the buff tree consume from this directly, or should ascension currency be a proper `Currency` in the catalogue that the tree spends via `LevelGameState.spend_currency_by_id()`? (Leaning: use `spend_currency_by_id()` for consistency — ascension currency is already added to the currency catalogue.) -5. **Content**: What actual buffs and tree shape should the first version ship with? +## Implementation Plan + +### Step 1: `core/ascension/ascension_buff_node.gd` + +Create new `Resource` class. + +- `class_name AscensionBuffNode extends Resource` +- `enum EffectType` with all seven types from the data structure table +- `@export` fields: `id`, `display_name`, `description` (multiline), `icon`, `parent_ids`, `effect_type`, `effect_value`, `target_id`, `cost_mantissa`, `cost_exponent`, `tier`, `x_position` +- Helper methods: + - `get_cost() → BigNumber` — returns `BigNumber.new(cost_mantissa, cost_exponent)` + - `has_parents() → bool` — returns `not parent_ids.is_empty()` + - `all_parents_unlocked(unlocked: Dictionary) → bool` — checks every id in `parent_ids` is in the dict as `true` + - `is_valid() → bool` — id not empty, cost_mantissa > 0 + +### Step 2: `core/ascension/ascension_buff_catalogue.gd` + +Create new `Resource` class. Follows `BuffCatalogue` pattern exactly. + +- `class_name AscensionBuffCatalogue extends Resource` +- `@export var nodes: Array[AscensionBuffNode] = []` +- `get_node_by_id(id: StringName) → AscensionBuffNode` — linear search +- `get_all_ids() → Array[StringName]` — collect non-empty ids +- `get_root_nodes() → Array[AscensionBuffNode]` — nodes with empty `parent_ids` +- `get_children_of(parent_id: StringName) → Array[AscensionBuffNode]` — nodes that list this id in `parent_ids` + +### Step 3: Modify `core/level_game_state.gd` + +**3a. Add export, state, signal, constants** + +- Add `@export var ascension_buff_catalogue: AscensionBuffCatalogue` in the export section +- Add `var _ascension_buff_unlocked: Dictionary = {}` in state variables +- Add signal: `signal ascension_buff_unlocked(buff_id: StringName)` in signals section +- Add constant: `const ASCENSION_BUFF_UNLOCKED_KEY: String = "ascension_buff_unlocked"` in save/load constants +- Bump `CURRENT_SAVE_FORMAT_VERSION` from 7 to 8 + +**3b. Add initialization in `_ready()`** + +After `_initialize_catalogues()`, call a new `_initialize_ascension_buffs()` method that: +- Iterates `ascension_buff_catalogue.nodes` (if catalogue is set) +- For each node id not already in `_ascension_buff_unlocked`, sets it to `false` + +**3c. Add save serialization** + +In `save_game()`, add: +```gdscript +ASCENSION_BUFF_UNLOCKED_KEY: _serialize_ascension_buff_unlocked(), +``` + +New private method `_serialize_ascension_buff_unlocked() → Array`: +- Returns an array of node id strings where `_ascension_buff_unlocked[id] == true` + +**3d. Add load deserialization** + +In `load_game()`, under the save format version check, add: +```gdscript +if parsed_data.has(ASCENSION_BUFF_UNLOCKED_KEY): + _deserialize_ascension_buff_unlocked(parsed_data.get(ASCENSION_BUFF_UNLOCKED_KEY)) +``` + +New private method `_deserialize_ascension_buff_unlocked(raw: Variant) → void`: +- If raw is Array, for each string element set `_ascension_buff_unlocked[StringName(s)] = true` +- Then call `_initialize_ascension_buffs()` to fill in any missing node ids as `false` + +**3e. Add API methods** + +In a new `#region ascension_buff` section: + +- `purchase_ascension_buff(buff_id: StringName) → bool` + - Guard: if not `can_purchase_ascension_buff(buff_id)`, return false + - Get node from catalogue, get cost via `BigNumber` from node's mantissa/exponent + - Call `spend_currency_by_id(ascension_currency_id, cost)` — use `get_ascension_currency_id()` helper + - Set `_ascension_buff_unlocked[buff_id] = true` + - Emit `ascension_buff_unlocked.emit(buff_id)` + - `save_game()` + - Return true + +- `can_purchase_ascension_buff(buff_id: StringName) → bool` + - Node must exist in catalogue + - Not already unlocked (`_ascension_buff_unlocked.get(buff_id, false) == false`) + - All parent_ids unlocked (use node's helper or iterate) + - Enough ascension currency (check via `get_currency_amount_by_id(ascension_currency_id)`) + +- `is_ascension_buff_unlocked(buff_id: StringName) → bool` + - `return _ascension_buff_unlocked.get(buff_id, false)` + +- `get_ascension_buff_cost(buff_id: StringName) → BigNumber` + - Get node, return `BigNumber.new(node.cost_mantissa, node.cost_exponent)` + +- `get_ascension_buff_multiplier(effect_type: int, target_id: StringName = &"") → float` + - Iterate all unlocked nodes matching the effect type + - Multiply their `effect_value` together + - If `target_id` is non-empty, filter nodes whose `target_id` matches or is empty (global) — TBD based on type semantics + - Return the product (starting from 1.0) + +- `get_available_ascension_buffs() → Array[AscensionBuffNode]` + - Iterate all catalogue nodes not yet unlocked where all parent_ids are unlocked + +- `get_ascension_currency_id() → StringName` (private helper or public) + - Look up the ascension currency ID from PrestigeManager's config, or use a well-known ID like `&"ascension"`. Prefer using the catalogue to determine it. For now, return `&"ascension"` and validate it exists in the currency catalogue. + +**3f. Ensure prestige does NOT reset ascension buffs** + +Verify `reset_for_prestige()` does not touch `_ascension_buff_unlocked`. It currently clears `_buff_levels` (generator buffs), `generator_states`, `research_xp`, `research_levels`, goals, etc. No change needed — `_ascension_buff_unlocked` is a separate dictionary that is not listed in the reset logic. + +### Step 4: Create `core/ascension/ascension_buff_graph_panel.gd` + +Create a new `PanelContainer`-based UI. This is a separate screen — it can be toggled visible/hidden (e.g., via a button in the main UI). + +- `class_name AscensionBuffGraphPanel extends PanelContainer` +- `@onready` references to: + - Title label + - `ScrollContainer`/`VBoxContainer` for tier rows + - Ascension currency balance label +- `_game_state: LevelGameState` — found via `find_parent("LevelGameState")` +- In `_ready()`: + - Connect `game_state.ascension_buff_unlocked` → refresh + - Connect `game_state.currency_changed` → refresh ascension currency label + - Call `_build_graph()` +- `_build_graph()`: + - Clear existing rows + - Group all catalogue nodes by `tier`, sort tiers ascending + - For each tier, create an `HBoxContainer` row + - For each node in the tier (sorted by `x_position`), instantiate a tile scene +- **Tile scene** (`ascension_buff_graph_tile.tscn` + `.gd`): + - A small `Button`/`Panel` showing: + - Node icon + - Node display_name + - Effect description (e.g., "+10% global production") + - Cost in ascension currency + - Visual state: locked (greyed out), available (highlighted, purchasable), unlocked (green check) + - On press: call `_game_state.purchase_ascension_buff(node_id)` + - Connect to `ascension_buff_unlocked` and `currency_changed` to re-evaluate state +- Ascension currency label at the top showing current balance + +### Step 5: Create content `.tres` files (deferred) + +Content is not defined yet per resolved question 4. When ready: +- Create `docs/gyms/tiny_sword/ascension/` directory +- Create `ascension_buff_catalogue.tres` resource with nodes +- Wire it into the `LevelGameState` node in `tiny_sword.tscn` via editor + +--- + +### Resolved Questions + +1. **Effect stacking**: Multiply — consistent with the existing generator buff multiplier pattern. +2. **UI placement**: A separate screen. +3. **Ascension currency spending**: Spend through `LevelGameState.spend_currency_by_id()` — ascension currency is already in the currency catalogue. +4. **Content**: None at the moment — define later. diff --git a/big_number_progress_bar.gd b/big_number_progress_bar.gd index ccda872..7480214 100644 --- a/big_number_progress_bar.gd +++ b/big_number_progress_bar.gd @@ -1,6 +1,6 @@ extends HBoxContainer -@export var currency: Resource +@export var currency: Currency @export var game_state: LevelGameState @onready var _label_start = $LabelStart diff --git a/core/README.md b/core/README.md index e0f6d8c..71ce681 100644 --- a/core/README.md +++ b/core/README.md @@ -87,8 +87,8 @@ Level is auto-calculated from accumulated BigNumber XP. Both constants are expor # For every active buff of kind AUTO_PRODUCTION_MULTIPLIER targeting this generator: mult = 1.0 for buff in buffs: - if buff.active and buff.kind == AUTO_PRODUCTION_MULTIPLIER: - mult *= buff.get_effect_multiplier(level) + if buff.active and buff.kind == AUTO_PRODUCTION_MULTIPLIER: + mult *= buff.get_effect_multiplier(level) ``` `GeneratorBuffData.get_effect_multiplier(level)` (`generator_buff_data.gd:97`): @@ -121,15 +121,15 @@ production_per_second = production_per_cycle / maxf(initial_time, 0.0001) ``` produced = initial_productivity × owned - # --- effective_run_multiplier --- - × run_multiplier - × (base_multiplier + multiplier_per_level × research_level) - × ∏(base_effect_i + effect_increment_i × buff_level_i) [auto-production buffs] - × prestige_multiplier [from PrestigeManager] + # --- effective_run_multiplier --- + × run_multiplier + × (base_multiplier + multiplier_per_level × research_level) + × ∏(base_effect_i + effect_increment_i × buff_level_i) [auto-production buffs] + × prestige_multiplier [from PrestigeManager] - # --- milestone & purchased --- - × milestone_multiplier ^ floor(owned / milestone_step) - × (1.0 + purchased_count × purchased_boost_percent / 100) + # --- milestone & purchased --- + × milestone_multiplier ^ floor(owned / milestone_step) + × (1.0 + purchased_count × purchased_boost_percent / 100) ``` ### Manual Click Formula (separate path) @@ -159,16 +159,16 @@ via `ResearchBuffCalculator.apply_buffs()` (`core/generator/research_buff_calcul ### Cascade Diagram ``` - ┌─────────────────────────────────┐ - │ run_multiplier (node export) │ - │ research_multiplier (XP level) │─── effective_run_multiplier ──┐ - │ auto-production buffs (∏) │ │ - │ prestige_multiplier │ │ - └─────────────────────────────────┘ │ - ▼ + ┌─────────────────────────────────┐ + │ run_multiplier (node export) │ + │ research_multiplier (XP level) │─── effective_run_multiplier ──┐ + │ auto-production buffs (∏) │ │ + │ prestige_multiplier │ │ + └─────────────────────────────────┘ │ + ▼ production_per_cycle = initial_productivity × owned × effective_run_multiplier × milestone × purchased - │ - ▼ + │ + ▼ production_per_second = production_per_cycle / initial_time ``` @@ -180,37 +180,37 @@ production_per_second = production_per_cycle / initial_time { "save_format_version": 5, "currencies": { - "": { - "current": {"m": float, "e": int}, - "total": {"m": float, "e": int}, - "all_time": {"m": float, "e": int} - } + "": { + "current": {"m": float, "e": int}, + "total": {"m": float, "e": int}, + "all_time": {"m": float, "e": int} + } }, "generator_states": { - "": { - "owned": int, - "purchased_count": int, - "unlocked": bool, - "available": bool - } + "": { + "owned": int, + "purchased_count": int, + "unlocked": bool, + "available": bool + } }, "buff_levels": { - "": int + "": int }, "buff_unlocked": { - "": true + "": true }, "buff_active": { - "": true + "": true }, "goals": { - "completed": ["", ...] + "completed": ["", ...] }, "research_xp": { - "": {"m": float, "e": int} + "": {"m": float, "e": int} }, "research_levels": { - "": int + "": int }, "last_save_time": unix_timestamp } diff --git a/core/big_number.gd b/core/big_number.gd index 92dda83..de29fc3 100644 --- a/core/big_number.gd +++ b/core/big_number.gd @@ -195,6 +195,16 @@ func get_ratio(target: BigNumber) -> float: static func from_float(val: float) -> BigNumber: return BigNumber.new(val, 0) +## Converts this BigNumber to a standard float. Returns INF or 0.0 for extreme values. +func to_float() -> float: + if mantissa <= 0.0: + return 0.0 + if exponent > 308: + return INF + if exponent < -308: + return 0.0 + return mantissa * pow(10.0, float(exponent)) + ## Outputs a UI-friendly string (e.g., "1.50e12") func to_string_sci(decimals: int = 2) -> String: if exponent < 3: diff --git a/core/generator/currency_generator.gd b/core/generator/currency_generator.gd index 3217597..d65432b 100644 --- a/core/generator/currency_generator.gd +++ b/core/generator/currency_generator.gd @@ -24,7 +24,7 @@ signal goal_achieved(generator_id: StringName, goal_id: StringName) const HUGE_COST_EXPONENT: int = 1000000 ## Currency target updated by this generator. -@export var currency: Resource +@export var currency: Currency ## Data resource containing balancing values and defaults (required). @export var data: CurrencyGeneratorData ## Enables per-cycle automatic production when true. @@ -218,7 +218,7 @@ func buy_max() -> int: if purchase_currency_id == &"": return 0 - var available_currency: float = _big_number_to_float(_get_currency_amount_by_id(purchase_currency_id)) + var available_currency: float = _get_currency_amount_by_id(purchase_currency_id).to_float() var estimated_max: int = data.max_affordable(owned, available_currency) if estimated_max <= 0: return 0 @@ -355,7 +355,7 @@ func get_automatic_production_multiplier() -> float: return _get_multiplier_for_buff_kind(GeneratorBuffData.BuffKind.AUTO_PRODUCTION_MULTIPLIER) func get_effective_auto_run_multiplier() -> float: - return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_buff_production_multiplier() func get_research_multiplier() -> float: if data == null or data.research_data == null: @@ -477,25 +477,10 @@ func _get_click_cooldown_seconds() -> float: func _grants_click_while_hovering() -> bool: return grants_click_while_hovering -func _get_prestige_multiplier() -> float: +func _get_prestige_buff_production_multiplier() -> float: if game_state == null: return 1.0 - - var parent: Node = get_parent() - if parent == null: - return 1.0 - - var prestige_manager: PrestigeManager = parent.find_child("PrestigeManager", true, false) - if prestige_manager == null: - return 1.0 - - var raw_multiplier: Variant = prestige_manager.get_total_multiplier() - if raw_multiplier is float: - return maxf(raw_multiplier, 0.0) - if raw_multiplier is int: - return maxf(float(raw_multiplier), 0.0) - - return 1.0 + return game_state.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, _generator_id) ## Returns this generator's resolved state id. func get_generator_id() -> StringName: @@ -580,20 +565,13 @@ func _float_to_big_number(value: float) -> BigNumber: return BigNumber.from_float(value) ## Converts BigNumber to float for approximate estimate math. -func _big_number_to_float(value: BigNumber) -> float: - if value.mantissa <= 0.0: - return 0.0 - if value.exponent > 308: - return INF - if value.exponent < -308: - return 0.0 - return value.mantissa * pow(10.0, float(value.exponent)) + func _resolve_buff_cost_currency_id(buff: GeneratorBuffData) -> StringName: if buff == null: return &"" - var cost_currency: Resource = buff.cost_currency if buff.cost_currency != null else currency + var cost_currency: Currency = buff.cost_currency if buff.cost_currency != null else currency if cost_currency == null: return &"" @@ -611,7 +589,7 @@ func _resolve_buff_target_currency_id(buff: GeneratorBuffData) -> StringName: if buff == null: return &"" - var target_currency: Resource = buff.resource_target_currency if buff.resource_target_currency != null else currency + var target_currency: Currency = buff.resource_target_currency if buff.resource_target_currency != null else currency if target_currency == null: return &"" diff --git a/core/generator/generator_container.gd b/core/generator/generator_container.gd index 9b6787e..e6e66b7 100644 --- a/core/generator/generator_container.gd +++ b/core/generator/generator_container.gd @@ -109,7 +109,7 @@ func _refresh_buff_rows(can_interact: bool) -> void: var buff_unlocked: bool = _generator.is_buff_unlocked(buff.id) if not buff_unlocked: var locked_hint: String = "Locked" - var unlock_goal_currency: Resource = buff.get_unlock_goal_currency() + var unlock_goal_currency: Currency = buff.get_unlock_goal_currency() if buff.has_unlock_goal() and unlock_goal_currency != null and _generator.game_state: var goal_currency_id: StringName = _generator.game_state.get_currency_id(unlock_goal_currency) var currency_name: String = _generator.game_state.get_currency_name(goal_currency_id) diff --git a/core/level_game_state.gd b/core/level_game_state.gd index af9aad0..672ad87 100644 --- a/core/level_game_state.gd +++ b/core/level_game_state.gd @@ -27,6 +27,8 @@ signal research_level_up(research_id: StringName, old_level: int, new_level: int signal worker_count_changed(currency_id: StringName, new_count: int) ## Emitted when research workers change. signal research_workers_changed(new_count: int) +## Emitted when an prestige buff node is unlocked. +signal prestige_buff_unlocked(buff_id: StringName) # ============================================================================== # EXPORTED REFERENCES @@ -39,6 +41,8 @@ signal research_workers_changed(new_count: int) @export var goal_catalogue: GoalCatalogue ## Catalog of all defined research tracks in the game. @export var research_catalogue: ResearchCatalogue +## Catalog of all prestige buff nodes in the graph. +@export var prestige_buff_catalogue: PrestigeBuffCatalogue ## File path for save/load persistence. Defaults to user directory. @export var save_file_path: String = "user://level_save.json" @@ -78,6 +82,8 @@ var research_xp: Dictionary = {} var research_levels: Dictionary = {} ## Workers assigned to research (total across all tracks). var research_workers: int = 0 +## Ascension buff unlock state: maps node_id (StringName) → bool (true if unlocked). Persists across prestige. +var _prestige_buff_unlocked: Dictionary = {} ## Unix timestamp of last save. var last_save_time: int = 0 @@ -88,7 +94,7 @@ var last_save_time: int = 0 ## JSON key for save format version. const SAVE_FORMAT_VERSION_KEY: String = "save_format_version" ## Current save format version (7 includes research workers tracking). -const CURRENT_SAVE_FORMAT_VERSION: int = 7 +const CURRENT_SAVE_FORMAT_VERSION: int = 8 ## JSON key for generator owned count. const GENERATOR_OWNED_KEY: String = "owned" ## JSON key for generator purchased count. @@ -128,6 +134,8 @@ const RESEARCH_XP_KEY: String = "research_xp" const RESEARCH_LEVELS_KEY: String = "research_levels" ## JSON key for research workers section. const RESEARCH_WORKERS_KEY: String = "research_workers" +## JSON key for prestige buff unlocked array. +const PRESTIGE_BUFF_UNLOCKED_KEY: String = "prestige_buff_unlocked" ## Reference to the PrestigeManager autoload node (set in _ready). var prestige_manager: PrestigeManager @@ -139,6 +147,7 @@ func _ready() -> void: _initialize_currency_maps() _initialize_catalogues() + _initialize_prestige_buffs() _load_catalogue_goals() load_game() _try_unlock_all_buffs_from_goals() @@ -184,7 +193,7 @@ func get_currency_name(currency_id: StringName) -> String: ## Returns the icon texture for a currency ID, or null if not found. func get_currency_icon(currency_id: StringName) -> Texture2D: - var currency: Resource = get_currency_resource(currency_id) + var currency: Currency = get_currency_resource(currency_id) if currency == null: return null var raw_icon: Variant = currency.icon @@ -627,6 +636,7 @@ func save_game() -> void: RESEARCH_XP_KEY: _serialize_research_xp(), RESEARCH_LEVELS_KEY: research_levels.duplicate(), RESEARCH_WORKERS_KEY: research_workers, + PRESTIGE_BUFF_UNLOCKED_KEY: _serialize_prestige_buff_unlocked(), LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system() } @@ -682,11 +692,13 @@ func load_game() -> void: research_workers = int(parsed_data.get(RESEARCH_WORKERS_KEY, 0)) last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system()) + if parsed_data.has(PRESTIGE_BUFF_UNLOCKED_KEY): + _deserialize_prestige_buff_unlocked(parsed_data.get(PRESTIGE_BUFF_UNLOCKED_KEY)) for save_key_variant in parsed_data.keys(): var save_key: String = String(save_key_variant) if save_key.is_empty(): continue - if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, RESEARCH_XP_KEY, RESEARCH_LEVELS_KEY, RESEARCH_WORKERS_KEY, LAST_SAVE_TIME_KEY]: + if save_key in [SAVE_FORMAT_VERSION_KEY, CURRENCIES_KEY, GENERATOR_STATES_KEY, BUFF_LEVELS_KEY, BUFF_UNLOCKED_KEY, BUFF_ACTIVE_KEY, GOALS_KEY, RESEARCH_XP_KEY, RESEARCH_LEVELS_KEY, RESEARCH_WORKERS_KEY, PRESTIGE_BUFF_UNLOCKED_KEY, LAST_SAVE_TIME_KEY]: continue var section_payload: Variant = parsed_data.get(save_key_variant) @@ -819,6 +831,16 @@ func _initialize_catalogues() -> void: continue register_goal(goal) +## Internal helper: Initializes prestige buff unlock state from the catalogue, filling missing node IDs as false. +func _initialize_prestige_buffs() -> void: + if prestige_buff_catalogue == null: + return + for node in prestige_buff_catalogue.nodes: + if node == null or node.id == &"": + continue + if not _prestige_buff_unlocked.has(node.id): + _prestige_buff_unlocked[node.id] = false + ## Internal helper: Loads goal definitions from the catalogue. func _load_catalogue_goals() -> void: if goal_catalogue: @@ -1297,6 +1319,99 @@ func _try_unlock_all_buffs_from_goals() -> void: +# ============================================================================== +# PRESTIGE BUFF API +# ============================================================================== + +## Returns the ascension currency ID used for purchasing buff nodes. +func get_prestige_currency_id() -> StringName: + return &"ascension" + +## Attempts to purchase and unlock an prestige buff node. Returns true on success. +func purchase_prestige_buff(buff_id: StringName) -> bool: + if not can_purchase_prestige_buff(buff_id): + return false + + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return false + + var cost: BigNumber = node.get_cost() + var currency_id: StringName = get_prestige_currency_id() + if not spend_currency_by_id(currency_id, cost): + return false + + _prestige_buff_unlocked[buff_id] = true + prestige_buff_unlocked.emit(buff_id) + save_game() + return true + +## Checks whether an prestige buff node can be purchased (all parents unlocked, not already unlocked, enough currency). +func can_purchase_prestige_buff(buff_id: StringName) -> bool: + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return false + + if is_prestige_buff_unlocked(buff_id): + return false + + if not node.all_parents_unlocked(_prestige_buff_unlocked): + return false + + var cost: BigNumber = node.get_cost() + var currency_id: StringName = get_prestige_currency_id() + var balance: BigNumber = get_currency_amount_by_id(currency_id) + return balance.is_greater_than(cost) or balance.is_equal_to(cost) + +## Returns true if the given prestige buff node is unlocked. +func is_prestige_buff_unlocked(buff_id: StringName) -> bool: + return bool(_prestige_buff_unlocked.get(buff_id, false)) + +## Returns the fixed BigNumber cost for an prestige buff node. +func get_prestige_buff_cost(buff_id: StringName) -> BigNumber: + var node: PrestigeBuffNode = _get_prestige_buff_node(buff_id) + if node == null: + return BigNumber.from_float(0.0) + return node.get_cost() + +## Computes the combined multiplier from all unlocked prestige buff nodes matching the given effect type. +## [param target_id] filters to nodes targeting this ID (or global nodes when empty). +func get_prestige_buff_multiplier(effect_type: int, target_id: StringName = &"") -> float: + var multiplier: float = 1.0 + for node_id in _prestige_buff_unlocked.keys(): + if not bool(_prestige_buff_unlocked.get(node_id, false)): + continue + var node: PrestigeBuffNode = _get_prestige_buff_node(node_id) + if node == null: + continue + if node.effect_type != effect_type: + continue + if target_id != &"" and node.target_id != &"" and node.target_id != target_id: + continue + multiplier *= node.effect_value + return multiplier + +## Returns all prestige buff nodes whose prerequisites are met but are not yet unlocked. +func get_available_prestige_buffs() -> Array[PrestigeBuffNode]: + if prestige_buff_catalogue == null: + return [] + + var available: Array[PrestigeBuffNode] = [] + for node in prestige_buff_catalogue.nodes: + if node == null or node.id == &"": + continue + if is_prestige_buff_unlocked(node.id): + continue + if node.all_parents_unlocked(_prestige_buff_unlocked): + available.append(node) + return available + +## Returns the PrestigeBuffNode resource for a given ID, or null if not found. +func _get_prestige_buff_node(buff_id: StringName) -> PrestigeBuffNode: + if prestige_buff_catalogue == null: + return null + return prestige_buff_catalogue.get_node_by_id(buff_id) + # ============================================================================== # PERSISTENCE HELPERS # ============================================================================== @@ -1394,3 +1509,26 @@ func _deserialize_research_xp(raw: Variant) -> Dictionary: if serialized is Dictionary: result[research_id] = BigNumber.deserialize(serialized) return result + +## Serializes prestige buff unlock state as an array of unlocked node IDs. +func _serialize_prestige_buff_unlocked() -> Array: + var unlocked: Array = [] + for node_id in _prestige_buff_unlocked.keys(): + if _prestige_buff_unlocked.get(node_id, false): + unlocked.append(node_id) + return unlocked + +## Deserializes prestige buff unlock state from an array of unlocked node ID strings, then fills missing catalogue entries. +func _deserialize_prestige_buff_unlocked(raw: Variant) -> void: + _prestige_buff_unlocked.clear() + if raw is Array: + for element in raw: + var node_id: StringName = StringName(String(element)) + if node_id != &"": + _prestige_buff_unlocked[node_id] = true + _initialize_prestige_buffs() + + # Notify tiles that were waiting for state to load + for node_id in _prestige_buff_unlocked.keys(): + if _prestige_buff_unlocked.get(node_id, false): + prestige_buff_unlocked.emit(node_id) diff --git a/core/prestige/README.md b/core/prestige/README.md index b0e9ddd..9e7b19e 100644 --- a/core/prestige/README.md +++ b/core/prestige/README.md @@ -2,29 +2,51 @@ ## Overview -The `prestige/` subfolder implements the rebirth/reset system. Players can reset progress in exchange for a permanent multiplier that accelerates future runs. +The `prestige/` subfolder implements the rebirth/reset system and the prestige buff graph — permanent upgrades purchasable with prestige currency that persist across resets. ## Files | File | Purpose | |------|---------| -| `prestige_config.gd` | Configuration resource for prestige rules | -| `prestige_manager.gd` | Core logic and state management | -| `prestige_panel.gd` | UI panel for prestige interaction | -| `TECH_SPEC.md` | Detailed technical specification | +| `prestige_config.gd` | Configuration resource for prestige rules (`PrestigeConfig`) | +| `prestige_manager.gd` | Core prestige state, gain calculation, and reset orchestration (`PrestigeManager`) | +| `prestige_buff_node.gd` | Single node in the prestige buff graph (`PrestigeBuffNode`) | +| `prestige_buff_catalogue.gd` | Flat list of all prestige buff nodes (`PrestigeBuffCatalogue`) | +| `prestige_buff_graph_tile.gd` | Individual tile UI for a buff node (`PrestigeBuffGraphTile`) | +| `prestige_buff_graph_tile.tscn` | Tile scene (icon, name, effect, cost, state) | +| `prestige_buff_graph_panel.gd` | Full-screen panel displaying the buff graph (`PrestigeBuffGraphPanel`) | +| `prestige_panel.gd` | UI panel for prestige reset interaction (`PrestigePanel`) | +| `prestige_panel.tscn` | Prestige panel scene | +| `prestige_progress_bar.gd` | Progress bar toward next prestige threshold | +| `prestige_progress_bar.tscn` | Progress bar scene | +| `TECH_SPEC.md` | Detailed technical specification (historical design doc) | ## Architecture ``` -PrestigeManager (autoload node) -├── Tracks prestige currency -├── Calculates pending gain -├── Applies multipliers to generators -└── PrestigePanel (UI) - ├── Shows current/pending prestige - └── Trigger reset button +LevelGameState (Node) +├── PrestigeManager (Node) +│ ├── Tracks prestige currency & reset count +│ ├── Calculates pending gain from config +│ └── Orchestrates reset via GameState +├── PrestigePanel (UI) +│ ├── Shows current/pending prestige & multiplier +│ └── Reset button with confirmation dialog +├── PrestigeProgressBar (UI) +│ └── Visual progress toward next threshold +├── PrestigeBuffGraphPanel (UI) +│ ├── Ascension currency balance +│ └── Tiered rows of PrestigeBuffGraphTile nodes +└── prestige_buff_catalogue (PrestigeBuffCatalogue) + └── Array of PrestigeBuffNode resources ``` +PrestigeManager is **not** an autoload — it is a child node of `LevelGameState`. The game state finds it via `find_child("PrestigeManager")` in `_ready()`. + +--- + +# Part 1 — Prestige Reset System + ## PrestigeConfig Resource defining prestige rules. @@ -82,13 +104,14 @@ enum MultiplierMode { ## PrestigeManager -Autoload node managing prestige state. +Child node of `LevelGameState` managing prestige state. ### Signals ```gdscript signal prestige_state_changed(total_prestige, pending_gain) signal prestige_performed(gain, total_prestige) +signal prestige_threshold_changed(next_threshold) ``` ### State Variables @@ -106,9 +129,11 @@ var run_peak_source_currency: BigNumber # Peak this run ```gdscript # Query state func get_total_prestige() -> BigNumber +func get_current_prestige_unspent() -> BigNumber func calculate_pending_gain() -> BigNumber func can_prestige() -> bool func get_total_multiplier() -> float +func get_next_prestige_threshold() -> BigNumber # Perform prestige func perform_prestige() -> bool @@ -171,20 +196,14 @@ multiplier = base_multiplier * (growth_base ^ total_prestige) 1. Player clicks "Prestige" button 2. `perform_prestige()` called -3. Gain calculated and added to total: - - For ALL_CURRENCIES: sum of all tracked currencies is used - - For single-currency: source currency is used -4. `GameState.reset_for_prestige()` called: - - Current currencies reset to 0 - - Generator states cleared - - Buff levels reset to 0 - - Goal completion cleared and re-initialized - - Lifetime totals preserved -5. Run tracking reinitialized: - - For ALL_CURRENCIES: sum of all currencies at reset time - - For single-currency: source currency at reset time -6. Generators reinitialized -7. Save triggered +3. Gain calculated and added to total +4. Prestige currency granted via `game_state.add_currency_by_id()` +5. `game_state.reset_for_prestige()` called (preserves prestige currency, resets generators, currencies, buff levels, goals) +6. `_reset_all_buff_levels()` — clears generator buff levels (NOT prestige buff unlocks) +7. Generator runtime state reinitialized +8. Run tracking reinitialized +9. `game_state.emit_currency_changed_for_all()` — triggers UI refresh +10. Save triggered ## PrestigePanel @@ -193,40 +212,39 @@ UI component for prestige interaction. ### Displays ```gdscript -_total_value.text = "123.45" # Total prestige earned -_pending_value.text = "56.78" # Gain available now -_multiplier_value.text = "x2.34" # Current multiplier -_basis_label.text = "Total Currency (1.50e6)" # For ALL_CURRENCIES basis -# Or: "Lifetime Total (1.50e6 Magic)" # For LIFETIME_TOTAL basis +_total_value.text # Total prestige earned (e.g. "123.45") +_pending_value.text # Gain available now +_multiplier_value.text # Current multiplier (e.g. "x2.34") +_basis_label.text # Basis + value (e.g. "Total Currency (1.50e6)") ``` ### Buttons -- **Reset**: Triggers `perform_prestige()` after confirmation +- **Reset**: Triggers `perform_prestige()` after confirmation dialog - **Save**: Manually saves game state -## Integration with Generators +## Generator Integration -Generators apply prestige multipliers automatically: +Generators apply prestige multipliers via `PrestigeManager.get_total_multiplier()`: ```gdscript -func get_effective_auto_run_multiplier() -> float: - return run_multiplier * buff_multiplier * _get_prestige_multiplier() - func _get_prestige_multiplier() -> float: - var manager = get_node_or_null("/root/PrestigeManager") - if manager: - return manager.get_total_multiplier() + var parent: Node = get_parent() + var prestige_manager: PrestigeManager = parent.find_child("PrestigeManager", true, false) + if prestige_manager: + return prestige_manager.get_total_multiplier() return 1.0 ``` +This is called from `get_effective_auto_run_multiplier()` alongside research and buff multipliers. In the future, prestige buff multipliers from the graph system will replace or augment this. + ## Save Integration -Prestige state stored in `GameState` external save: +Prestige state stored in `LevelGameState` external save: ```json { - "prestige": { + "prestige_state": { "total_prestige_earned": {"m": 10.0, "e": 0}, "current_prestige_unspent": {"m": 5.0, "e": 0}, "prestige_resets_count": 3, @@ -242,56 +260,208 @@ Prestige state stored in `GameState` external save: For a typical idle game with single currency: ```gdscript -# Primary prestige currency id = &"primary" display_name = "Ascension" source_currency_id = &"magic" - -# Based on lifetime total of Magic currency basis = LIFETIME_TOTAL - -# Need 1e6 Magic to get first prestige threshold_mantissa = 1.0 -threshold_exponent = 6 - -# Square root scaling (diminishing returns) +threshold_exponent = 6 # 1e6 formula = POWER -exponent = 0.5 - -# +10% multiplier per prestige point +exponent = 0.5 # Square root multiplier_mode = ADDITIVE multiplier_per_prestige = 0.10 ``` -For multi-currency tracking (ALL_CURRENCIES): +For multi-currency tracking (`ALL_CURRENCIES`): ```gdscript id = &"ascension" display_name = "Ascension" prestige_currency_id = &"ascension" - -# Track sum of ALL currencies (gold + wood + food + etc.) basis = ALL_CURRENCIES - -# Empty include_currency_ids means all currencies from catalog -# Set specific IDs to track only those: include_currency_ids = [&"gold", &"wood"] -include_currency_ids = [] - -# Need 1e4 total currency sum to get first prestige +include_currency_ids = [] # Empty = all currencies from catalogue threshold_mantissa = 1.0 -threshold_exponent = 4 - -# Square root scaling +threshold_exponent = 4 # 1e4 formula = POWER exponent = 0.5 - -# +5% multiplier per prestige point multiplier_mode = ADDITIVE multiplier_per_prestige = 0.05 ``` -## See Also +--- -- `core/level_game_state.gd` - State persistence -- `core/generator/currency_generator.gd` - Multiplier application -- `core/big_number.gd` - Large number math +# Part 2 — Prestige Buff Graph + +## Overview + +The prestige buff graph is a permanent upgrade system where players spend prestige currency to unlock nodes in a DAG-structured graph. Unlike generator buffs (which reset on prestige), prestige buff unlocks are **permanent** and survive resets. + +## PrestigeBuffNode + +Single node in the graph. Each node is a one-shot unlock — either locked or unlocked, no repeatable levels. + +### EffectType Enum + +```gdscript +enum EffectType { + CURRENCY_PRODUCTION_MULTIPLIER, # Multiply currency output by x% + BUILDING_CREATION_BONUS, # When a building is created, increase currency gain by x% + WORKER_THRESHOLD_MULTIPLIER, # When 10+ workers are assigned to a building, increase currency gain by x% + CARRYOVER_BUILDINGS, # After prestige, start with some buildings and workers already assigned + UNLOCK_AUTO_BUY_WORKER, # Unlock option to auto-buy workers + HOVER_SPEED_BOOST, # Hovering over a place speeds up workers instead of clicking + UNLOCK_BUILDING, # Unlock a new building (generator) + WORKER_PRODUCTIVITY, # Increase worker productivity by x% + RESEARCH_XP_MULTIPLIER, # Increase research XP gain by x% +} +``` + +### Exported Fields + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `StringName` | Unique node identifier | +| `display_name` | `String` | UI label | +| `description` | `String` (multiline) | Tooltip | +| `icon` | `Texture2D` | Icon in graph UI | +| `parent_ids` | `Array[StringName]` | Prerequisites (DAG — multiple parents allowed) | +| `effect_type` | `EffectType` | What this buff does | +| `effect_value` | `float` | Effect magnitude (multiplier or count depending on type) | +| `target_id` | `StringName` | Target building/currency for type-specific effects (empty = global) | +| `cost_mantissa` | `float` | Prestige currency cost mantissa | +| `cost_exponent` | `int` | Prestige currency cost exponent | +| `tier` | `int` | Row in graph UI (visual grouping) | +| `x_position` | `float` | Column in graph UI | + +### Key Methods + +```gdscript +func get_cost() -> BigNumber # BigNumber from mantissa/exponent +func has_parents() -> bool # True if parent_ids is non-empty +func all_parents_unlocked(unlocked: Dictionary) -> bool # All prerequisites met? +func is_valid() -> bool # Non-empty id and positive cost +``` + +## PrestigeBuffCatalogue + +Flat resource containing all buff nodes. The graph structure emerges from `parent_ids`. + +| Field | Type | +|-------|------| +| `nodes` | `Array[PrestigeBuffNode]` | + +### Key Methods + +```gdscript +func get_node_by_id(id: StringName) -> PrestigeBuffNode +func get_all_ids() -> Array[StringName] +func get_root_nodes() -> Array[PrestigeBuffNode] # Nodes with empty parent_ids +func get_children_of(parent_id: StringName) -> Array[PrestigeBuffNode] +``` + +## State in LevelGameState + +```gdscript +@export var prestige_buff_catalogue: PrestigeBuffCatalogue +var _prestige_buff_unlocked: Dictionary = {} # {StringName: bool} — node_id → unlocked +``` + +- Never cleared by `reset_for_prestige()` (unlike `_buff_levels` which IS cleared) +- Normalized on load: missing catalogue node IDs are filled as `false` +- On initialization, iterates the catalogue and sets any missing entries to `false` + +## LevelGameState API + +```gdscript +# Purchase and query +func purchase_prestige_buff(buff_id: StringName) -> bool +func can_purchase_prestige_buff(buff_id: StringName) -> bool +func is_prestige_buff_unlocked(buff_id: StringName) -> bool +func get_prestige_buff_cost(buff_id: StringName) -> BigNumber +func get_prestige_currency_id() -> StringName # Returns &"ascension" + +# Multiplier computation — walks graph, returns combined product from unlocked nodes +func get_prestige_buff_multiplier(effect_type: int, target_id: StringName = &"") -> float + +# Available nodes (prerequisites met, not yet unlocked) +func get_available_prestige_buffs() -> Array[PrestigeBuffNode] +``` + +### Signal + +```gdscript +signal prestige_buff_unlocked(buff_id: StringName) +``` + +Prestige currency balance changes use the existing `currency_changed` signal — no separate signal needed. + +## Purchase Flow + +``` +Player clicks unlock node + → can_purchase_prestige_buff(node_id) + → Node exists in catalogue? + → Not already unlocked? + → All parent_ids unlocked? + → Enough prestige currency? + → YES: spend_currency_by_id("ascension", cost) + → _prestige_buff_unlocked[node_id] = true + → emit prestige_buff_unlocked + → save_game() +``` + +## Multiplier Computation + +`get_prestige_buff_multiplier(effect_type, target_id)` iterates all unlocked nodes matching the given effect type and multiplies their `effect_value` together. Results start at `1.0`. + +- If `target_id` is empty: all matching nodes are included (global effect) +- If `target_id` is set: only nodes with empty or matching `target_id` are included + +## Multiplier Usage In Generators + +Generators should call `get_prestige_buff_multiplier()` with their generator ID: + +```gdscript +var asc_mult: float = game_state.get_prestige_buff_multiplier( + PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, + get_generator_id() +) +``` + +This replaces the old `_get_prestige_multiplier()` call for prestige buff-driven production bonuses. + +## Save Format + +- Save format version bumped to **8** when prestige buffs were introduced. +- Key: `"prestige_buff_unlocked"` → array of unlocked node ID strings: `["gold_boost", "farm_boost"]` +- Load deserialization reads the array, sets matching entries to `true`, then calls `_initialize_prestige_buffs()` to fill in missing entries as `false`. + +## UI — PrestigeBuffGraphPanel + +A `PanelContainer`-based separate screen. + +- **Layout**: Tiered rows (`HBoxContainer` per tier), sorted by `tier` then `x_position` +- **Tiles** (`PrestigeBuffGraphTile`): show icon, name, effect description, cost, state +- **States**: + - **Locked** (greyed out): prerequisites not met + - **Available** (highlighted, button enabled): all prerequisites met, can purchase + - **Unlocked** (green check, button disabled): already owned +- **Balance**: Shows current prestige currency balance at top, refreshes on `currency_changed` +- **Graph rebuild**: Rebuilds entire graph on `prestige_buff_unlocked` to reflect newly available children + +## Content Files + +Prestige buff content lives in `docs/gyms/tiny_sword/prestige/`: + +| File | Purpose | +|------|---------| +| `prestige_buff_catalogue.tres` | Catalogue resource referencing all buff nodes | +| `prestige_buff_gold_boost.tres` | Example: +50% gold mine production | +| `prestige_buff_farm_boost.tres` | Example: +50% farm food production | + +Each `.tres` is a `PrestigeBuffNode` resource with exported fields set in the inspector. + +## Prestige Reset And Buffs + +- **Generator buffs** (`_buff_levels`, `_buff_unlocked`, `_buff_active`): reset to 0/false on prestige +- **Prestige buff unlocks** (`_prestige_buff_unlocked`): **never reset** — permanent across all prestige resets diff --git a/core/prestige/prestige_buff_catalogue.gd b/core/prestige/prestige_buff_catalogue.gd new file mode 100644 index 0000000..013ecb7 --- /dev/null +++ b/core/prestige/prestige_buff_catalogue.gd @@ -0,0 +1,40 @@ +## Flat list of all ascension buff nodes. The graph structure emerges from each node's parent_ids field. +class_name PrestigeBuffCatalogue +extends Resource + +@export var nodes: Array[PrestigeBuffNode] = [] + + +## Finds a node by its unique ID. Returns null if not found. +func get_node_by_id(id: StringName) -> PrestigeBuffNode: + for node in nodes: + if node.id == id: + return node + return null + + +## Returns all non-empty node IDs in the catalogue. +func get_all_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for node in nodes: + if node.id: + ids.append(node.id) + return ids + + +## Returns all root nodes (nodes with no prerequisites). +func get_root_nodes() -> Array[PrestigeBuffNode]: + var roots: Array[PrestigeBuffNode] = [] + for node in nodes: + if node.parent_ids.is_empty(): + roots.append(node) + return roots + + +## Returns all nodes that list the given parent_id as a prerequisite. +func get_children_of(parent_id: StringName) -> Array[PrestigeBuffNode]: + var children: Array[PrestigeBuffNode] = [] + for node in nodes: + if node.parent_ids.has(parent_id): + children.append(node) + return children diff --git a/core/prestige/prestige_buff_catalogue.gd.uid b/core/prestige/prestige_buff_catalogue.gd.uid new file mode 100644 index 0000000..354a599 --- /dev/null +++ b/core/prestige/prestige_buff_catalogue.gd.uid @@ -0,0 +1 @@ +uid://bjcvnyh2bc6l5 diff --git a/core/prestige/prestige_buff_connection_overlay.gd b/core/prestige/prestige_buff_connection_overlay.gd new file mode 100644 index 0000000..e4d1860 --- /dev/null +++ b/core/prestige/prestige_buff_connection_overlay.gd @@ -0,0 +1,44 @@ +## Draws bezier curves between connected PrestigeBuffGraphTile nodes in the graph panel. +## Store pairs of tile references before calling queue_redraw(). +class_name PrestigeBuffConnectionOverlay +extends Control + +## Pairs of [parent_tile, child_tile] to connect. +var _connections: Array = [] + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +## Replace all connections and redraw. +func set_connections(connections: Array) -> void: + _connections = connections + queue_redraw() + + +func _draw() -> void: + if _connections.is_empty(): + return + + for pair in _connections: + var parent_tile: Control = pair[0] as Control + var child_tile: Control = pair[1] as Control + if parent_tile == null or child_tile == null: + continue + + var from_pos: Vector2 = _tile_bottom_center(parent_tile) + var to_pos: Vector2 = _tile_top_center(child_tile) + draw_line(from_pos, to_pos, Color(0.6, 0.6, 0.7, 0.6), 2.0) + + +func _tile_bottom_center(tile: Control) -> Vector2: + var rect: Rect2 = tile.get_global_rect() + var global_pos: Vector2 = Vector2(rect.position.x + rect.size.x * 0.5, rect.position.y + rect.size.y) + return get_global_transform().affine_inverse() * global_pos + + +func _tile_top_center(tile: Control) -> Vector2: + var rect: Rect2 = tile.get_global_rect() + var global_pos: Vector2 = Vector2(rect.position.x + rect.size.x * 0.5, rect.position.y) + return get_global_transform().affine_inverse() * global_pos diff --git a/core/prestige/prestige_buff_connection_overlay.gd.uid b/core/prestige/prestige_buff_connection_overlay.gd.uid new file mode 100644 index 0000000..a326169 --- /dev/null +++ b/core/prestige/prestige_buff_connection_overlay.gd.uid @@ -0,0 +1 @@ +uid://uv5lxj6hmpk diff --git a/core/prestige/prestige_buff_graph_panel.gd b/core/prestige/prestige_buff_graph_panel.gd new file mode 100644 index 0000000..2f45af8 --- /dev/null +++ b/core/prestige/prestige_buff_graph_panel.gd @@ -0,0 +1,85 @@ +## Separate-screen panel displaying the prestige buff graph. +## Tiles are placed manually in the scene editor — each PrestigeBuffGraphTile has its PrestigeBuffNode +## resource assigned directly. The panel manages the prestige currency balance display and +## delegates connection-line drawing to a PrestigeBuffConnectionOverlay child. +class_name PrestigeBuffGraphPanel +extends PanelContainer + +@onready var _balance_label: Label = $MarginContainer/VBoxContainer/BalanceLabel +@onready var _tiles_area: Control = $MarginContainer/VBoxContainer/TilesArea +@onready var _close_button: Button = $MarginContainer/VBoxContainer/Header/CloseButton +@onready var _connection_overlay: PrestigeBuffConnectionOverlay = $MarginContainer/VBoxContainer/TilesArea/ConnectionOverlay + +var _game_state: LevelGameState + + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("PrestigeBuffGraphPanel: Could not find LevelGameState parent") + return + + _game_state.currency_changed.connect(_on_currency_changed) + _close_button.pressed.connect(_on_close_pressed) + + visibility_changed.connect(_on_visibility_changed) + _refresh_balance() + + +## Rebuilds the connection overlay by scanning all PrestigeBuffGraphTile children and matching parent_ids. +func _refresh_connections() -> void: + if _connection_overlay == null: + return + + var tiles_by_id: Dictionary = {} + var all_tiles: Array[PrestigeBuffGraphTile] = [] + _collect_tiles(_tiles_area, all_tiles) + + for tile in all_tiles: + var node_id: StringName = tile.get_node_id() + if node_id != &"": + tiles_by_id[node_id] = tile + + var connections: Array = [] + for tile in all_tiles: + if tile.prestige_buff == null: + continue + for parent_id in tile.prestige_buff.parent_ids: + var parent_tile: PrestigeBuffGraphTile = tiles_by_id.get(parent_id, null) + if parent_tile: + connections.append([parent_tile, tile]) + + _connection_overlay.set_connections(connections) + + +## Recursively collects all PrestigeBuffGraphTile nodes from a container. +func _collect_tiles(from: Node, out_tiles: Array[PrestigeBuffGraphTile]) -> void: + for child in from.get_children(): + if child is PrestigeBuffGraphTile: + out_tiles.append(child) + elif child is Container: + _collect_tiles(child, out_tiles) + +func _refresh_balance() -> void: + if _game_state == null: + _balance_label.text = "Prestige Currency: 0" + return + + var currency_id: StringName = _game_state.get_prestige_currency_id() + var balance: BigNumber = _game_state.get_currency_amount_by_id(currency_id) + _balance_label.text = "Prestige Currency: %s" % balance.to_string_suffix(2) + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if currency_id == _game_state.get_prestige_currency_id(): + _refresh_balance() + +func _on_close_pressed() -> void: + hide() + +func _on_visibility_changed() -> void: + if visible: + _refresh_balance() + _refresh_connections() + +func _on_prestige_buff_toggle_pressed() -> void: + show() diff --git a/core/prestige/prestige_buff_graph_panel.gd.uid b/core/prestige/prestige_buff_graph_panel.gd.uid new file mode 100644 index 0000000..1cc1237 --- /dev/null +++ b/core/prestige/prestige_buff_graph_panel.gd.uid @@ -0,0 +1 @@ +uid://n01pkakxllnj diff --git a/core/prestige/prestige_buff_graph_tile.gd b/core/prestige/prestige_buff_graph_tile.gd new file mode 100644 index 0000000..63f62f6 --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.gd @@ -0,0 +1,122 @@ +## Individual tile in the prestige buff graph UI. +## Visually represents a single PrestigeBuffNode with locked/available/unlocked states. +## The PrestigeBuffNode resource is assigned directly via the editor export. +class_name PrestigeBuffGraphTile +extends PanelContainer + +## The prestige buff node this tile represents. Set in the scene editor. +@export var prestige_buff: PrestigeBuffNode + +@onready var _button: Button = $Button +@onready var _icon: TextureRect = $Button/HBoxContainer/Icon +@onready var _name_label: Label = $Button/HBoxContainer/VBoxContainer/NameLabel +@onready var _effect_label: Label = $Button/HBoxContainer/VBoxContainer/EffectLabel +@onready var _cost_label: Label = $Button/HBoxContainer/VBoxContainer/CostLabel +@onready var _state_label: Label = $Button/HBoxContainer/StateLabel + +var _game_state: LevelGameState + + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + _button.pressed.connect(_on_button_pressed) + + if _game_state: + _game_state.prestige_buff_unlocked.connect(_on_prestige_buff_unlocked) + _game_state.currency_changed.connect(_on_currency_changed) + + if prestige_buff == null: + push_warning("PrestigeBuffGraphTile: prestige_buff is not set") + return + + _refresh_visuals() + _refresh_state() + + +func get_node_id() -> StringName: + if prestige_buff: + return prestige_buff.id + return &"" + + +func _refresh_visuals() -> void: + if prestige_buff == null: + return + + _name_label.text = prestige_buff.display_name + _cost_label.text = "Cost: %s" % prestige_buff.get_cost().to_string_suffix(2) + + if prestige_buff.icon: + _icon.texture = prestige_buff.icon + + var effect_text: String = _format_effect_description() + _effect_label.text = effect_text + _effect_label.visible = not effect_text.is_empty() + + +func _refresh_state() -> void: + if prestige_buff == null or _game_state == null: + return + + var unlocked: bool = _game_state.is_prestige_buff_unlocked(prestige_buff.id) + var available: bool = _game_state.can_purchase_prestige_buff(prestige_buff.id) + + if unlocked: + _state_label.text = "✓ Unlocked" + _button.disabled = true + modulate = Color.GREEN + elif available: + _state_label.text = "Available" + _button.disabled = false + modulate = Color.WHITE + else: + _state_label.text = "Locked" + _button.disabled = true + modulate = Color(0.5, 0.5, 0.5, 1.0) + + +func _format_effect_description() -> String: + if prestige_buff == null: + return "" + + match prestige_buff.effect_type: + PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER: + if prestige_buff.target_id != &"": + return "+%.0f%% %s production" % [((prestige_buff.effect_value - 1.0) * 100.0), prestige_buff.target_id] + return "+%.0f%% all production" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.BUILDING_CREATION_BONUS: + return "+%.0f%% gain on build" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.WORKER_THRESHOLD_MULTIPLIER: + return "+%.0f%% with 10+ workers" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.CARRYOVER_BUILDINGS: + return "Keep %d buildings on prestige" % int(prestige_buff.effect_value) + PrestigeBuffNode.EffectType.UNLOCK_AUTO_BUY_WORKER: + return "Unlock auto-buy worker" + PrestigeBuffNode.EffectType.HOVER_SPEED_BOOST: + return "Hover to speed up workers" + PrestigeBuffNode.EffectType.UNLOCK_BUILDING: + return "Unlock %s building" % prestige_buff.target_id + PrestigeBuffNode.EffectType.WORKER_PRODUCTIVITY: + return "+%.0f%% worker productivity" % ((prestige_buff.effect_value - 1.0) * 100.0) + PrestigeBuffNode.EffectType.RESEARCH_XP_MULTIPLIER: + return "+%.0f%% research XP" % ((prestige_buff.effect_value - 1.0) * 100.0) + _: + return "" + + +func _on_button_pressed() -> void: + if _game_state and prestige_buff: + _game_state.purchase_prestige_buff(prestige_buff.id) + + +func _on_prestige_buff_unlocked(buff_id: StringName) -> void: + if prestige_buff and buff_id == prestige_buff.id: + _refresh_state() + + +func _on_currency_changed(currency_id: StringName, _new_amount: BigNumber) -> void: + if _game_state == null or prestige_buff == null: + return + var asc_id: StringName = _game_state.get_prestige_currency_id() + if currency_id == asc_id: + _refresh_state() diff --git a/core/prestige/prestige_buff_graph_tile.gd.uid b/core/prestige/prestige_buff_graph_tile.gd.uid new file mode 100644 index 0000000..cf49391 --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.gd.uid @@ -0,0 +1 @@ +uid://w8ogrp1s6qsf diff --git a/core/prestige/prestige_buff_graph_tile.tscn b/core/prestige/prestige_buff_graph_tile.tscn new file mode 100644 index 0000000..b662b9f --- /dev/null +++ b/core/prestige/prestige_buff_graph_tile.tscn @@ -0,0 +1,40 @@ +[gd_scene format=3 uid="uid://bqk8rwia7lpbg"] + +[ext_resource type="Script" uid="uid://w8ogrp1s6qsf" path="res://core/prestige/prestige_buff_graph_tile.gd" id="1_script"] + +[node name="AscensionBuffGraphTile" type="PanelContainer" unique_id=582396224] +custom_minimum_size = Vector2(220, 100) +script = ExtResource("1_script") + +[node name="Button" type="Button" parent="." unique_id=34096996] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="Button" unique_id=760838153] +layout_mode = 0 + +[node name="Icon" type="TextureRect" parent="Button/HBoxContainer" unique_id=2086768939] +custom_minimum_size = Vector2(48, 48) +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="Button/HBoxContainer" unique_id=1701436193] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="NameLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=334883451] +layout_mode = 2 +text = "Buff Name" + +[node name="EffectLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=1416390723] +layout_mode = 2 +text = "+10% effect" + +[node name="CostLabel" type="Label" parent="Button/HBoxContainer/VBoxContainer" unique_id=983468527] +layout_mode = 2 +text = "Cost: 0" + +[node name="StateLabel" type="Label" parent="Button/HBoxContainer" unique_id=629071282] +layout_mode = 2 +size_flags_horizontal = 0 +text = "Locked" +horizontal_alignment = 2 diff --git a/core/prestige/prestige_buff_node.gd b/core/prestige/prestige_buff_node.gd new file mode 100644 index 0000000..6e06aa5 --- /dev/null +++ b/core/prestige/prestige_buff_node.gd @@ -0,0 +1,69 @@ +## A single node in the prestige buff graph. +## Each node represents a permanent upgrade that persists across prestige resets and is purchased with prestige currency. +class_name PrestigeBuffNode +extends Resource + +enum EffectType { + CURRENCY_PRODUCTION_MULTIPLIER, ## Multiply currency output by x% + BUILDING_CREATION_BONUS, ## When a building is created, increase currency gain by x% + WORKER_THRESHOLD_MULTIPLIER, ## When 10+ workers are assigned to a building, increase currency gain by x% + CARRYOVER_BUILDINGS, ## After prestige, start with some buildings and workers already assigned + UNLOCK_AUTO_BUY_WORKER, ## Unlock option to auto-buy workers + HOVER_SPEED_BOOST, ## Hovering over a place speeds up workers instead of clicking + UNLOCK_BUILDING, ## Unlock a new building (generator) + WORKER_PRODUCTIVITY, ## Increase worker productivity by x% + RESEARCH_XP_MULTIPLIER, ## Increase research XP gain by x% +} + +## Unique node identifier. +@export var id: StringName = &"" +## UI display label. +@export var display_name: String = "" +## Tooltip / description text. +@export_multiline var description: String = "" +## Icon shown in the graph UI. +@export var icon: Texture2D +## Prerequisite node IDs that must be unlocked before this node becomes available. +@export var parent_ids: Array[StringName] = [] +## What kind of effect this buff provides. +@export var effect_type: EffectType = EffectType.CURRENCY_PRODUCTION_MULTIPLIER +## Magnitude of the effect (interpreted per effect type). +@export var effect_value: float = 1.0 +## Target generator, building, or currency ID for type-specific effects. Empty means global. +@export var target_id: StringName = &"" +## Prestige currency cost mantissa (combined with cost_exponent for BigNumber). +@export var cost_mantissa: float = 1.0 +## Prestige currency cost exponent (combined with cost_mantissa for BigNumber). +@export var cost_exponent: int = 0 +## Visual tier / row in the graph UI. +@export var tier: int = 0 +## Visual column / horizontal position in the graph UI. +@export var x_position: float = 0.0 + + +## Builds and returns the fixed prestige currency cost as a BigNumber. +func get_cost() -> BigNumber: + return BigNumber.new(cost_mantissa, cost_exponent) + + +## Returns true if this node has any prerequisite parent nodes. +func has_parents() -> bool: + return not parent_ids.is_empty() + + +## Checks whether all prerequisites are unlocked in the provided dictionary. +## [param unlocked] maps node_id (StringName) → bool (true if unlocked). +func all_parents_unlocked(unlocked: Dictionary) -> bool: + for parent_id in parent_ids: + if not bool(unlocked.get(parent_id, false)): + return false + return true + + +## Returns true if this node has valid required data (non-empty id, positive cost). +func is_valid() -> bool: + if id == &"": + return false + if cost_mantissa <= 0.0: + return false + return true diff --git a/core/prestige/prestige_buff_node.gd.uid b/core/prestige/prestige_buff_node.gd.uid new file mode 100644 index 0000000..728262c --- /dev/null +++ b/core/prestige/prestige_buff_node.gd.uid @@ -0,0 +1 @@ +uid://mjyig8xlgki0 diff --git a/core/prestige/prestige_manager.gd b/core/prestige/prestige_manager.gd index fba756a..9b04968 100644 --- a/core/prestige/prestige_manager.gd +++ b/core/prestige/prestige_manager.gd @@ -24,7 +24,7 @@ const FORMULA_TRIANGULAR_INVERSE: int = 1 const MULTIPLIER_MODE_ADDITIVE: int = 0 const MULTIPLIER_MODE_MULTIPLICATIVE_POWER: int = 1 -@export var config: Resource +@export var config: PrestigeConfig @export var game_state: LevelGameState var total_prestige_earned: BigNumber = BigNumber.from_float(0.0) @@ -36,7 +36,7 @@ var last_reset_time: int = 0 func _ready() -> void: if config == null: - config = load("res://docs/gyms/tiny_sword/prestige/primary_prestige.tres") as Resource + config = load("res://docs/gyms/tiny_sword/prestige/primary_prestige.tres") as PrestigeConfig if not _is_config_valid(): push_warning("PrestigeManager has invalid or missing config; prestige is disabled.") @@ -58,7 +58,7 @@ func _ready() -> void: prestige_state_changed.emit(get_total_prestige(), calculate_pending_gain()) prestige_threshold_changed.emit(get_next_prestige_threshold()) -func get_config() -> Resource: +func get_config() -> PrestigeConfig: return config func get_source_currency_id() -> StringName: @@ -110,7 +110,7 @@ func get_total_multiplier() -> float: return 1.0 var base: float = maxf(_get_config_float("base_multiplier", 1.0), 0.0) - var prestige_value: float = _big_number_to_float(total_prestige_earned) + var prestige_value: float = total_prestige_earned.to_float() if prestige_value <= 0.0: return base @@ -209,7 +209,7 @@ func get_next_prestige_threshold() -> BigNumber: BASIS_RUN_TOTAL, BASIS_ALL_CURRENCIES: return _get_config_threshold() _: - var current_total: float = _big_number_to_float(total_prestige_earned) + var current_total: float = total_prestige_earned.to_float() var next_target: float = _calculate_target_for_prestige(current_total + 1.0) return BigNumber.from_float(next_target) @@ -260,7 +260,7 @@ func _calculate_target_for_prestige(prestige_level: float) -> float: if config == null: return 0.0 - var threshold_value: float = _big_number_to_float(_get_config_threshold()) + var threshold_value: float = _get_config_threshold().to_float() if threshold_value <= 0.0: return 0.0 @@ -289,11 +289,11 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: match _get_config_int("formula", FORMULA_POWER): FORMULA_TRIANGULAR_INVERSE: - var threshold_value: float = _big_number_to_float(_get_config_threshold()) + var threshold_value: float = _get_config_threshold().to_float() if threshold_value <= 0.0: return 0.0 - var basis_float: float = _big_number_to_float(basis_value) + var basis_float: float = basis_value.to_float() if basis_float <= 0.0: return 0.0 @@ -303,7 +303,7 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: var target_total: float = (sqrt(1.0 + 8.0 * k) - 1.0) * 0.5 if _is_cumulative_basis(): - return target_total - _big_number_to_float(total_prestige_earned) + return target_total - total_prestige_earned.to_float() return target_total _: var ratio: float = _big_number_ratio_to_float(basis_value, _get_config_threshold()) @@ -312,7 +312,7 @@ func _calculate_raw_gain_float(basis_value: BigNumber) -> float: var value: float = maxf(_get_config_float("scale", 0.0), 0.0) * pow(ratio, maxf(_get_config_float("exponent", 0.0001), 0.0001)) + _get_config_float("flat_bonus", 0.0) if _is_cumulative_basis(): - return value - _big_number_to_float(total_prestige_earned) + return value - total_prestige_earned.to_float() return value func _get_basis_value() -> BigNumber: @@ -449,9 +449,11 @@ func _reset_all_buff_levels() -> void: if game_state == null: return - for buff_id in game_state._buff_levels.keys(): - game_state.set_buff_level(buff_id, 0) - game_state.set_buff_unlocked(buff_id, false) + for buff in game_state.get_all_buffs(): + if buff == null: + continue + game_state.set_buff_level(buff.id, 0) + game_state.set_buff_unlocked(buff.id, false) func _reinitialize_generators_after_prestige_reset() -> void: var scene_root: Node = get_tree().current_scene @@ -489,14 +491,7 @@ func _big_number_ratio_to_float(a: BigNumber, b: BigNumber) -> float: return (a.mantissa / b.mantissa) * pow(10.0, float(exponent_delta)) -func _big_number_to_float(value: BigNumber) -> float: - if value.mantissa <= 0.0: - return 0.0 - if value.exponent > 308: - return INF - if value.exponent < -308: - return 0.0 - return value.mantissa * pow(10.0, float(value.exponent)) + func _normalize_currency_id(currency_id: StringName) -> StringName: var normalized: String = String(currency_id).to_lower().strip_edges() @@ -514,35 +509,18 @@ func _to_non_negative_int(value: Variant, fallback: int = 0) -> int: func _is_config_valid() -> bool: if config == null: return false - if not config.has_method("is_valid"): - return false - - return bool(config.call("is_valid")) + return config.is_valid() func _is_cumulative_basis() -> bool: - if config != null and config.has_method("is_cumulative_basis"): - return bool(config.call("is_cumulative_basis")) - var basis_type: int = _get_config_int("basis", BASIS_LIFETIME_TOTAL) - return basis_type != BASIS_RUN_TOTAL and basis_type != BASIS_ALL_CURRENCIES func _round_gain(value: float) -> float: - if config != null and config.has_method("round_value"): - var rounded: Variant = config.call("round_value", value) - if rounded is float: - return rounded - if rounded is int: - return float(rounded) - - return floorf(value) + return config.round_value(value) func _get_config_threshold() -> BigNumber: - if config != null and config.has_method("get_threshold"): - var threshold: Variant = config.call("get_threshold") - if threshold is BigNumber: - return threshold - + if config != null: + return config.get_threshold() return BigNumber.from_float(1.0) func _get_config_string_name(property_name: String, fallback: StringName = &"") -> StringName: diff --git a/core/prestige/prestige_progress_bar.gd b/core/prestige/prestige_progress_bar.gd index adee1db..1ce3463 100644 --- a/core/prestige/prestige_progress_bar.gd +++ b/core/prestige/prestige_progress_bar.gd @@ -30,8 +30,7 @@ func _on_game_state_ready() -> void: return if config == null: - if _manager.has_method("get_config"): - config = _manager.get_config() + config = _manager.get_config() if config == null: push_warning("PrestigeProgressBar '%s' has no config; progress bar disabled." % String(name)) @@ -57,14 +56,11 @@ func _update_progress() -> void: if _manager == null or config == null: return - _basis_value = _manager.call("get_basis_value_for_display") - if _manager.has_method("get_next_prestige_threshold"): - _next_threshold = _manager.call("get_next_prestige_threshold") - else: - _next_threshold = _get_threshold_from_config() + _basis_value = _manager.get_basis_value_for_display() + _next_threshold = _manager.get_next_prestige_threshold() - var basis_float: float = _big_number_to_float(_basis_value) - var threshold_float: float = _big_number_to_float(_next_threshold) + var basis_float: float = _basis_value.to_float() + var threshold_float: float = _next_threshold.to_float() if threshold_float > 0.0 and basis_float >= 0.0: var ratio: float = minf(basis_float / threshold_float, 1.0) @@ -74,25 +70,3 @@ func _update_progress() -> void: _label_start.text = _basis_value.to_string_suffix(2) _label_end.text = _next_threshold.to_string_suffix(2) - -func _get_threshold_from_config() -> BigNumber: - if config == null: - return BigNumber.from_float(0.0) - - if not config.has_method("get_threshold"): - return BigNumber.from_float(0.0) - - var threshold = config.call("get_threshold") - if threshold is BigNumber: - return threshold - - return BigNumber.from_float(0.0) - -func _big_number_to_float(value: BigNumber) -> float: - if value.mantissa <= 0.0: - return 0.0 - if value.exponent > 308: - return INF - if value.exponent < -308: - return 0.0 - return value.mantissa * pow(10.0, float(value.exponent)) diff --git a/docs/gyms/tiny_sword/buildings/castle/castle.tscn b/docs/gyms/tiny_sword/buildings/castle/castle.tscn index 882c612..11a0274 100644 --- a/docs/gyms/tiny_sword/buildings/castle/castle.tscn +++ b/docs/gyms/tiny_sword/buildings/castle/castle.tscn @@ -23,15 +23,16 @@ position = Vector2(1, 22) shape = SubResource("RectangleShape2D_tgvch") [node name="PrestigePanel" parent="." unique_id=245519778 instance=ExtResource("3_l7gct")] -visible = false -offset_left = 155.0 -offset_top = -80.0 -offset_right = 575.0 -offset_bottom = 126.0 +offset_left = 161.0 +offset_top = -232.0 +offset_right = 581.0 +offset_bottom = -3.0 [node name="PanelContainer" type="PanelContainer" parent="." unique_id=549346899] -offset_right = 415.0 -offset_bottom = 196.0 +offset_left = 160.0 +offset_top = 5.0 +offset_right = 575.0 +offset_bottom = 201.0 size_flags_horizontal = 3 size_flags_vertical = 3 diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres new file mode 100644 index 0000000..33ecc25 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="PrestigeBuffCatalogue" format=3] + +[ext_resource type="Script" path="res://core/prestige/prestige_buff_node.gd" id="1_node"] +[ext_resource type="Script" path="res://core/prestige/prestige_buff_catalogue.gd" id="2_cat"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres" id="3_gold"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres" id="4_farm"] + +[resource] +script = ExtResource("2_cat") +nodes = Array[ExtResource("1_node")]([ExtResource("3_gold"), ExtResource("4_farm")]) diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres new file mode 100644 index 0000000..267f104 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://cvogxo0msis2w"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_script"] + +[resource] +script = ExtResource("1_script") +id = &"farm_boost" +display_name = "Fertile Grounds" +description = "Permanently increases farm food production." +effect_value = 1.5 +target_id = &"farm" +x_position = 1.0 diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres new file mode 100644 index 0000000..93646b1 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://vl3ot4t876pc"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_script"] + +[resource] +script = ExtResource("1_script") +id = &"gold_boost" +display_name = "Golden Touch" +description = "Permanently increases gold mine production." +effect_value = 1.5 +target_id = &"goldmine" diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn b/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn new file mode 100644 index 0000000..7568cfa --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn @@ -0,0 +1,76 @@ +[gd_scene format=3 uid="uid://dprbgokqitaav"] + +[ext_resource type="Script" path="res://core/prestige/prestige_buff_graph_panel.gd" id="1_panel"] +[ext_resource type="Script" path="res://core/prestige/prestige_buff_connection_overlay.gd" id="2_overlay"] +[ext_resource type="PackedScene" path="res://core/prestige/prestige_buff_graph_tile.tscn" id="3_tile"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_gold_boost.tres" id="4_gold"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_farm_boost.tres" id="5_farm"] + +[node name="PrestigeBuffGraphPanel" type="PanelContainer" unique_id=947835716] +visible = false +custom_minimum_size = Vector2(600, 400) +script = ExtResource("1_panel") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=829451203] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 12 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=637192845] +layout_mode = 2 + +[node name="Header" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=372819465] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer/Header" unique_id=518293746] +layout_mode = 2 +text = "Prestige Buffs" +theme_override_font_sizes/font_size = 20 + +[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/Header" unique_id=184756392] +layout_mode = 2 +size_flags_horizontal = 10 +text = "✕" +theme_override_font_sizes/font_size = 16 + +[node name="BalanceLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=293847561] +layout_mode = 2 +text = "Prestige Currency: 0" +theme_override_font_sizes/font_size = 14 + +[node name="TilesArea" type="Control" parent="MarginContainer/VBoxContainer" unique_id=847291034] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 1 + +[node name="ConnectionOverlay" type="Control" parent="MarginContainer/VBoxContainer/TilesArea" unique_id=563829104] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 1 +grow_vertical = 1 +mouse_filter = 2 +script = ExtResource("2_overlay") + +[node name="TiersContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TilesArea" unique_id=129384756] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 1 +grow_vertical = 1 + +[node name="Tier0" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TilesArea/TiersContainer" unique_id=384756192] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="GoldBoostTile" parent="MarginContainer/VBoxContainer/TilesArea/TiersContainer/Tier0" unique_id=756192834 instance=ExtResource("3_tile")] +layout_mode = 2 +prestige_buff = ExtResource("4_gold") + +[node name="FarmBoostTile" parent="MarginContainer/VBoxContainer/TilesArea/TiersContainer/Tier0" unique_id=619283475 instance=ExtResource("3_tile")] +layout_mode = 2 +prestige_buff = ExtResource("5_farm") diff --git a/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres b/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres new file mode 100644 index 0000000..b8670e7 --- /dev/null +++ b/docs/gyms/tiny_sword/prestige/prestige_buff_wood_boost.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="PrestigeBuffNode" format=3 uid="uid://bif136o485oqh"] + +[ext_resource type="Script" uid="uid://mjyig8xlgki0" path="res://core/prestige/prestige_buff_node.gd" id="1_42hk5"] + +[resource] +script = ExtResource("1_42hk5") +id = &"wood_boost" +display_name = "Wood Touch" +description = "Permanently increases forestry production." +effect_value = 1.5 +target_id = &"forestry" diff --git a/docs/gyms/tiny_sword/tiny_sword.tscn b/docs/gyms/tiny_sword/tiny_sword.tscn index 0f0595a..2bc5cfe 100644 --- a/docs/gyms/tiny_sword/tiny_sword.tscn +++ b/docs/gyms/tiny_sword/tiny_sword.tscn @@ -8,6 +8,7 @@ [ext_resource type="Resource" uid="uid://cgt1mjir1v4br" path="res://docs/gyms/tiny_sword/goals/ts_goal_catalogue.tres" id="4_x77df"] [ext_resource type="Resource" uid="uid://umi37hotcq8m" path="res://docs/gyms/tiny_sword/research/ts_research_catalogue.tres" id="5_v0pty"] [ext_resource type="Script" uid="uid://srkiu4qe8s2m" path="res://core/prestige/prestige_manager.gd" id="5_x77df"] +[ext_resource type="Resource" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_catalogue.tres" id="6_asc"] [ext_resource type="Resource" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="6_xnhlc"] [ext_resource type="PackedScene" path="res://docs/gyms/tiny_sword/currency_panel.tscn" id="9_cpnl"] [ext_resource type="PackedScene" uid="uid://djedqovgngrx5" path="res://docs/gyms/tiny_sword/buildings/farm/farm.tscn" id="10_1lv5i"] @@ -17,6 +18,7 @@ [ext_resource type="PackedScene" uid="uid://rejxvjwybkll" path="res://sandbox/tiny_swords/Terrain/Resources/Wood/Trees/tree_1.tscn" id="14_0cs5o"] [ext_resource type="PackedScene" uid="uid://bp5ng4vu4ot4a" path="res://docs/gyms/tiny_sword/buildings/alchemy_tower/alchemy_tower.tscn" id="14_hum8s"] [ext_resource type="Script" uid="uid://b6systw05frh0" path="res://core/edge_scroll_camera.gd" id="17_x77df"] +[ext_resource type="PackedScene" uid="uid://dprbgokqitaav" path="res://docs/gyms/tiny_sword/prestige/prestige_buff_graph_panel.tscn" id="18_pnl"] [node name="TinySwords" type="Node" unique_id=498237642] @@ -26,6 +28,7 @@ currency_catalogue = ExtResource("2_dwrof") buff_catalogue = ExtResource("3_wl838") goal_catalogue = ExtResource("4_x77df") research_catalogue = ExtResource("5_v0pty") +prestige_buff_catalogue = ExtResource("6_asc") save_file_path = "user://tiny_sword_save.json" [node name="PrestigeManager" type="Node" parent="LevelGameState" unique_id=1019028144 node_paths=PackedStringArray("game_state")] @@ -41,6 +44,10 @@ anchors_preset = 0 [node name="CurrencyPanel" parent="LevelGameState/CanvasLayer/UI" unique_id=2000000001 instance=ExtResource("9_cpnl")] layout_mode = 1 +offset_left = 0.0 +offset_top = 429.0 +offset_right = 250.0 +offset_bottom = 769.0 [node name="GoalsDebugUI" parent="LevelGameState/CanvasLayer/UI" unique_id=1494700185 instance=ExtResource("10_qifrv")] visible = false @@ -50,6 +57,22 @@ offset_top = 5.0 offset_right = 1913.0 offset_bottom = 263.0 +[node name="PrestigeBuffToggle" type="Button" parent="LevelGameState/CanvasLayer/UI" unique_id=293847102] +layout_mode = 1 +offset_left = 1730.0 +offset_top = 64.0 +offset_right = 1857.0 +offset_bottom = 100.0 +toggle_mode = true +text = "Prestige Buffs" + +[node name="PrestigeBuffGraphPanel" parent="LevelGameState/CanvasLayer/UI" unique_id=847291035 instance=ExtResource("18_pnl")] +layout_mode = 1 +offset_left = 400.0 +offset_top = 200.0 +offset_right = 1000.0 +offset_bottom = 700.0 + [node name="World" type="Node2D" parent="LevelGameState" unique_id=2118297724] [node name="ForestProps" type="Node2D" parent="LevelGameState/World" unique_id=649424542] @@ -97,3 +120,5 @@ script = ExtResource("17_x77df") clamp_enabled = true clamp_min = Vector2(-1920, -1080) clamp_max = Vector2(1920, 1080) + +[connection signal="pressed" from="LevelGameState/CanvasLayer/UI/PrestigeBuffToggle" to="LevelGameState/CanvasLayer/UI/PrestigeBuffGraphPanel" method="_on_prestige_buff_toggle_pressed"] diff --git a/tests/test_ascension.gd b/tests/test_ascension.gd new file mode 100644 index 0000000..cb9ec39 --- /dev/null +++ b/tests/test_ascension.gd @@ -0,0 +1,318 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + + +func _ready() -> void: + await run() + + +func run() -> void: + print("\n=== TEST: Ascension Buff System ===\n") + TestUtils.set_test_name("ascension") + + _test_node_resource() + _test_node_validation() + _test_node_parent_checking() + _test_catalogue_queries() + _test_purchase_flow() + _test_prestige_does_not_reset_prestige_buffs() + _test_multiplier_computation() + _test_save_load_roundtrip() + + _print_summary() + + +#region Node Resource Tests +func _test_node_resource() -> void: + print("--- Node Resource ---") + + var node: PrestigeBuffNode = PrestigeBuffNode.new() + node.id = &"test_node" + node.display_name = "Test Node" + node.effect_type = PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER + node.effect_value = 2.0 + node.cost_mantissa = 5.0 + node.cost_exponent = 2 # cost = 5e2 = 500 + + var cost: BigNumber = node.get_cost() + TestUtils.assert_equals(5.0, cost.mantissa, "cost mantissa matches") + TestUtils.assert_equals(2, cost.exponent, "cost exponent matches") + TestUtils.assert_false(node.has_parents(), "node with no parents returns false") + + +func _test_node_validation() -> void: + print("--- Node Validation ---") + + var valid: PrestigeBuffNode = PrestigeBuffNode.new() + valid.id = &"valid_id" + valid.cost_mantissa = 1.0 + TestUtils.assert_true(valid.is_valid(), "node with id and positive cost is valid") + + var no_id: PrestigeBuffNode = PrestigeBuffNode.new() + no_id.cost_mantissa = 1.0 + TestUtils.assert_false(no_id.is_valid(), "node with empty id is invalid") + + var zero_cost: PrestigeBuffNode = PrestigeBuffNode.new() + zero_cost.id = &"has_id" + zero_cost.cost_mantissa = 0.0 + TestUtils.assert_false(zero_cost.is_valid(), "node with zero cost is invalid") + + +func _test_node_parent_checking() -> void: + print("--- Parent Checking ---") + + var unlocked: Dictionary = {} + unlocked[&"parent_a"] = true + unlocked[&"parent_b"] = false + + var node: PrestigeBuffNode = PrestigeBuffNode.new() + node.id = &"child" + node.parent_ids = [&"parent_a"] + + TestUtils.assert_true(node.all_parents_unlocked(unlocked), "all parents unlocked when all are true") + + node.parent_ids = [&"parent_a", &"parent_b"] + TestUtils.assert_false(node.all_parents_unlocked(unlocked), "not all parents unlocked when one is false") + + node.parent_ids = [&"nonexistent"] + TestUtils.assert_false(node.all_parents_unlocked(unlocked), "missing parent treated as not unlocked") +#endregion + + +#region Catalogue Tests +func _test_catalogue_queries() -> void: + print("--- Catalogue Queries ---") + + var root: PrestigeBuffNode = _make_node(&"root", [], 0, 0.0) + var child_a: PrestigeBuffNode = _make_node(&"child_a", [&"root"], 1, 0.0) + var child_b: PrestigeBuffNode = _make_node(&"child_b", [&"root"], 1, 1.0) + var grandchild: PrestigeBuffNode = _make_node(&"grandchild", [&"child_a", &"child_b"], 2, 0.0) + + var cat: PrestigeBuffCatalogue = PrestigeBuffCatalogue.new() + cat.nodes = [root, child_a, child_b, grandchild] + + TestUtils.assert_equals(root, cat.get_node_by_id(&"root"), "get_node_by_id finds root") + TestUtils.assert_equals(null, cat.get_node_by_id(&"nonexistent"), "get_node_by_id returns null for unknown id") + + var ids: Array[StringName] = cat.get_all_ids() + TestUtils.assert_equals(4, ids.size(), "get_all_ids returns 4 ids") + + var roots: Array[PrestigeBuffNode] = cat.get_root_nodes() + TestUtils.assert_equals(1, roots.size(), "one root node") + TestUtils.assert_equals(&"root", roots[0].id, "root node id is 'root'") + + var children: Array[PrestigeBuffNode] = cat.get_children_of(&"root") + TestUtils.assert_equals(2, children.size(), "root has 2 children") + + var grandkids: Array[PrestigeBuffNode] = cat.get_children_of(&"child_a") + TestUtils.assert_equals(1, grandkids.size(), "child_a has 1 child") +#endregion + + +#region Purchase Flow Tests +func _test_purchase_flow() -> void: + print("--- Purchase Flow ---") + + var scene: PackedScene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + await _wait() + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + print("[ERROR] LevelGameState not found") + return + + # Give ascension currency for purchasing + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + await _wait() + + # Verify catalogue has our two nodes + var cat: PrestigeBuffCatalogue = gs.prestige_buff_catalogue + TestUtils.assert_not_null(cat, "prestige_buff_catalogue is set") + if cat == null: + return + + TestUtils.assert_true(cat.nodes.size() >= 2, "catalogue has at least 2 nodes") + + # Verify nodes initialized in state + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost starts locked") + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost starts locked") + + # Check can_purchase (both are root nodes with no parents, should be purchaseable) + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"gold_boost"), "gold_boost is purchasable") + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"farm_boost"), "farm_boost is purchasable") + + # Check cost + var cost: BigNumber = gs.get_prestige_buff_cost(&"gold_boost") + TestUtils.assert_greater_than(cost.mantissa, 0.0, "gold_boost cost > 0") + + # Purchase gold_boost + var purchased: bool = gs.purchase_prestige_buff(&"gold_boost") + TestUtils.assert_true(purchased, "purchase_prestige_buff returns true") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost is now unlocked") + TestUtils.assert_false(gs.can_purchase_prestige_buff(&"gold_boost"), "cannot repurchase already unlocked node") + + # farm_boost should still be available (both are roots, independent) + TestUtils.assert_true(gs.can_purchase_prestige_buff(&"farm_boost"), "farm_boost is still purchasable") + + # Verify available buffs + var available: Array[PrestigeBuffNode] = gs.get_available_prestige_buffs() + var found_farm: bool = false + var found_gold: bool = false + for node in available: + if node.id == &"farm_boost": + found_farm = true + if node.id == &"gold_boost": + found_gold = true + TestUtils.assert_true(found_farm, "farm_boost appears in available buffs") + TestUtils.assert_false(found_gold, "gold_boost does NOT appear in available buffs (already unlocked)") + + # Test cannot purchase with insufficient currency + gs._prestige_buff_unlocked.clear() + gs._initialize_prestige_buffs() + # Drain ascension currency + var bal: BigNumber = gs.get_currency_amount_by_id(&"ascension") + gs.spend_currency_by_id(&"ascension", bal) + TestUtils.assert_false(gs.can_purchase_prestige_buff(&"gold_boost"), "cannot purchase with no currency") + + # Restore currency, re-unlock gold_boost for later tests + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + gs.purchase_prestige_buff(&"gold_boost") + await _wait() +#endregion + + +func _test_prestige_does_not_reset_prestige_buffs() -> void: + print("--- Prestige Does Not Reset Prestige ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # gold_boost should be unlocked from previous test + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost unlocked before prestige") + + # Give enough currency to prestige + gs.add_currency_by_id(&"gold", BigNumber.new(1.0, 10)) # 1e10 gold + await _wait() + + var pm: PrestigeManager = _game_root.find_child("PrestigeManager") as PrestigeManager + if pm == null: + print("[WARN] PrestigeManager not found; skipping prestige test") + return + + # Perform prestige if possible + if pm.can_prestige(): + pm.perform_prestige() + await _wait() + + # After prestige, gold_boost should STILL be unlocked + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost still unlocked after prestige") + else: + # Manual simulation: call reset_for_prestige directly and verify ascension survives + gs.reset_for_prestige(true, false, [&"ascension"]) + await _wait() + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost still unlocked after reset_for_prestige") +#endregion + + +func _test_multiplier_computation() -> void: + print("--- Multiplier Computation ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # gold_boost is already unlocked (from purchase test). Effect type 1 = GENERATOR_PRODUCTION_MULTIPLIER + var mult: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"goldmine") + TestUtils.assert_greater_than(mult, 1.0, "multiplier for goldmine is > 1.0 with gold_boost unlocked") + + # farm_boost is NOT unlocked, multiplier for farm should be 1.0 + var farm_mult: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"farm") + TestUtils.assert_equals(1.0, farm_mult, "multiplier for farm is 1.0 (farm_boost not unlocked)") + + # Unlock farm_boost and check again + gs.add_currency_by_id(&"ascension", BigNumber.from_float(100.0)) + gs.purchase_prestige_buff(&"farm_boost") + await _wait() + + var farm_mult2: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.CURRENCY_PRODUCTION_MULTIPLIER, &"farm") + TestUtils.assert_greater_than(farm_mult2, 1.0, "multiplier for farm is > 1.0 after unlocking farm_boost") + + # Check that unrelated effect type returns 1.0 + var unrelated: float = gs.get_prestige_buff_multiplier(PrestigeBuffNode.EffectType.RESEARCH_XP_MULTIPLIER, &"") + TestUtils.assert_equals(1.0, unrelated, "unrelated effect type returns 1.0") +#endregion + + +func _test_save_load_roundtrip() -> void: + print("--- Save/Load Roundtrip ---") + + if _game_root == null: + return + + var gs: LevelGameState = _game_root.find_child("LevelGameState") + if gs == null: + return + + # Both gold_boost and farm_boost should be unlocked + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost unlocked before save") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost unlocked before save") + + # Save + gs.save_game() + await _wait() + + # Clear state manually to simulate fresh load + gs._prestige_buff_unlocked.clear() + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost false after clear") + TestUtils.assert_false(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost false after clear") + + # Load + gs.load_game() + await _wait() + + # Both should be restored + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"gold_boost"), "gold_boost restored after load") + TestUtils.assert_true(gs.is_prestige_buff_unlocked(&"farm_boost"), "farm_boost restored after load") +#endregion + + +#region Helpers +func _make_node(id: StringName, parent_ids: Array[StringName], tier: int, x_pos: float) -> PrestigeBuffNode: + var n: PrestigeBuffNode = PrestigeBuffNode.new() + n.id = id + n.display_name = String(id) + n.parent_ids = parent_ids + n.tier = tier + n.x_position = x_pos + n.cost_mantissa = 1.0 + return n + + +func _wait() -> void: + await get_tree().create_timer(0.3).timeout + + +func _print_summary() -> void: + TestUtils.print_result() + passed = TestUtils.get_passed() + failed = TestUtils.get_failed() + + +func get_passed() -> int: + return passed + + +func get_failed() -> int: + return failed diff --git a/tests/test_ascension.gd.uid b/tests/test_ascension.gd.uid new file mode 100644 index 0000000..8f55de3 --- /dev/null +++ b/tests/test_ascension.gd.uid @@ -0,0 +1 @@ +uid://cwmf4h7be356h diff --git a/tests/test_prestige.gd b/tests/test_prestige.gd index 6befcab..0569763 100644 --- a/tests/test_prestige.gd +++ b/tests/test_prestige.gd @@ -6,10 +6,8 @@ var _game_root: Node = null func _ready(): await run() - if failed == 0: - # get_tree().quit(0) // Let test_runner handle it - else: - # get_tree().quit(1) // Let test_runner handle it + # Let test_runner handle exit + pass func run(): print("\n=== TEST: Prestige Mechanics ===\n")