13 KiB
Refactoring Plan: Decoupled Buff System
Overview
Decouple buffs from generators to enable:
- Global buff levels shared across all targets
- Multi-target buffs (including wildcards for all current + future generators)
- Automatic buff activation via goal achievement
- Clean separation between buff registry and generator state
- Flexible UI patterns (global buff shop, per-generator panels, debug controls)
Current State (Problem)
- Buffs stored in
CurrencyGeneratorData.buffsarray (tight coupling) - Buff levels tracked per-generator in
GameState.generator_buff_levels[generator_id][buff_id] - Buff resources have no
target_ids- don't know which generators they affect - Cannot create global buffs or multi-target buffs
- Buff logic scattered across
CurrencyGeneratorandGameState
Target State (Solution)
- Buff registry in
GameState._buff_definitions(global, auto-discovered fromres://sandbox/buffs/) - Buff levels stored globally:
GameState._buff_levels[buff_id] - Buff resources have
target_ids: Array[StringName](supports["*"]for wildcards) - Generators query
GameState.get_buffs_for_generator(generator_id)for applicable buffs - Multipliers calculated in
GameState.get_effective_multiplier(generator_id, kind)
Implementation Phases
Phase 1: Enhance GeneratorBuffData (Safe, Non-Breaking)
File: core/generator/generator_buff_data.gd
Add:
@export var target_ids: Array[StringName] = []
func targets_generator(generator_id: StringName) -> bool:
if target_ids.is_empty():
return false
if "*" in target_ids:
return true
return generator_id in target_ids
func get_target_generators() -> Array[StringName]:
return target_ids.duplicate()
Timeline: 1-2 hours Risk: Low (additive change, backward compatible) Testing: Verify existing buffs work with empty target_ids
Phase 2: Extend GameState with Buff Registry
File: core/game_state.gd
Add state variables:
var _buff_definitions: Dictionary = {} # buff_id → GeneratorBuffData
var _buff_levels: Dictionary = {} # buff_id → int
var _buff_unlocked: Dictionary = {} # buff_id → bool
var _buff_active: Dictionary = {} # buff_id → bool (auto-set when unlocked)
signal buff_unlocked_changed(buff_id: StringName, unlocked: bool)
signal buff_level_changed(buff_id: StringName, new_level: int)
Add registry methods:
func register_buff(buff: GeneratorBuffData) -> void
func get_buff(buff_id: StringName) -> GeneratorBuffData
func get_all_buffs() -> Array[GeneratorBuffData]
func get_buff_level(buff_id: StringName) -> int
func set_buff_level(buff_id: StringName, level: int) -> void
func is_buff_unlocked(buff_id: StringName) -> bool
func set_buff_unlocked(buff_id: StringName, unlocked: bool) -> void
func is_buff_active(buff_id: StringName) -> bool
func get_generators_targeted_by(buff_id: StringName) -> Array[StringName]
func get_buffs_for_generator(generator_id: StringName) -> Array[GeneratorBuffData]
func get_effective_multiplier(generator_id: StringName, kind: int) -> float
Add goal evaluation helper:
func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void
Timeline: 4-6 hours Risk: High (core state management) Testing: Unit tests for each method, verify goal-based unlocks
Phase 3: Update Save/Load System
File: core/game_state.gd
New save format (v2):
{
"save_format_version": 2,
"currencies": { ... },
"generator_states": { ... },
"buff_definitions": { ... },
"buff_levels": { ... },
"buff_unlocked": { ... },
"buff_active": { ... },
"last_save_time": 1234567890
}
Add serialization helpers:
func _serialize_buff_definitions() -> Dictionary
func _serialize_buff_data(buff: GeneratorBuffData) -> Dictionary
func _deserialize_buff_definitions(raw: Variant) -> void
Version check in load_game():
if version < 2:
push_error("Save file version %d too old. Please start fresh." % version)
return
Timeline: 3-4 hours Risk: High (data loss if bugs exist) Testing: Save/load cycles, verify buff state persists, test version rejection
Phase 4: Update CurrencyGenerator
File: core/generator/currency_generator.gd
Changes:
# Replace:
func get_buffs() -> Array[GeneratorBuffData] # Now queries GameState
func _get_multiplier_for_buff_kind(kind: int) -> float: # Delegates to GameState
return GameState.get_effective_multiplier(_generator_id, kind)
# Update _register_buffs() to only evaluate unlock goals
func _register_buffs() -> void:
for buff in get_buffs():
if buff == null:
continue
var buff_id: StringName = buff.id
if not GameState.get_buff(buff_id):
GameState.register_buff(buff)
_try_unlock_buff_from_goal(buff)
Timeline: 2-3 hours Risk: Medium (runtime behavior change) Testing: Verify production multipliers, buff purchases, goal unlocks
Phase 5: Update PrestigeManager
File: core/prestige/prestige_manager.gd
Add buff reset logic:
func perform_prestige() -> bool:
# ... existing logic ...
GameState.reset_for_prestige(true, false)
_reset_all_buff_levels()
# ... rest of logic ...
func _reset_all_buff_levels() -> void:
for buff_id in GameState._buff_levels.keys():
GameState.set_buff_level(buff_id, 0)
GameState.set_buff_unlocked(buff_id, false)
Timeline: 1-2 hours Risk: Low Testing: Verify buff levels reset after prestige
Phase 6: Update UI Components
File: core/generator/generator_container.gd
Minimal changes needed - GeneratorPanel._build_buff_rows() should work automatically since it uses _generator.get_buffs().
Optional: Add Global Buff Panel
Create res://ui/global_buff_panel.gd for testing/future UI:
extends PanelContainer
func _build_buff_list() -> void:
for buff in GameState.get_all_buffs():
if GameState.is_buff_hidden(buff.id):
continue
# Create UI row showing buff state
Timeline: 1-2 hours (optional) Risk: Low Testing: Verify buff UI updates on level/unlock changes
Phase 7: Data Migration (One-Time Manual)
Manual steps:
-
Update
res://sandbox/buffs/*.tres:- Open each buff in Godot editor
- Add
target_idsarray (e.g.,["farm", "forestry"]or["*"])
-
Update
res://sandbox/generators/*.tres:- Remove
buffsarray field - (Optional) Add
expected_buff_idsfor documentation
- Remove
-
Delete old saves:
user://idle_save.json- starts fresh with v2 format
Timeline: 1 hour Risk: Low (data files only) Testing: Load game, verify all buffs work correctly
Phase 8: Buff Auto-Discovery System (Bonus Feature)
New file: core/buff_database.gd
extends Node
const BUFF_DIRECTORY_PATH: String = "res://sandbox/buffs"
var _buff_by_id: Dictionary = {}
var _initialized: bool = false
func _ready() -> void:
_initialize_buff_catalog()
func _initialize_buff_catalog() -> void:
_buff_by_id.clear()
var dir: DirAccess = DirAccess.open(BUFF_DIRECTORY_PATH)
if dir == null:
push_warning("Unable to open buff directory: %s" % BUFF_DIRECTORY_PATH)
return
dir.list_dir_begin()
var entry_name: String = dir.get_next()
while not entry_name.is_empty():
if not dir.current_is_dir() and entry_name.ends_with(".tres"):
var path: String = "%s/%s" % [BUFF_DIRECTORY_PATH, entry_name]
var buff: GeneratorBuffData = load(path) as GeneratorBuffData
if buff != null:
GameState.register_buff(buff)
entry_name = dir.get_next()
dir.list_dir_end()
func get_all_buffs() -> Array[GeneratorBuffData]:
return _buff_by_id.values()
func get_buff(buff_id: StringName) -> GeneratorBuffData:
return _buff_by_id.get(buff_id)
Add BuffDatabase to project.godot autoloads
Timeline: 2-3 hours Risk: Low Testing: Verify all buffs auto-load at runtime
Timeline Summary
| Phase | Duration | Risk |
|---|---|---|
| Phase 1: GeneratorBuffData | 1-2h | Low |
| Phase 2: GameState Extension | 4-6h | High |
| Phase 3: Save/Load Update | 3-4h | High |
| Phase 4: CurrencyGenerator | 2-3h | Medium |
| Phase 5: PrestigeManager | 1-2h | Low |
| Phase 6: UI Updates | 1-2h | Low |
| Phase 7: Data Migration | 1h | Low |
| Phase 8: Buff Auto-Discovery | 2-3h | Low |
| Total | 15-23 hours |
Testing Checklist
- Buff with
target_ids: ["farm", "forestry"]applies to both - Buff with
target_ids: ["*"]applies to all current + future generators - Buying a buff level increases global level (not per-generator)
- Buff unlock goal automatically activates buff when met
- Generator production reflects active buff multipliers
- Save/load preserves buff levels and unlock states
- Prestige resets all buff levels to 0
- Adding new generator auto-includes wildcard buffs
- Multiple buff kinds stack multiplicatively
- Inactive (locked) buffs don't apply multipliers
- Buff definitions auto-load from
res://sandbox/buffs/ - New buff resources appear in registry without code changes
Rollback Plan
If issues arise:
- Revert code changes - Use git to restore previous state
- Delete user://idle_save.json - Start fresh with v1 format
- Restore buff/generator .tres files - Remove target_ids, restore buffs arrays
- Remove GameState buff methods - Restore original API
Note: No save migration code - breaking change requires fresh starts
Success Criteria
- All generators produce correct amounts with active buffs
- Buff UI can display global buff list or per-generator view
- Save files load correctly and preserve buff state
- Prestige resets buff levels as expected
- New buffs can be added by dropping .tres files in sandbox/buffs/
- Wildcard buffs auto-apply to new generators
- No performance degradation in _process loop
Future Enhancements (Post-Implementation)
- Buff synergies: "If you have X buff, Y buff gets +10%"
- Buff tiers: Common/rare/legendary classification
- Conditional buffs: "Only active if generator_ownership > 10"
- Buff stacking rules: Additive vs multiplicative vs highest-only
- Prestige-only buffs: Permanent buffs unlocked via prestige currency
- Buff UI shop: Global buff purchase interface
- Buff preview: Show "what-if" calculations before purchase
Technical Notes
Architecture Decisions
| Decision | Implementation |
|---|---|
| Buff Levels | Global (shared across all targets) |
| Buff Registry | Auto-discovered from res://sandbox/buffs/ |
| Wildcards | ["*"] matches all current AND future generators |
| Default State | Inactive/unlocked = false, auto-activates on goal met |
| Persistence | Buff definitions serialized to save (by id) |
| Prestige | All buff levels reset to 0 |
| Activation | Automatic via goals, no manual toggle |
Key API Changes
Old API (per-generator state):
GameState.register_generator_buff(generator_id, buff_id, level)
GameState.get_generator_buff_level(generator_id, buff_id)
GameState.is_generator_buff_unlocked(generator_id, buff_id)
New API (global buff state):
GameState.register_buff(buff: GeneratorBuffData)
GameState.get_buff(buff_id) -> GeneratorBuffData
GameState.get_buff_level(buff_id) -> int
GameState.is_buff_unlocked(buff_id) -> bool
GameState.get_buffs_for_generator(generator_id) -> Array[GeneratorBuffData]
GameState.get_effective_multiplier(generator_id, kind) -> float
Save Format Migration
Version 1 (old - incompatible):
{
"currencies": { ... },
"generator_states": { ... },
"generator_buff_levels": { "farm": { "farm_flux": 5 } },
"generator_buff_unlocked": { "farm": { "farm_flux": true } }
}
Version 2 (new):
{
"save_format_version": 2,
"currencies": { ... },
"generator_states": { ... },
"buff_definitions": { "farm_flux": { ... } },
"buff_levels": { "farm_flux": 5 },
"buff_unlocked": { "farm_flux": true },
"buff_active": { "farm_flux": true }
}
Dependencies
- Godot 4.6+ (current project version)
- Existing
BigNumbersystem for currency math - Existing
GameStateautoload - Existing
GoalDataandGoalRequirementDatafor unlock conditions
Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Save format incompatibility | Clear version check, error on old saves |
| Performance in _process loop | Cache multiplier calculations, only recalc on buff changes |
| Buff definition loading failures | Graceful degradation, warn but continue |
| Wildcard expansion at runtime | Explicit registration in generator _ready() |
| Multiple buffs of same kind | Multiplicative stacking (document clearly) |
Approval Status
- Architecture approved
- Implementation phases reviewed
- Timeline accepted
- Risks understood
- Ready to begin Phase 1
Last updated: Initial planning document Status: Pending implementation approval