Decouples core systems from centralized globals in favor of catalogue-based architecture and data-driven content. Key changes: - Prestige: node-based buff tech tree, graph panel, progress bar - Research: multi-worker system, per-generator research, xp generation - Ascension: meta-currency layer via multi-currency prestige resets - Buffs: refactored as generator-independent via catalogues - UI: currency panel, edge-scrolling camera + zoom, current-goal panel - Alchemy Tower: crafting building with recipe/cost system - Testing: 7 test suites (ascension, prestige, research, goals) - Content: migrated from hardcoded idles/ to gym format (tiny_sword)
12 KiB
TODO
Ascension Buff Graph — Needs Review Before Implementation
Goal
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).
- 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 graph.
| Field | Type | Notes |
|---|---|---|
id |
StringName |
Unique node ID |
display_name |
String |
UI label |
description |
String (multiline) |
Tooltip |
icon |
Texture2D |
Visual |
parent_ids |
Array[StringName] |
Prerequisites (DAG — multiple parents allowed) |
effect_type |
enum EffectType |
What this buff does |
effect_value |
float |
Effect magnitude |
target_id |
StringName |
Target generator/currency for type-specific effects (empty = global) |
cost_mantissa |
float |
Ascension currency cost |
cost_exponent |
int |
|
tier |
int |
Row in graph UI (visual grouping) |
x_position |
float |
Column in graph UI |
Effect types (extensible enum):
GLOBAL_PRODUCTION_MULTIPLIER → multiply all generator output
GENERATOR_PRODUCTION_MULTIPLIER → multiply one generator's output
CURRENCY_PER_RUN → start each prestige with this currency
COST_REDUCTION → reduce all generator purchase costs
PRESTIGE_GAIN_MULTIPLIER → earn more ascension currency per prestige
GENERATOR_COUNT_MULTIPLIER → scaling based on owned generator count
RESEARCH_XP_MULTIPLIER → faster research leveling
AscensionBuffCatalogue (Resource)
Flat list of nodes. The graph emerges from parent_ids. Follows existing catalogue pattern (BuffCatalogue, GoalCatalogue, etc.).
| Field | Type |
|---|---|
nodes |
Array[AscensionBuffNode] |
State in LevelGameState
@export var ascension_buff_catalogue: AscensionBuffCatalogue
var _ascension_buff_unlocked: Dictionary = {} # {StringName: bool} — node_id → unlocked
- Never cleared by
reset_for_prestige()(unlike_buff_levelsfor generator buffs, which IS cleared). - Normalized/sanitized on load (like generator states).
Save Format
- Bump
CURRENT_SAVE_FORMAT_VERSIONfrom 7 → 8. - 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, 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
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() 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 unlock node
→ can_purchase_ascension_buff(node_id)
→ All parent_ids unlocked?
→ Not already unlocked?
→ Enough ascension currency?
→ YES: spend_currency_by_id(ascension_currency, cost)
→ _ascension_buff_unlocked[node_id] = true
→ emit ascension_buff_unlocked
→ save_game()
Example Graph Content (Illustrative)
Tier 0 (roots):
strong_start → +10 gold/run cost: 1 parents: []
efficiency → +10% global prod cost: 1 parents: []
thrifty → -5% gen costs cost: 2 parents: []
Tier 1:
better_start → +100 gold/run cost: 3 parents: [strong_start]
enhanced_efficiency → +25% global prod cost: 3 parents: [efficiency]
double_dip → +50% gold from forestry cost: 5 parents: [strong_start, efficiency]
Tier 2:
power_surge → +100% global prod cost: 10 parents: [better_start, enhanced_efficiency]
Files to Create/Modify
| File | Action | Purpose |
|---|---|---|
core/ascension/ascension_buff_node.gd |
Create | Node resource |
core/ascension/ascension_buff_catalogue.gd |
Create | Catalogue resource |
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 |
Implementation Plan
Step 1: core/ascension/ascension_buff_node.gd
Create new Resource class.
class_name AscensionBuffNode extends Resourceenum EffectTypewith all seven types from the data structure table@exportfields: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— returnsBigNumber.new(cost_mantissa, cost_exponent)has_parents() → bool— returnsnot parent_ids.is_empty()all_parents_unlocked(unlocked: Dictionary) → bool— checks every id inparent_idsis in the dict astrueis_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 searchget_all_ids() → Array[StringName]— collect non-empty idsget_root_nodes() → Array[AscensionBuffNode]— nodes with emptyparent_idsget_children_of(parent_id: StringName) → Array[AscensionBuffNode]— nodes that list this id inparent_ids
Step 3: Modify core/level_game_state.gd
3a. Add export, state, signal, constants
- Add
@export var ascension_buff_catalogue: AscensionBuffCataloguein 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_VERSIONfrom 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 tofalse
3c. Add save serialization
In save_game(), add:
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:
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 asfalse
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
BigNumberfrom node's mantissa/exponent - Call
spend_currency_by_id(ascension_currency_id, cost)— useget_ascension_currency_id()helper - Set
_ascension_buff_unlocked[buff_id] = true - Emit
ascension_buff_unlocked.emit(buff_id) save_game()- Return true
- Guard: if not
-
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) → boolreturn _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 node, return
-
get_ascension_buff_multiplier(effect_type: int, target_id: StringName = &"") → float- Iterate all unlocked nodes matching the effect type
- Multiply their
effect_valuetogether - If
target_idis non-empty, filter nodes whosetarget_idmatches 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.
- Look up the ascension currency ID from PrestigeManager's config, or use a well-known ID like
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@onreadyreferences to:- Title label
ScrollContainer/VBoxContainerfor tier rows- Ascension currency balance label
_game_state: LevelGameState— found viafind_parent("LevelGameState")- In
_ready():- Connect
game_state.ascension_buff_unlocked→ refresh - Connect
game_state.currency_changed→ refresh ascension currency label - Call
_build_graph()
- Connect
_build_graph():- Clear existing rows
- Group all catalogue nodes by
tier, sort tiers ascending - For each tier, create an
HBoxContainerrow - 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/Panelshowing:- 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_unlockedandcurrency_changedto re-evaluate state
- A small
- 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.tresresource with nodes - Wire it into the
LevelGameStatenode intiny_sword.tscnvia editor
Resolved Questions
- Effect stacking: Multiply — consistent with the existing generator buff multiplier pattern.
- UI placement: A separate screen.
- Ascension currency spending: Spend through
LevelGameState.spend_currency_by_id()— ascension currency is already in the currency catalogue. - Content: None at the moment — define later.