Files
idle/core/research/TODO.md
2026-04-18 10:50:44 +02:00

22 KiB

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                           │
│  - research_xp: Dictionary                                       │
│  - research_levels: Dictionary                                   │
│  - research_catalogue: ResearchCatalogue                         │
│  - add_research_xp() applies buffs via ResearchBuffCalculator   │
└─────────────────────────────────────────────────────────────────┘
							  │
		  ┌───────────────────┼───────────────────┐
		  ▼                   ▼                   ▼
┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│ CurrencyGen     │  │ ResearchData    │  │ ResearchPanel   │
│ (Gold Mine)     │──│ (Resource)      │──│ (Always visible)│
│ - produces gold │  │ - XP config     │  │ - Shows all     │
│ - emits signal  │  │ - Multiplier    │  │   research      │
└─────────────────┘  └─────────────────┘  │ - Progress bars │
										  └─────────────────┘
		  │
		  ▼
┌───────────────────────────────────────────────────────────────┐
│              ResearchBuffCalculator (static utility)           │
│  - apply_buffs() calculates XP multiplier from active buffs    │
└───────────────────────────────────────────────────────────────┘

Data Flow

Generator Production (currency_per_cycle)
		 │
		 ▼
┌──────────────────────┐
│ CurrencyGen          │
│ emits production_tick │
└──────────────────────┘
		 │
		 ▼
┌─────────────────────────────────────────┐
│ LevelGameState.add_research_xp()        │
│ 1. base_xp = currency_produced * rate   │
│ 2. buff_mult = ResearchBuffCalculator.apply_buffs()
│ 3. actual_xp = base_xp * buff_mult      │
│ 4. research_xp += actual_xp             │
│ 5. Check level threshold → auto-level   │
│ 6. 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:

@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:

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:

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. ResearchBuffCalculator (core/generator/research_buff_calculator.gd)

Purpose: Static utility class for calculating research XP buff multipliers

Location: res://core/generator/research_buff_calculator.gd

Key Methods:

static func apply_buffs(research: ResearchData, game_state: LevelGameState) -> float:
	"""Calculate total buff multiplier for research XP gain. Returns multiplicative factor."""
	var multiplier: float = 1.0
	if research.associated_buff_id.is_empty():
		return multiplier
	
	var buff: GeneratorBuffData = game_state.get_buff(research.associated_buff_id)
	if buff != null and game_state.is_buff_active(buff.id):
		var buff_level: int = game_state.get_buff_level(buff.id)
		multiplier *= _calculate_buff_multiplier(buff, buff_level)
	
	return multiplier

static func _calculate_buff_multiplier(buff: GeneratorBuffData, level: int) -> float:
	"""Calculate buff effect multiplier at given level."""
	return buff.get_effect_multiplier(level)

Usage: Called from LevelGameState.add_research_xp() to apply buffs before storing XP


4. LevelGameState Extensions (core/level_game_state.gd)

New State Variables:

var research_xp: Dictionary = {}        # {research_id: BigNumber}
var research_levels: Dictionary = {}    # {research_id: level_int}
# research_tracker removed - buff calculation moved to static utility

New Signals:

signal research_xp_changed(research_id: StringName, new_xp: BigNumber)
signal research_level_up(research_id: StringName, old_level: int, new_level: int)

New Properties:

@export var research_catalogue: ResearchCatalogue

New Methods:

func register_research(research_id: StringName) -> void:
	if not research_xp.has(research_id):
		research_xp[research_id] = BigNumber.new(0.0, 0)
	if not research_levels.has(research_id):
		research_levels[research_id] = 0

func get_research_xp(research_id: StringName) -> BigNumber:
	return research_xp.get(research_id, BigNumber.new(0.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: BigNumber) -> void:
	"""Add XP with buff multipliers applied, auto-level if threshold reached"""
	var research: ResearchData = research_catalogue.get_research_by_id(research_id)
	if research == null:
		return
	
	var old_level: int = get_research_level(research_id)
	var current_xp: BigNumber = get_research_xp(research_id)
	
	# Apply buff multipliers via static utility
	var buff_multiplier: float = ResearchBuffCalculator.apply_buffs(research, self)
	var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier)
	var actual_xp: BigNumber = xp.multiply(multiplier_bn)
	
	research_xp[research_id] = current_xp.add(actual_xp)
	var new_level: int = research.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_data(research_id: StringName) -> ResearchData:
	if research_catalogue == null:
		return null
	return research_catalogue.get_research_by_id(research_id)

Save/Load Integration:

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():

# 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:

@export var research_data: ResearchData  # Link to research configuration

6. CurrencyGenerator Extensions (core/generator/currency_generator.gd)

In _grant_cycle_income():

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)
	
	# Award research XP - buff multipliers applied in LevelGameState
	if data.research_data != null:
		var xp: float = data.research_data.xp_per_currency_produced * produced.to_float()
		game_state.add_research_xp(data.research_data.id, BigNumber.from_float(xp))

New Method:

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():

func get_effective_auto_run_multiplier() -> float:
	return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier()

7. GeneratorBuffData Extensions (core/generator/generator_buff_data.gd)

New Enum Value:

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):

@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:

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:

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 ResearchBuffCalculator static utility core/generator/research_buff_calculator.gd High Done
4 Add research state to LevelGameState core/level_game_state.gd High Done
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 Done
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)
Buff calculation Static utility (ResearchBuffCalculator) - no dedicated tracker node
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 5 when adding research support (BigNumber XP)
  • ResearchXPTracker removed - buff calculation moved to static ResearchBuffCalculator utility

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