# Research Feature Implementation Plan ## Overview A production-based research system where: - **Generator production → Research XP → Auto-level → Production multiplier** - **Research buffs** (purchased with currency) increase XP gain multiplicatively - **One research track per generator** with a single associated buff - **Progress bar shows percentage** to next level - **Buff display shows name and effect** - **Research tracks start unlocked** - **Resets on prestige** (levels and XP lost) --- ## System Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ LevelGameState │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ ResearchXPTracker (child node) │ │ │ │ - Receives production_tick signals │ │ │ │ - Applies buff multipliers │ │ │ │ - Awards XP to research tracks │ │ │ │ - Auto-levels when thresholds reached │ │ │ └─────────────────────────────────────────────────────────┘ │ │ - research_xp: Dictionary │ │ - research_levels: Dictionary │ │ - research_catalogue: ResearchCatalogue │ └─────────────────────────────────────────────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ CurrencyGen │ │ ResearchData │ │ ResearchPanel │ │ (Gold Mine) │──│ (Resource) │──│ (Always visible)│ │ - produces gold │ │ - XP config │ │ - Shows all │ │ - emits signal │ │ - Multiplier │ │ research │ └─────────────────┘ └─────────────────┘ │ - Progress bars │ └─────────────────┘ ``` --- ## Data Flow ``` Generator Production (currency_per_cycle) │ ▼ ┌──────────────────────┐ │ ResearchXPTracker │ │ 1. base_xp = currency_produced * xp_per_currency_produced │ 2. actual_xp = base_xp * buff_multiplier │ 3. research_xp += actual_xp │ 4. Check level threshold → auto-level │ 5. Emit research_level_up signal └──────────────────────┘ │ ▼ ┌──────────────────────┐ │ ResearchState │ │ {xp: 150.5, level: 3}│ └──────────────────────┘ │ ▼ ┌──────────────────────┐ │ CurrencyGen │ │ get_research_multiplier() → 1.3 (30% bonus) └──────────────────────┘ │ ▼ ┌──────────────────────┐ │ Production *= 1.3 │ └──────────────────────┘ ``` --- ## Component Specifications ### 1. ResearchData (core/generator/research_data.gd) **Purpose:** Resource defining research configuration for a generator **Location:** `res://core/generator/research_data.gd` **Key Properties:** ```gdscript @export var id: StringName # Unique research identifier @export var generator_id: StringName # Which generator this research affects @export var name: String # Display name @export_multiline var description: String = "" # Description text @export var icon: Texture2D # Icon for UI # XP Configuration @export var xp_per_currency_produced: float = 0.01 # XP per 1 currency unit produced @export var base_xp_required: float = 100.0 # XP needed for level 1 @export var xp_growth_multiplier: float = 1.5 # XP requirement growth per level # Multiplier Configuration @export var base_multiplier: float = 1.0 # Production multiplier at level 0 @export var multiplier_per_level: float = 0.1 # +10% production per level # Associated Buff (single buff per research track) @export var associated_buff_id: StringName # Buff that increases XP gain ``` **Key Methods:** ```gdscript func get_xp_required_for_level(level: int) -> float: """XP needed to reach this level from previous""" return base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) func get_total_xp_for_level(level: int) -> float: """Total cumulative XP needed to reach this level from level 0""" if level <= 0: return 0.0 var total: float = 0.0 for l in range(1, level + 1): total += get_xp_required_for_level(l) return total func get_level_for_xp(xp: float) -> int: """Calculate level from total accumulated XP""" var level: int = 0 var cumulative_xp: float = 0.0 while true: var next_xp: float = cumulative_xp + get_xp_required_for_level(level + 1) if xp < next_xp: break level += 1 cumulative_xp = next_xp return level func get_multiplier_for_level(level: int) -> float: """Production multiplier at given level""" return base_multiplier + (multiplier_per_level * float(maxi(level, 0))) func get_xp_progress(xp: float) -> float: """Returns 0.0-1.0 progress to next level""" if xp <= 0.0: return 0.0 var current_level: int = get_level_for_xp(xp) if current_level < 0: return 0.0 var xp_at_current_level: float = get_total_xp_for_level(current_level) var xp_needed_for_next: float = get_xp_required_for_level(current_level + 1) var xp_in_current_level: float = xp - xp_at_current_level return clampf(xp_in_current_level / xp_needed_for_next, 0.0, 1.0) ``` --- ### 2. ResearchCatalogue (core/research/research_catalogue.gd) **Purpose:** Resource holding all research configurations **Location:** `res://core/research/research_catalogue.gd` **Structure:** ```gdscript class_name ResearchCatalogue extends Resource @export var research_entries: Array[ResearchData] = [] func get_research_by_id(research_id: StringName) -> ResearchData: for entry in research_entries: if entry.id == research_id: return entry return null func get_research_by_generator_id(generator_id: StringName) -> ResearchData: for entry in research_entries: if entry.generator_id == generator_id: return entry return null func get_all_research() -> Array[ResearchData]: return research_entries.duplicate() ``` **Usage:** Referenced directly by LevelGameState via `@export var research_catalogue: ResearchCatalogue` --- ### 3. ResearchXPTracker (core/research/research_xp_tracker.gd) **Purpose:** Child of LevelGameState; accumulates XP from production and applies buff multipliers **Location:** `res://core/research/research_xp_tracker.gd` **Signals:** ```gdscript signal research_xp_changed(research_id: StringName, new_xp: float) signal research_level_up(research_id: StringName, old_level: int, new_level: int) ``` **Key Methods:** ```gdscript func add_xp_with_buffs(research_id: StringName, base_xp: float) -> float: """Apply buff multipliers and award XP. Returns actual XP after buffs.""" if game_state == null: return 0.0 var actual_xp: float = base_xp # Apply research-specific buff multiplier var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id) actual_xp *= buff_multiplier # Award XP (handles auto-leveling internally) game_state.add_research_xp(research_id, actual_xp) return actual_xp func _on_generator_produced(generator_id: StringName, amount: BigNumber) -> void: var research: ResearchData = _get_research_for_generator(generator_id) if research == null: return var amount_float: float = amount.to_float() if amount_float <= 0.0: return var base_xp: float = research.xp_per_currency_produced * amount_float if base_xp <= 0.0: return add_xp_with_buffs(research.id, base_xp) ``` **Connection:** Connected to `LevelGameState` via `generator_produced` signal --- ### 4. LevelGameState Extensions (core/level_game_state.gd) **New State Variables:** ```gdscript var research_xp: Dictionary = {} # {research_id: xp_float} var research_levels: Dictionary = {} # {research_id: level_int} var research_tracker: ResearchXPTracker ``` **New Signals:** ```gdscript signal research_xp_changed(research_id: StringName, new_xp: float) signal research_level_up(research_id: StringName, old_level: int, new_level: int) ``` **New Properties:** ```gdscript @export var research_catalogue: ResearchCatalogue ``` **New Methods:** ```gdscript func register_research(research_id: StringName) -> void: if not research_xp.has(research_id): research_xp[research_id] = 0.0 if not research_levels.has(research_id): research_levels[research_id] = 0 func get_research_xp(research_id: StringName) -> float: return research_xp.get(research_id, 0.0) func get_research_level(research_id: StringName) -> int: return research_levels.get(research_id, 0) func get_research_multiplier(research_id: StringName) -> float: var level: int = get_research_level(research_id) var research: ResearchData = _get_research_data(research_id) if research == null: return 1.0 return research.get_multiplier_for_level(level) func add_research_xp(research_id: StringName, xp: float) -> void: var old_level: int = get_research_level(research_id) research_xp[research_id] = research_xp.get(research_id, 0.0) + xp var new_level: int = _get_research_data(research_id).get_level_for_xp(research_xp[research_id]) if new_level > old_level: research_levels[research_id] = new_level research_level_up.emit(research_id, old_level, new_level) research_xp_changed.emit(research_id, research_xp[research_id]) func get_research_xp_multiplier(research_id: StringName) -> float: """Returns multiplicative buff for research XP gain""" var multiplier: float = 1.0 var research: ResearchData = _get_research_data(research_id) if research == null: return 1.0 # Find associated buff and apply its multiplier if not research.associated_buff_id.is_empty(): var buff: GeneratorBuffData = get_buff(research.associated_buff_id) if buff != null and is_buff_active(buff.id): var level: int = get_buff_level(buff.id) multiplier *= buff.get_effect_multiplier(level) return multiplier func _get_research_data(research_id: StringName) -> ResearchData: if research_catalogue == null: return null return research_catalogue.get_research_by_id(research_id) ``` **Save/Load Integration:** ```gdscript const RESEARCH_XP_KEY = "research_xp" const RESEARCH_LEVELS_KEY = "research_levels" # In save_game(): var save_data = { # ... existing fields ... RESEARCH_XP_KEY: research_xp.duplicate(), RESEARCH_LEVELS_KEY: research_levels.duplicate(), } # In load_game(): if parsed_data.has(RESEARCH_XP_KEY): research_xp = parsed_data.get(RESEARCH_XP_KEY, {}) if parsed_data.has(RESEARCH_LEVELS_KEY): research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) # In reset_for_prestige(): research_xp.clear() research_levels.clear() ``` **Initialization in _ready():** ```gdscript # After initializing other state, create research tracker research_tracker = ResearchXPTracker.new() add_child(research_tracker) research_tracker.game_state = self # Initialize research state from catalogue if research_catalogue: for research in research_catalogue.get_all_research(): register_research(research.id) ``` --- ### 5. CurrencyGeneratorData Extensions (core/generator/currency_generator_data.gd) **New Property:** ```gdscript @export var research_data: ResearchData # Link to research configuration ``` --- ### 6. CurrencyGenerator Extensions (core/generator/currency_generator.gd) **In `_grant_cycle_income()`:** ```gdscript func _grant_cycle_income(cycle_count: int) -> void: if data == null or cycle_count <= 0: return var effective_run_multiplier: float = get_effective_auto_run_multiplier() var per_cycle: float = data.production_per_cycle(owned, effective_run_multiplier, purchased_count) if per_cycle <= 0.0: return var produced: BigNumber = BigNumber.from_float(per_cycle).multiply(BigNumber.from_float(float(cycle_count))) _add_currency(produced) production_tick.emit(produced, cycle_count, owned) # NEW: Award research XP if data.research_data != null: var xp: float = data.research_data.xp_per_currency_produced * produced.to_float() research_tracker.add_xp_with_buffs(data.research_data.id, xp) ``` **New Method:** ```gdscript func get_research_multiplier() -> float: if data == null or data.research_data == null: return 1.0 if game_state == null: return 1.0 return game_state.get_research_multiplier(data.research_data.id) ``` **Modify `get_effective_auto_run_multiplier()`:** ```gdscript func get_effective_auto_run_multiplier() -> float: return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() ``` **In `_ready()`:** ```gdscript # Get reference to research tracker if game_state: research_tracker = game_state.research_tracker ``` --- ### 7. GeneratorBuffData Extensions (core/generator/generator_buff_data.gd) **New Enum Value:** ```gdscript enum BuffKind { AUTO_PRODUCTION_MULTIPLIER, MANUAL_CLICK_MULTIPLIER, RESOURCE_PURCHASE, RESEARCH_XP_MULTIPLIER # NEW: Increases research XP gain } ``` **New Properties (for RESEARCH_XP_MULTIPLIER buffs):** ```gdscript @export var research_target_id: StringName # Which research track this buff affects @export var xp_multiplier_per_level: float = 0.1 # +10% XP per level (uses get_effect_multiplier) ``` **Note:** Buff names are configured in the `text` field of each `GeneratorBuffData` instance --- ### 8. ResearchRow (core/research/research_row.tscn + .gd) **Location:** - Script: `res://core/research/research_row.gd` - Scene: `res://core/research/research_row.tscn` **UI Layout:** ``` ResearchRow (HBoxContainer) ├── Icon (TextureRect) ├── VBoxContainer │ ├── NameLabel ("Gold Mine Research") │ └── LevelLabel ("Level 3") ├── ProgressBar (with percentage label "75%") ├── MultiplierLabel ("+30%") └── BuffLabel ("Mining Expert: +20% XP") ``` **Script Methods:** ```gdscript func setup(research: ResearchData) -> void: """Initialize row with research data""" _research = research _name_label.text = research.name _icon.texture = research.icon func update_display(level: int, progress: float, multiplier: float, xp_current: float, xp_needed: float) -> void: """Update UI elements""" _level_label.text = "Level %d" % level _progress_bar.value = progress * 100 _progress_label.text = "%d%%" % int(progress * 100) _multiplier_label.text = "+%d%%" % int((multiplier - 1.0) * 100) # Update buff label if buff is active if _research.associated_buff_id != &"": var buff: GeneratorBuffData = game_state.get_buff(_research.associated_buff_id) if buff != null and game_state.is_buff_active(buff.id): var level: int = game_state.get_buff_level(buff.id) var effect: float = buff.get_effect_multiplier(level) - 1.0 _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] ``` --- ### 9. ResearchPanel (core/research/research_panel.tscn + .gd) **Location:** - Script: `res://core/research/research_panel.gd` - Scene: `res://core/research/research_panel.tscn` **Layout:** ``` ResearchPanel (ScrollContainer) ├── VBoxContainer │ ├── TitleLabel ("Research") │ └── ResearchRows (VBoxContainer) │ └── ResearchRow (for each research track) ``` **Script Methods:** ```gdscript func _ready() -> void: _game_state = find_parent("LevelGameState") if _game_state == null: return _game_state.research_xp_changed.connect(_on_research_xp_changed) _game_state.research_level_up.connect(_on_research_level_up) _build_research_rows() _refresh_all() func _build_research_rows() -> void: for child in _research_rows_container.get_children(): child.queue_free() _research_rows.clear() if _game_state.research_catalogue: for research in _game_state.research_catalogue.get_all_research(): var row = RESEARCH_ROW_SCENE.instantiate() _research_rows_container.add_child(row) row.setup(research) _research_rows[research.id] = row func _refresh_all() -> void: for row in _research_rows.values(): _refresh_row(row) func _refresh_row(row) -> void: var research_id: StringName = row.get_research_id() var xp: float = _game_state.get_research_xp(research_id) var level: int = _game_state.get_research_level(research_id) var research: ResearchData = _game_state.research_catalogue.get_research_by_id(research_id) if research != null: var progress: float = research.get_xp_progress(xp) var multiplier: float = research.get_multiplier_for_level(level) var xp_current: float = xp - research.get_total_xp_for_level(level) var xp_needed: float = research.get_xp_required_for_level(level + 1) row.update_display(level, progress, multiplier, xp_current, xp_needed) func _on_research_xp_changed(research_id: StringName, _new_xp: float) -> void: if _research_rows.has(research_id): _refresh_row(_research_rows[research_id]) func _on_research_level_up(research_id: StringName, _old_level: int, _new_level: int) -> void: if _research_rows.has(research_id): _refresh_row(_research_rows[research_id]) ``` --- ## Implementation Sequence | Step | Task | Files | Priority | Status | |------|------|-------|----------|--------| | 1 | Create `ResearchData` resource class | `core/generator/research_data.gd` | High | ⬜ | | 2 | Create `ResearchCatalogue` resource class | `core/research/research_catalogue.gd` | High | ⬜ | | 3 | Create `ResearchXPTracker` node class | `core/research/research_xp_tracker.gd` | High | ⬜ | | 4 | Add research state to `LevelGameState` | `core/level_game_state.gd` | High | ⬜ | | 5 | Add `RESEARCH_XP_MULTIPLIER` buff kind | `core/generator/generator_buff_data.gd` | High | ⬜ | | 6 | Add `research_data` export to `CurrencyGeneratorData` | `core/generator/currency_generator_data.gd` | High | ⬜ | | 7 | Connect production to research XP in `CurrencyGenerator` | `core/generator/currency_generator.gd` | High | ⬜ | | 8 | Create `ResearchRow` scene & script | `core/research/research_row.tscn`, `.gd` | Medium | ⬜ | | 9 | Create `ResearchPanel` scene & script | `core/research/research_panel.tscn`, `.gd` | Medium | ⬜ | | 10 | Add save/load support for research state | `core/level_game_state.gd` | Medium | ⬜ | | 11 | Add prestige reset for research | `core/level_game_state.gd` | Medium | ⬜ | | 12 | Create sample `ResearchData` resource | `doc/gyms/tiny_sword/resources/` | Low | ⬜ | | 13 | Test integration | Manual testing | Low | ⬜ | --- ## Design Decisions (Confirmed) | Decision | Value | |----------|-------| | ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | | ResearchCatalogue location | `core/research/research_catalogue.gd` (resource, referenced directly) | | ResearchXPTracker location | Child of `LevelGameState` (receives signals via connection) | | Progress bar display | Percentage format (e.g., "75%") | | Buff display | Shows buff name and effect (e.g., "Mining Expert: +20% XP") | | Research unlock | Starts unlocked | | XP formula | Production-based: `XP = currency_produced * xp_per_currency_produced` | | Level-up | Auto-level when XP threshold reached | | Buffs | Single buff per research track, multiplicative XP bonus | | Prestige | Research levels and XP reset to 0 | | ResearchData location | `res://doc/gyms/tiny_sword/resources/` | | ResearchPanel visibility | Always visible (like GeneratorPanel) | --- ## Formulas ### XP Calculation ``` base_xp = currency_produced * xp_per_currency_produced actual_xp = base_xp * buff_multiplier ``` ### Level Progression ``` xp_required_for_level(n) = base_xp_required * (xp_growth_multiplier ^ (n - 1)) total_xp_for_level(n) = sum(xp_required_for_level(i) for i in 1..n) level = get_level_for_xp(total_xp) ``` ### Production Multiplier ``` multiplier = base_multiplier + (multiplier_per_level * level) effective_production = base_production * multiplier ``` ### Buff XP Multiplier ``` xp_multiplier = 1.0 for each active buff targeting this research: xp_multiplier *= buff.get_effect_multiplier(level) actual_xp = base_xp * xp_multiplier ``` --- ## Notes - **ResearchData instances** should be created as `.tres` resources in `doc/gyms/tiny_sword/resources/` - **Associated buffs** should be created as `GeneratorBuffData` with `BuffKind.RESEARCH_XP_MULTIPLIER` - **Buff names** are configured in the `text` field of each `GeneratorBuffData` - **ResearchPanel** should be added as a child node in the main game scene (always visible) - **Save format version** should be incremented to 4 when adding research support --- ## Testing Checklist - [ ] Generator produces currency → research XP increases - [ ] Buff multipliers correctly applied to XP gain - [ ] Auto-level triggers at correct XP thresholds - [ ] Production multiplier updates after level-up - [ ] ResearchPanel displays correct progress and level - [ ] Save/load preserves research state - [ ] Prestige resets research levels and XP - [ ] Multiple research tracks work independently - [ ] Buff purchase correctly affects XP gain --- ## Dependencies - `CurrencyGenerator` must emit `production_tick` signal - `GeneratorBuffData` must support `RESEARCH_XP_MULTIPLIER` kind - `LevelGameState` must support buff tracking and signals - Save format version must be updated