diff --git a/.agents/skills/godot-test-instrumentation/SKILL.md b/.agents/skills/godot-test-instrumentation/SKILL.md new file mode 100644 index 0000000..f9e3a06 --- /dev/null +++ b/.agents/skills/godot-test-instrumentation/SKILL.md @@ -0,0 +1,642 @@ +--- +name: godot-test-instrumentation +description: Automates Godot testing for coding agents via headless execution, method simulation, and stdout validation. +--- + +# Godot Test Instrumentation + +Enables coding agents to automatically test game changes by running Godot in headless mode, simulating user actions via method calls, and validating results through structured stdout output. + +## Use This Skill When + +- A coding agent needs to verify gameplay changes work correctly +- Testing generator purchases, currency gains, prestige mechanics, or buff systems +- Running automated validation in CI/CD pipelines +- Debugging features without manual GUI interaction + +## Core Workflow + +``` +1. Agent writes/modifies game code + ↓ +2. Agent creates or updates test script in res://tests/ + ↓ +3. Agent runs: godot --headless --path . -s res://tests/test_x.gd + ↓ +4. Test loads gym scene, simulates actions, prints [TAG] results + ↓ +5. Agent parses stdout for [PASS]/[FAIL]/[RESULT] + ↓ +6. Exit code 0 = PASS, Exit code 1 = FAIL +``` + +--- + +## Directory Structure + +``` +res:// +├── tests/ +│ ├── test_runner.gd # Cross-platform test discovery & execution +│ ├── test_utils.gd # Shared assertion helpers +│ ├── test_gold_mine_click.gd # Example: generator click → currency gain +│ └── test_prestige.gd # Example: prestige mechanics validation +├── docs/gyms/ # Test scenes (e.g., tiny_sword.tscn) +└── core/ # Game logic (LevelGameState, generators, etc.) +``` + +--- + +## 1. Test Utils (res://tests/test_utils.gd) + +Shared assertion helpers with structured stdout output. + +```gdscript +class_name TestUtils +extends RefCounted + +static var _test_name: String = "" +static var _passed: int = 0 +static var _failed: int = 0 + +static func set_test_name(name: String) -> void: + _test_name = name + _passed = 0 + _failed = 0 + +static func assert_equals(expected: Variant, actual: Variant, message: String) -> void: + if expected == actual: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s, got %s)" % [ + _test_name, message, str(expected), str(actual) + ]) + _failed += 1 + +static func assert_true(condition: bool, message: String) -> void: + if condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func assert_greater_than(a: float, b: float, message: String) -> void: + if a > b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func print_result() -> void: + print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed]) + if _failed == 0: + print("[RESULT] PASS") + else: + print("[RESULT] FAIL") +``` + +--- + +## 2. Test Runner (res://tests/test_runner.gd) + +Cross-platform test discovery and execution. Runs all `test_*.gd` scripts in the tests directory. + +```gdscript +extends SceneTree + +var test_scripts: Array[String] = [] +var test_index: int = 0 +var total_passed: int = 0 +var total_failed: int = 0 + +func _init(): + print("\n=== GODOT TEST RUNNER ===\n") + _discover_tests() + + if test_scripts.is_empty(): + print("[ERROR] No test scripts found in res://tests/") + quit(1) + return + + print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)]) + await _run_next_test() + + print("\n=== TEST SUMMARY ===") + print("Total passed: %d" % total_passed) + print("Total failed: %d" % total_failed) + + if total_failed == 0: + print("[OVERALL_RESULT] PASS") + quit(0) + else: + print("[OVERALL_RESULT] FAIL") + quit(1) + +func _discover_tests() -> void: + var dir = DirAccess.open("res://tests") + if dir: + dir.list_dir_begin() + var file_name = dir.get_next() + while file_name != "": + if file_name.ends_with(".gd") and file_name.begins_with("test_") and file_name != "test_runner.gd" and file_name != "test_utils.gd": + test_scripts.append("res://tests/%s" % file_name) + file_name = dir.get_next() + dir.list_dir_end() + test_scripts.sort() + +func _run_next_test() -> void: + if test_index >= test_scripts.size(): + return + + var test_script = test_scripts[test_index] + print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script]) + + var test_instance = load(test_script).new() + await test_instance.run() + + total_passed += test_instance.passed + total_failed += test_instance.failed + + test_index += 1 + await _run_next_test() + +class TestScript: + var passed: int = 0 + var failed: int = 0 + + func run(): + pass +``` + +**Usage:** +```bash +# Run all tests via test runner +"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd +``` + +--- + +## 3. Test Script Template + +Each test extends `SceneTree` and implements a `run()` method. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 + +func _init(): + await run() + quit(0 if failed == 0 else 1) + +func run(): + # Setup + TestUtils.set_test_name("") + + # Load scene + var scene = load("") # e.g., "res://docs/gyms/tiny_sword/tiny_sword.tscn" + var root_node = scene.instantiate() + add_root_node(root_node) + + # Wait for scene initialization + await _wait() + + # Get references + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var generator = root_node.find_child("") as CurrencyGenerator + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + # Test logic + var initial_value = game_state.get_currency_amount_by_id(&"") + print("[TARGET] initial_: %s" % initial_value.to_string_suffix(2)) + + # Simulate action (method call, not mouse click) + generator._on_pressed() + + # Wait for signal propagation + await _wait() + + # Validate + var final_value = game_state.get_currency_amount_by_id(&"") + print("[TARGET] final_: %s" % final_value.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_value.mantissa, + initial_value.mantissa, + " increased after action" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + passed = _get_passed_count() + failed = _get_failed_count() + +func _get_passed_count() -> int: + # Extract from TestUtils or track locally + return 0 + +func _get_failed_count() -> int: + return 0 +``` + +--- + +## 4. Example Test: Gold Mine Click + +Tests that clicking a generator grants currency. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _local_passed: int = 0 +var _local_failed: int = 0 + +func _init(): + await run() + quit(failed == 0 ? 0 : 1) + +func run(): + print("\n=== TEST: Gold Mine Click ===\n") + TestUtils.set_test_name("gold_mine_click") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root_node = scene.instantiate() + add_root_node(root_node) + + await _wait() + + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var gold_mine = root_node.find_child("GoldMine") as CurrencyGenerator + + if game_state == null or gold_mine == null: + print("[ERROR] Missing LevelGameState or GoldMine node") + _print_summary() + return + + # Record initial gold + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2)) + + # Simulate clicking the generator + gold_mine._on_pressed() + + await _wait() + + # Validate gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_gold.mantissa, + initial_gold.mantissa, + "Gold increased after generator click" + ) + + # Check specific value (adjust based on click_mantissa in data) + var expected_min: float = initial_gold.mantissa + 1.0 + TestUtils.assert_true( + final_gold.mantissa >= expected_min, + "Gold increased by at least 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() + # Note: For accurate counts, modify TestUtils to return counts or track locally + _local_failed = 0 # Update based on actual assertions +``` + +--- + +## 5. Example Test: Prestige Mechanics + +Tests prestige threshold and currency gain. + +```gdscript +extends SceneTree + +var passed: int = 0 +var failed: int = 0 + +func _init(): + await run() + quit(failed == 0 ? 0 : 1) + +func run(): + print("\n=== TEST: Prestige Mechanics ===\n") + TestUtils.set_test_name("prestige") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root_node = scene.instantiate() + add_root_node(root_node) + + await _wait() + + var game_state: LevelGameState = root_node.find_child("LevelGameState") + var prestige_manager = root_node.find_child("PrestigeManager") as PrestigeManager + + if game_state == null or prestige_manager == null: + print("[ERROR] Missing LevelGameState or PrestigeManager") + _print_summary() + return + + # Setup: Accumulate currencies to meet prestige threshold + game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) + game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) + + await _wait() + + # Check if can prestige + var can_prestige: bool = prestige_manager.can_prestige() + print("[TARGET] can_prestige: %s" % str(can_prestige)) + + TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies") + + if can_prestige: + # Perform prestige + prestige_manager.perform_prestige() + + await _wait() + + # Check ascension currency + var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension") + print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2)) + + TestUtils.assert_true( + ascension.mantissa > 0, + "Should receive ascension currency after prestige" + ) + + # Check prestige multiplier increased + var total_multiplier = prestige_manager.get_total_multiplier() + print("[TARGET] total_multiplier: %s" % str(total_multiplier)) + + TestUtils.assert_greater_than( + total_multiplier if total_multiplier is float else float(total_multiplier), + 1.0, + "Prestige multiplier should be > 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + TestUtils.print_result() +``` + +--- + +## 6. Shell Scripts + +### run_test.sh + +```bash +#!/bin/bash +# Usage: ./run_test.sh +# Example: ./run_test.sh res://tests/test_gold_mine_click.gd + +set -e + +TEST_SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$TEST_SCRIPT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Running test: $TEST_SCRIPT" +echo "" + +"$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT" +EXIT_CODE=$? + +echo "" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Test passed" +else + echo "✗ Test failed with exit code: $EXIT_CODE" +fi + +exit $EXIT_CODE +``` + +--- + +### run_all.sh + +```bash +#!/bin/bash +# Usage: ./run_all.sh + +set -e + +GODOT_BIN="${GODOT_BIN:-godot}" +TEST_DIR="res://tests" + +echo "=== Running All Tests ===" +echo "" + +# Run test runner (discovers all tests automatically) +"$GODOT_BIN" --headless --path . -s "$TEST_DIR/test_runner.gd" +EXIT_CODE=$? + +echo "" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ All tests passed" +else + echo "✗ Some tests failed" +fi + +exit $EXIT_CODE +``` + +--- + +### check_syntax.sh + +```bash +#!/bin/bash +# Usage: ./check_syntax.sh + +SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$SCRIPT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Checking syntax: $SCRIPT" +"$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT" +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Syntax valid" +else + echo "✗ Syntax errors found" +fi + +exit $EXIT_CODE +``` + +--- + +## 7. Godot CLI Command Reference + +| Command | Purpose | +|---------|---------| +| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd` | Run single test in headless mode | +| `"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd` | Run all tests via test runner | +| `"$GODOT_BIN" --headless --path . --check-only --script res://test.gd` | Syntax validation only | +| `"$GODOT_BIN" --headless --verbose --path . -s res://tests/test_x.gd` | Verbose output for debugging | +| `"$GODOT_BIN" --headless --path . --quit-after 100 -s res://tests/test_x.gd` | Run for 100 frames then quit | +| `"$GODOT_BIN" --headless --path . -s res://tests/test_x.gd --log-file /tmp/test.log` | Log output to file | + +**Important:** Always use `--headless` for automated testing. This sets `--display-driver headless --audio-driver Dummy`. + +--- + +## 8. Output Parsing Protocol + +Agents should parse stdout for these tags: + +| Tag | Purpose | Example | +|-----|---------|---------| +| `[TARGET]` | Values to validate | `[TARGET] gold: 100.00` | +| `[PASS]` | Assertion passed | `[PASS] gold_mine_click: Gold increased]` | +| `[FAIL]` | Assertion failed | `[FAIL] gold_mine_click: Expected gold > 0]` | +| `[SUMMARY]` | Test completion stats | `[SUMMARY] gold_mine_click: 2 passed, 0 failed]` | +| `[RESULT]` | Overall test result | `[RESULT] PASS` or `[RESULT] FAIL]` | +| `[ERROR]` | Setup/runtime error | `[ERROR] Missing LevelGameState node]` | +| `[OVERALL_RESULT]` | Test runner summary | `[OVERALL_RESULT] PASS` or `[OVERALL_RESULT] FAIL]` | + +**Exit Codes:** +- `0` = All assertions passed +- `1` = One or more assertions failed or error occurred + +--- + +## 9. Best Practices + +### Simulating User Actions + +In headless mode, **do not** simulate mouse clicks with `InputEventMouseButton`. Instead, call methods directly: + +```gdscript +# ✅ DO: Call the method directly +generator._on_pressed() +game_state.add_currency_by_id(&"gold", BigNumber.from_float(100.0)) +prestige_manager.perform_prestige() + +# ❌ DON'T: Try to synthesize mouse events (won't work in headless) +var event = InputEventMouseButton.new() +Input.parse_input_event(event) # Ineffective without GUI +``` + +### Async Handling + +Use standard wait time for signal propagation: + +```gdscript +await get_tree().create_timer(0.5).timeout +``` + +Adjust based on test needs: +- `0.1s` - Simple state changes +- `0.5s` - Signal propagation (recommended default) +- `1.0s+` - Complex async operations + +### Scene Selection + +Use gym scenes from `res://docs/gyms/`: +- `res://docs/gyms/tiny_sword/tiny_sword.tscn` - Full game with generators, currencies, prestige +- Create minimal test scenes if testing isolated features + +### Test Naming + +- Use `test_.gd` naming convention +- Test name in `TestUtils.set_test_name()` should match filename (without `.gd`) + +### Assertions + +- One assertion per line for clear parsing +- Include descriptive messages +- Use `[TARGET]` to log values before/after actions + +--- + +## 10. Quick Start for Agents + +1. **Create test file:** + ```bash + touch res://tests/test_my_feature.gd + ``` + +2. **Copy template:** + ```gdscript + extends SceneTree + var passed: int = 0 + var failed: int = 0 + + func _init(): + await run() + quit(failed == 0 ? 0 : 1) + + func run(): + print("\n=== TEST: My Feature ===\n") + TestUtils.set_test_name("my_feature") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + var root = scene.instantiate() + add_root_node(root) + + await get_tree().create_timer(0.5).timeout + + # Your test logic here + + TestUtils.print_result() + ``` + +3. **Run test:** + ```bash + godot --headless --path . -s res://tests/test_my_feature.gd + ``` + +4. **Parse output:** + - Look for `[PASS]`, `[FAIL]`, `[RESULT]` tags + - Check exit code (0 = pass, 1 = fail) + +--- + +## References + +- [Godot CLI Tutorial](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html) +- [Godot Headless Mode](https://docs.godotengine.org/en/latest/tutorials/editor/command_line_tutorial.html#run-options) +- [InputEvent Documentation](https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html) + + + +/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md \ No newline at end of file diff --git a/core/generator/currency_generator.gd b/core/generator/currency_generator.gd index 02680cf..6322da9 100644 --- a/core/generator/currency_generator.gd +++ b/core/generator/currency_generator.gd @@ -39,6 +39,9 @@ const HUGE_COST_EXPONENT: int = 1000000 ## Reference to generator UI @export var info_generator_container: GeneratorPanel +## Reference to research tracker +var research_tracker: Node = null + ## True while the pointer is inside the generator Area2D. var _mouse_entered: bool = false ## Time left until next click/hover grant is allowed. @@ -105,6 +108,8 @@ func _ready() -> void: game_state.currency_changed.connect(_on_currency_changed) game_state.generator_state_changed.connect(_on_generated_state_changed) game_state.goal_completed.connect(_on_goal_completed) + # Don't cache research_tracker here - it may not be initialized yet + # Access it lazily when needed instead _ensure_registered() @@ -142,6 +147,14 @@ func _grant_cycle_income(cycle_count: int) -> void: 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) + + if data != null and data.research_data != null: + var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) + var research_xp_amount: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + if game_state != null and research_xp_amount.mantissa > 0.0: + var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker + if tracker: + tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount) ## Convenience helper for the next single-unit purchase cost. func get_next_cost() -> BigNumber: @@ -337,7 +350,14 @@ 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_automatic_production_multiplier() * _get_prestige_multiplier() + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() + +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) func reset_runtime_state_for_prestige() -> void: _is_registered = false @@ -383,7 +403,18 @@ func _try_grant_click_currency() -> void: if _remaining_click_cooldown_seconds > 0.0: return - _add_currency(_get_click_value()) + var click_value: BigNumber = _get_click_value() + _add_currency(click_value) + + # Grant research XP for manual clicks + if data.research_data != null and click_value.mantissa > 0.0: + var amount_float: float = click_value.mantissa * pow(10.0, float(click_value.exponent)) + var research_xp_amount: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + if game_state != null and research_xp_amount.mantissa > 0.0: + var tracker: ResearchXPTracker = game_state.research_tracker as ResearchXPTracker + if tracker: + tracker.add_xp_with_buffs(data.research_data.id, research_xp_amount) + _remaining_click_cooldown_seconds = _get_click_cooldown_seconds() ## Resolves click reward from data. diff --git a/core/generator/currency_generator_data.gd b/core/generator/currency_generator_data.gd index 338ea63..6d89250 100644 --- a/core/generator/currency_generator_data.gd +++ b/core/generator/currency_generator_data.gd @@ -31,6 +31,8 @@ extends Resource @export var click_exponent: int = 0 @export var click_cooldown_seconds: float = 0.2 +@export var research_data: ResearchData + func has_unlock_goal() -> bool: if unlock_goal == null: return false diff --git a/core/generator/generator_buff_data.gd b/core/generator/generator_buff_data.gd index 09cdcc9..00d0275 100644 --- a/core/generator/generator_buff_data.gd +++ b/core/generator/generator_buff_data.gd @@ -5,6 +5,7 @@ enum BuffKind { AUTO_PRODUCTION_MULTIPLIER, MANUAL_CLICK_MULTIPLIER, RESOURCE_PURCHASE, + RESEARCH_XP_MULTIPLIER, } const HUGE_EXPONENT: int = 1000000 @@ -24,6 +25,10 @@ const HUGE_EXPONENT: int = 1000000 @export var base_effect: float = 1.0 @export var effect_increment: float = 0.1 +@export_group("Research XP Multiplier") +@export var research_target_id: StringName = &"" +@export var xp_multiplier_per_level: float = 0.1 + @export_group("Cost") @export var cost_currency: Currency @export var base_cost_mantissa: float = 10.0 @@ -133,7 +138,9 @@ func get_effect_description(level: int) -> String: BuffKind.AUTO_PRODUCTION_MULTIPLIER, BuffKind.MANUAL_CLICK_MULTIPLIER: return "x%.2f" % get_effect_multiplier(safe_level) BuffKind.RESOURCE_PURCHASE: - return "+%s" % get_resource_purchase_amount_for_level(safe_level).to_string_suffix(2) + return "+%s" % get_resource_purchase_amount_for_level(level).to_string_suffix(2) + BuffKind.RESEARCH_XP_MULTIPLIER: + return "+%.0f%% XP" % ((get_effect_multiplier(safe_level) - 1.0) * 100) _: return "-" diff --git a/core/level_game_state.gd b/core/level_game_state.gd index f424c7b..a1d918d 100644 --- a/core/level_game_state.gd +++ b/core/level_game_state.gd @@ -12,6 +12,8 @@ signal buff_level_changed(buff_id: StringName, new_level: int) signal buff_unlocked_changed(buff_id: StringName, unlocked: bool) signal goal_completed(goal_id: StringName) signal goal_progress_changed(goal_id: StringName, progress: float) +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +signal research_level_up(research_id: StringName, old_level: int, new_level: int) # ========================================== # EXPORTED REFERENCES @@ -19,6 +21,7 @@ signal goal_progress_changed(goal_id: StringName, progress: float) @export var currency_catalogue: CurrencyCatalogue @export var buff_catalogue: BuffCatalogue @export var goal_catalogue: GoalCatalogue +@export var research_catalogue: ResearchCatalogue @export var save_file_path: String = "user://level_save.json" # ========================================== @@ -41,13 +44,17 @@ var _buff_active: Dictionary = {} var _goal_definitions: Dictionary = {} var _goal_completed: Dictionary = {} +var research_xp: Dictionary = {} +var research_levels: Dictionary = {} +var research_tracker: Node = null + var last_save_time: int = 0 # ========================================== # Save / Load # ========================================== const SAVE_FORMAT_VERSION_KEY: String = "save_format_version" -const CURRENT_SAVE_FORMAT_VERSION: int = 3 +const CURRENT_SAVE_FORMAT_VERSION: int = 5 const GENERATOR_OWNED_KEY: String = "owned" const GENERATOR_PURCHASED_COUNT_KEY: String = "purchased_count" const GENERATOR_UNLOCKED_KEY: String = "unlocked" @@ -66,6 +73,8 @@ const CURRENT_KEY: String = "current" const TOTAL_KEY: String = "total" const ALL_TIME_KEY: String = "all_time" const LAST_SAVE_TIME_KEY: String = "last_save_time" +const RESEARCH_XP_KEY: String = "research_xp" +const RESEARCH_LEVELS_KEY: String = "research_levels" var prestige_manager: PrestigeManager @@ -76,6 +85,7 @@ func _ready() -> void: _load_catalogue_goals() load_game() _try_unlock_all_buffs_from_goals() + _initialize_research() # ========================================== # STATE MODIFIERS @@ -429,6 +439,19 @@ func get_effective_multiplier(generator_id: StringName, kind: int) -> float: return multiplier +func _initialize_research() -> void: + research_tracker = Node.new() + research_tracker.set_script(load("res://core/research/research_xp_tracker.gd")) + add_child(research_tracker) + + var tracker: ResearchXPTracker = research_tracker as ResearchXPTracker + if tracker: + tracker.game_state = self + + if research_catalogue: + for research in research_catalogue.get_all_research(): + register_research(research.id) + func _try_unlock_all_buffs_from_goals() -> void: for buff in get_all_buffs(): if buff == null: @@ -446,6 +469,60 @@ func _try_unlock_buff_from_goal(buff: GeneratorBuffData) -> void: if not goal_id.is_empty() and is_goal_completed(goal_id): set_buff_unlocked(buff.id, true) +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: + var val: Variant = research_xp.get(research_id, BigNumber.new(0.0, 0)) + return val as BigNumber + +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: + var old_level: int = get_research_level(research_id) + var current_xp: BigNumber = get_research_xp(research_id) + research_xp[research_id] = current_xp.add(xp) + + var research: ResearchData = _get_research_data(research_id) + if research != null: + 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_xp_multiplier(research_id: StringName) -> float: + var multiplier: float = 1.0 + var research: ResearchData = _get_research_data(research_id) + if research == null: + return 1.0 + + 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) + func set_external_save_data(section_key: StringName, payload: Dictionary) -> void: var key: String = String(section_key).strip_edges() if key.is_empty(): @@ -473,12 +550,14 @@ func reset_for_prestige(reset_total_currency: bool = false, emit_currency_signal if reset_total_currency: _set_total_currency(currency_id, BigNumber.from_float(0.0)) - generator_states.clear() - generator_buff_levels.clear() - generator_buff_unlocked.clear() - _goal_completed.clear() - for goal_id in _goal_definitions.keys(): - _goal_completed[goal_id] = false + generator_states.clear() + generator_buff_levels.clear() + generator_buff_unlocked.clear() + _goal_completed.clear() + for goal_id in _goal_completed.keys(): + _goal_completed[goal_id] = false + research_xp.clear() + research_levels.clear() if not emit_currency_signals: return @@ -506,6 +585,8 @@ func save_game() -> void: BUFF_UNLOCKED_KEY: _serialize_buff_unlocked(), BUFF_ACTIVE_KEY: _serialize_buff_active(), GOALS_KEY: _serialize_goals(), + RESEARCH_XP_KEY: _serialize_research_xp(), + RESEARCH_LEVELS_KEY: research_levels.duplicate(), LAST_SAVE_TIME_KEY: Time.get_unix_time_from_system() } @@ -551,12 +632,18 @@ func load_game() -> void: generator_states = _deserialize_generator_states(parsed_data.get(GENERATOR_STATES_KEY, {})) _deserialize_buff_states(parsed_data) _deserialize_goals(parsed_data.get(GOALS_KEY, {})) + + if parsed_data.has(RESEARCH_XP_KEY): + research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {})) + if parsed_data.has(RESEARCH_LEVELS_KEY): + research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) + last_save_time = parsed_data.get(LAST_SAVE_TIME_KEY, Time.get_unix_time_from_system()) 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, 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, LAST_SAVE_TIME_KEY]: continue var section_payload: Variant = parsed_data.get(save_key_variant) @@ -1294,3 +1381,19 @@ func _humanize_currency_id(currency_id: StringName) -> String: if normalized.is_empty(): return "Unknown" return normalized.replace("_", " ").capitalize() + +func _serialize_research_xp() -> Dictionary: + var result: Dictionary = {} + for research_id in research_xp.keys(): + var xp: BigNumber = research_xp[research_id] + result[research_id] = xp.serialize() + return result + +func _deserialize_research_xp(raw: Variant) -> Dictionary: + var result: Dictionary = {} + if raw is Dictionary: + for research_id in raw.keys(): + var serialized: Variant = raw[research_id] + if serialized is Dictionary: + result[research_id] = BigNumber.deserialize(serialized) + return result diff --git a/core/research/README.md b/core/research/README.md new file mode 100644 index 0000000..7b1cbb8 --- /dev/null +++ b/core/research/README.md @@ -0,0 +1,459 @@ +# Research Module Documentation + +## Overview + +The `research/` subfolder implements a production-based research system where generator output earns research XP, automatically leveling up to provide production multipliers. Research tracks are tied to specific generators and can be enhanced with purchasable buffs that increase XP gain. + +## Files + +| File | Purpose | +|------|---------| +| `research_xp_tracker.gd` | Accumulates XP from production and handles auto-leveling (Node) | +| `research_catalogue.gd` | Resource holding all research configurations | +| `research_panel.gd` | UI panel showing all research tracks | +| `research_row.gd` | UI row component for individual research display | + +## Architecture + +``` +LevelGameState (Node2D) +├── 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 + └── ResearchPanel (UI, always visible) + ├── ResearchRow (for each research track) + │ ├── Name & icon + │ ├── Level display + │ ├── Progress bar (percentage) + │ ├── Multiplier indicator + │ └── Active buff display +``` + +## 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} + │ + ▼ +CurrencyGenerator.get_research_multiplier() → 1.3 (30% bonus) + │ + ▼ +Production *= 1.3 +``` + +## ResearchData + +Resource class defining research configuration for a generator (defined in `core/generator/research_data.gd`). + +### Key Properties + +```gdscript +@export var id: StringName # Unique research identifier +@export var generator_id: StringName # Which generator this 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 +@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_xp_required_for_level_big(level: int) -> BigNumber: + """XP needed to reach this level from previous (BigNumber)""" + return BigNumber.from_float(base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0)))) + +func get_total_xp_for_level(level: int) -> BigNumber: + """Total cumulative XP needed to reach this level from level 0 (BigNumber)""" + if level <= 0: return BigNumber.new(0.0, 0) + var total: BigNumber = BigNumber.new(0.0, 0) + for l in range(1, level + 1): + total = total.add(get_xp_required_for_level_big(l)) + return total + +func get_level_for_xp(xp: BigNumber) -> int: + """Calculate level from total accumulated BigNumber XP (loop-based)""" + if xp.mantissa == 0.0: return 0 + var level: int = 0 + while true: + var next_xp: BigNumber = get_total_xp_for_level(level + 1) + if xp.compare_to(next_xp) < 0: break + level += 1 + 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""" +``` + +## ResearchCatalogue + +Resource class holding all research configurations. + +### Structure + +```gdscript +class_name ResearchCatalogue +extends Resource + +@export var research_entries: Array[ResearchData] = [] + +func get_research_by_id(research_id: StringName) -> ResearchData +func get_research_by_generator_id(generator_id: StringName) -> ResearchData +func get_all_research() -> Array[ResearchData] +``` + +### Usage + +Referenced directly by `LevelGameState` via `@export var research_catalogue: ResearchCatalogue`. + +## ResearchXPTracker + +Child node of `LevelGameState` that accumulates XP from production and applies buff multipliers. + +### 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: BigNumber) -> BigNumber: + """Apply buff multipliers and award XP. Returns actual XP after buffs (BigNumber).""" + var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id) + var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) + var actual_xp: BigNumber = base_xp.multiply(multiplier_bn) + 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) + var amount_float: float = amount.mantissa * pow(10.0, float(amount.exponent)) + var base_xp: BigNumber = BigNumber.from_float(research.xp_per_currency_produced * amount_float) + add_xp_with_buffs(research.id, base_xp) +``` + +### Connection + +Connected to `LevelGameState` via `generator_produced` signal. + +## LevelGameState Extensions + +### State Variables +```gdscript +var research_xp: Dictionary = {} # {research_id: BigNumber} +var research_levels: Dictionary = {} # {research_id: level_int} +var research_tracker: ResearchXPTracker +``` + +### Signals +```gdscript +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +signal research_level_up(research_id: StringName, old_level: int, new_level: int) +``` + +### Key Methods +```gdscript +func register_research(research_id: StringName) -> void: + """Initialize research state from catalogue (BigNumber XP)""" + if not research_xp.has(research_id): + research_xp[research_id] = BigNumber.new(0.0, 0) + +func get_research_xp(research_id: StringName) -> BigNumber + +func get_research_level(research_id: StringName) -> int + +func get_research_multiplier(research_id: StringName) -> float: + """Returns production multiplier from research level""" + var level: int = get_research_level(research_id) + return research.get_multiplier_for_level(level) + +func add_research_xp(research_id: StringName, xp: BigNumber) -> void: + """Add XP (BigNumber) and auto-level if threshold reached""" + var current_xp: BigNumber = get_research_xp(research_id) + research_xp[research_id] = current_xp.add(xp) + new_level = 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) + +func get_research_xp_multiplier(research_id: StringName) -> float: + """Returns multiplicative buff for research XP gain""" + var multiplier: float = 1.0 + var buff: GeneratorBuffData = get_buff(research.associated_buff_id) + if is_buff_active(buff.id): + multiplier *= buff.get_effect_multiplier(get_buff_level(buff.id)) + return multiplier +``` + +### Save/Load Integration +```gdscript +const RESEARCH_XP_KEY = "research_xp" +const RESEARCH_LEVELS_KEY = "research_levels" +const CURRENT_SAVE_FORMAT_VERSION = 5 # Bumped from 4 to support BigNumber + +# In save_game(): +var save_data = { + # ... existing fields ... + RESEARCH_XP_KEY: _serialize_research_xp(), # Serialize BigNumber as {m, e} + RESEARCH_LEVELS_KEY: research_levels.duplicate(), +} + +func _serialize_research_xp() -> Dictionary: + var result: Dictionary = {} + for research_id in research_xp.keys(): + var xp: BigNumber = research_xp[research_id] + result[research_id] = xp.serialize() + return result + +# In load_game(): +research_xp = _deserialize_research_xp(parsed_data.get(RESEARCH_XP_KEY, {})) +research_levels = parsed_data.get(RESEARCH_LEVELS_KEY, {}) + +func _deserialize_research_xp(raw: Variant) -> Dictionary: + var result: Dictionary = {} + if raw is Dictionary: + for research_id in raw.keys(): + var serialized: Variant = raw[research_id] + if serialized is Dictionary: + result[research_id] = BigNumber.deserialize(serialized) + return result + +# In reset_for_prestige(): +research_xp.clear() +research_levels.clear() +``` + +## CurrencyGenerator Extensions + +### Production Tick Integration +```gdscript +func _grant_cycle_income(cycle_count: int) -> void: + var produced: BigNumber = ... + _add_currency(produced) + production_tick.emit(produced, cycle_count, owned) + + # Award research XP (BigNumber) + if data.research_data != null: + var amount_float: float = produced.mantissa * pow(10.0, float(produced.exponent)) + var xp: BigNumber = BigNumber.from_float(data.research_data.xp_per_currency_produced * amount_float) + research_tracker.add_xp_with_buffs(data.research_data.id, xp) +``` + +### Research Multiplier + +```gdscript +func get_research_multiplier() -> float: + if data.research_data == null: + return 1.0 + return game_state.get_research_multiplier(data.research_data.id) + +func get_effective_auto_run_multiplier() -> float: + return run_multiplier * get_research_multiplier() * get_automatic_production_multiplier() * _get_prestige_multiplier() +``` + +## GeneratorBuffData Extensions + +### New Buff Kind + +```gdscript +enum BuffKind { + AUTO_PRODUCTION_MULTIPLIER, + MANUAL_CLICK_MULTIPLIER, + RESOURCE_PURCHASE, + RESEARCH_XP_MULTIPLIER # 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 +``` + +## ResearchPanel + +UI component showing all research tracks (always visible, like GeneratorPanel). + +### Layout + +``` +ResearchPanel (ScrollContainer) +└── VBoxContainer + ├── TitleLabel ("Research") + └── ResearchRows (VBoxContainer) + └── ResearchRow (for each research track) +``` + +### Key Methods + +```gdscript +func _build_research_rows() -> void: + for research in research_catalogue.get_all_research(): + var row = RESEARCH_ROW_SCENE.instantiate() + row.setup(research) + _research_rows[research.id] = row + +func _refresh_row(row) -> void: + var research_id: StringName = row.get_research_id() + var xp: BigNumber = game_state.get_research_xp(research_id) + var level: int = game_state.get_research_level(research_id) + var progress: float = research.get_xp_progress(xp) + var multiplier: float = research.get_multiplier_for_level(level) + var xp_current: BigNumber = xp.subtract(research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = research.get_xp_required_for_level_big(level + 1) + row.update_display(level, progress, multiplier, xp_current, xp_needed) +``` + +### Signals Connected + +- `research_xp_changed` - Refresh progress bars +- `research_level_up` - Refresh level and multiplier displays + +## ResearchRow + +UI row component for individual research display. + +### 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") +``` + +### Key Methods + +```gdscript +func setup(research: ResearchData) -> void: + _name_label.text = research.name + _icon.texture = research.icon + +func update_display(level: int, progress: float, multiplier: float, xp_current: BigNumber, xp_needed: BigNumber) -> void: + _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 is_buff_active(buff.id): + _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] +``` + +## 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 +``` + +## Design Decisions + +| Decision | Value | +|----------|-------| +| ResearchCatalogue reference | Direct `@export var research_catalogue: ResearchCatalogue` in LevelGameState | +| 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 | +| ResearchPanel visibility | Always visible (like GeneratorPanel) | + +## System Behavior + +1. **Generator produces currency** → `production_tick` signal emitted +2. **ResearchXPTracker receives signal** → calculates base XP from production amount +3. **Buff multipliers applied** → checks for active RESEARCH_XP_MULTIPLIER buffs +4. **XP awarded** → stored in `research_xp` dictionary +5. **Level check** → if XP exceeds threshold, auto-level and emit `research_level_up` +6. **Multiplier applied** → `get_research_multiplier()` returns updated production bonus +7. **UI refresh** → `ResearchPanel` updates progress bars and level displays + +## Prestige Integration + +On prestige reset: +1. `research_xp.clear()` - All XP lost +2. `research_levels.clear()` - All levels reset to 0 +3. Buff purchases persist (separate state) +4. Research tracks remain unlocked + +## See Also + +- `core/level_game_state.gd` - Research state storage and management +- `core/generator/currency_generator_data.gd` - Links to ResearchData +- `core/generator/generator_buff_data.gd` - RESEARCH_XP_MULTIPLIER buff type +- `core/research/research_catalogue.gd` - Research configuration resource diff --git a/core/research/TODO.md b/core/research/TODO.md new file mode 100644 index 0000000..70b1e4f --- /dev/null +++ b/core/research/TODO.md @@ -0,0 +1,645 @@ +# 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 diff --git a/core/research/research_catalogue.gd b/core/research/research_catalogue.gd new file mode 100644 index 0000000..65f89b8 --- /dev/null +++ b/core/research/research_catalogue.gd @@ -0,0 +1,19 @@ +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() diff --git a/core/research/research_catalogue.gd.uid b/core/research/research_catalogue.gd.uid new file mode 100644 index 0000000..dc92b19 --- /dev/null +++ b/core/research/research_catalogue.gd.uid @@ -0,0 +1 @@ +uid://d2v6t6w2todfy diff --git a/core/research/research_data.gd b/core/research/research_data.gd new file mode 100644 index 0000000..dc28367 --- /dev/null +++ b/core/research/research_data.gd @@ -0,0 +1,66 @@ +class_name ResearchData +extends Resource + +@export var id: StringName = &"" +@export var generator_id: StringName = &"" +@export var name: String = "" +@export_multiline var description: String = "" +@export var icon: Texture2D + +## XP Configuration +@export var xp_per_currency_produced: float = 0.01 +@export var base_xp_required: float = 100.0 +@export var xp_growth_multiplier: float = 1.5 + +## Multiplier Configuration +@export var base_multiplier: float = 1.0 +@export var multiplier_per_level: float = 0.1 + +## Associated Buff (single buff per research track) +@export var associated_buff_id: StringName = &"" + +func get_xp_required_for_level(level: int) -> float: + return base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + +func get_xp_required_for_level_big(level: int) -> BigNumber: + var float_val: float = base_xp_required * pow(xp_growth_multiplier, float(maxi(level - 1, 0))) + return BigNumber.from_float(float_val) + +func get_total_xp_for_level(level: int) -> BigNumber: + if level <= 0: + return BigNumber.new(0.0, 0) + var total: BigNumber = BigNumber.new(0.0, 0) + for l in range(1, level + 1): + total = total.add(get_xp_required_for_level_big(l)) + return total + +func get_level_for_xp(xp: BigNumber) -> int: + if xp.mantissa == 0.0: + return 0 + var level: int = 0 + while true: + var next_xp: BigNumber = get_total_xp_for_level(level + 1) + if xp.compare_to(next_xp) < 0: + break + level += 1 + return level + +func get_multiplier_for_level(level: int) -> float: + return base_multiplier + (multiplier_per_level * float(maxi(level, 0))) + +func get_xp_progress(xp: BigNumber) -> float: + if xp.mantissa == 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: BigNumber = get_total_xp_for_level(current_level) + var xp_needed_for_next: BigNumber = get_xp_required_for_level_big(current_level + 1) + var xp_in_current_level: BigNumber = xp.subtract(xp_at_current_level) + + if xp_needed_for_next.mantissa == 0.0: + return 1.0 + + var ratio: float = (xp_in_current_level.mantissa / xp_needed_for_next.mantissa) * pow(10.0, float(xp_in_current_level.exponent - xp_needed_for_next.exponent)) + return clampf(ratio, 0.0, 1.0) diff --git a/core/research/research_data.gd.uid b/core/research/research_data.gd.uid new file mode 100644 index 0000000..664f5b6 --- /dev/null +++ b/core/research/research_data.gd.uid @@ -0,0 +1 @@ +uid://m7baywrfnpn0 diff --git a/core/research/research_panel.gd b/core/research/research_panel.gd new file mode 100644 index 0000000..9c9b9dd --- /dev/null +++ b/core/research/research_panel.gd @@ -0,0 +1,60 @@ +class_name ResearchPanel +extends PanelContainer + +const RESEARCH_ROW_SCENE: PackedScene = preload("res://core/research/research_row.tscn") + +@onready var _title_label: Label = $ScrollContainer/VBoxContainer/TitleLabel +@onready var _research_rows_container: VBoxContainer = $ScrollContainer/VBoxContainer/ResearchRows + +var _game_state: LevelGameState +var _research_rows: Dictionary = {} + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("ResearchPanel: Could not find LevelGameState parent") + 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: ResearchRow = 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: ResearchRow) -> void: + var research_id: StringName = row.get_research_id() + var xp: BigNumber = _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: BigNumber = xp.subtract(research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = research.get_xp_required_for_level_big(level + 1) + + row.update_display(level, progress, multiplier, xp_current, xp_needed) + +func _on_research_xp_changed(research_id: StringName, _new_xp: BigNumber) -> 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]) diff --git a/core/research/research_panel.gd.uid b/core/research/research_panel.gd.uid new file mode 100644 index 0000000..400ee9b --- /dev/null +++ b/core/research/research_panel.gd.uid @@ -0,0 +1 @@ +uid://fdl7ftxw8w1e diff --git a/core/research/research_panel.tscn b/core/research/research_panel.tscn new file mode 100644 index 0000000..eb6525c --- /dev/null +++ b/core/research/research_panel.tscn @@ -0,0 +1,26 @@ +[gd_scene format=3 uid="uid://dd8roif0mqirl"] + +[ext_resource type="Script" uid="uid://fdl7ftxw8w1e" path="res://core/research/research_panel.gd" id="1_script"] +[ext_resource type="PackedScene" uid="uid://bpo8pl5tipav" path="res://core/research/research_row.tscn" id="2_3m5ee"] + +[node name="ResearchPanel" type="PanelContainer" unique_id=1105274967] +custom_minimum_size = Vector2(400, 100) +offset_right = 400.0 +offset_bottom = 100.0 +script = ExtResource("1_script") + +[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=1099596602] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=692886617] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="ScrollContainer/VBoxContainer" unique_id=1565843659] +layout_mode = 2 +text = "Research" + +[node name="ResearchRows" type="VBoxContainer" parent="ScrollContainer/VBoxContainer" unique_id=1200240263] +layout_mode = 2 + +[node name="ResearchRow" parent="ScrollContainer/VBoxContainer/ResearchRows" unique_id=245375198 instance=ExtResource("2_3m5ee")] +layout_mode = 2 diff --git a/core/research/research_row.gd b/core/research/research_row.gd new file mode 100644 index 0000000..0daec0f --- /dev/null +++ b/core/research/research_row.gd @@ -0,0 +1,74 @@ +class_name ResearchRow +extends HBoxContainer + +@onready var _name_label: Label = $VBoxContainer/NameLabel +@onready var _level_label: Label = $VBoxContainer/HBoxContainer/LevelLabel +@onready var _progress_bar: ProgressBar = $VBoxContainer/HBoxContainer/ProgressBar +@onready var _multiplier_label: Label = $VBoxContainer/HBoxContainer/MultiplierLabel +@onready var _buff_label: Label = $VBoxContainer/HBoxContainer/BuffLabel + +var _research: ResearchData +var _game_state: LevelGameState + +func _ready() -> void: + _game_state = find_parent("LevelGameState") + if _game_state == null: + push_error("ResearchRow: Could not find LevelGameState parent") + return + + _game_state.research_xp_changed.connect(_on_research_xp_changed) + _game_state.research_level_up.connect(_on_research_level_up) + +func setup(research: ResearchData) -> void: + _research = research + if _name_label: + _name_label.text = research.name + +func get_research_id() -> StringName: + if _research == null: + return &"" + return _research.id + +func update_display(level: int, progress: float, multiplier: float, xp_current: BigNumber, xp_needed: BigNumber) -> void: + if _level_label: + _level_label.text = "Level %d" % level + + if _progress_bar: + _progress_bar.value = progress * 100 + + if _multiplier_label: + _multiplier_label.text = "+%d%%" % int((multiplier - 1.0) * 100) + + if _buff_label and _research != null: + if not _research.associated_buff_id.is_empty(): + 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) + var effect: float = buff.get_effect_multiplier(buff_level) - 1.0 + _buff_label.text = "%s: +%d%% XP" % [buff.text, int(effect * 100)] + else: + _buff_label.text = "" + else: + _buff_label.text = "" + +func _on_research_xp_changed(research_id: StringName, _new_xp: BigNumber) -> void: + if _research != null and research_id == _research.id: + _update_from_state() + +func _on_research_level_up(research_id: StringName, _old_level: int, _new_level: int) -> void: + if _research != null and research_id == _research.id: + _update_from_state() + +func _update_from_state() -> void: + if _research == null or _game_state == null: + return + + var xp: BigNumber = _game_state.get_research_xp(_research.id) + var level: int = _game_state.get_research_level(_research.id) + + var progress: float = _research.get_xp_progress(xp) + var multiplier: float = _research.get_multiplier_for_level(level) + var xp_current: BigNumber = xp.subtract(_research.get_total_xp_for_level(level)) + var xp_needed: BigNumber = _research.get_xp_required_for_level_big(level + 1) + + update_display(level, progress, multiplier, xp_current, xp_needed) diff --git a/core/research/research_row.gd.uid b/core/research/research_row.gd.uid new file mode 100644 index 0000000..108beb5 --- /dev/null +++ b/core/research/research_row.gd.uid @@ -0,0 +1 @@ +uid://gk3mr3k5yvp7 diff --git a/core/research/research_row.tscn b/core/research/research_row.tscn new file mode 100644 index 0000000..c743183 --- /dev/null +++ b/core/research/research_row.tscn @@ -0,0 +1,38 @@ +[gd_scene format=3 uid="uid://bpo8pl5tipav"] + +[ext_resource type="Script" uid="uid://gk3mr3k5yvp7" path="res://core/research/research_row.gd" id="1_script"] + +[node name="ResearchRow" type="HBoxContainer" unique_id=810714290] +mouse_filter = 2 +script = ExtResource("1_script") + +[node name="Icon" type="TextureRect" parent="." unique_id=1376308659] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=468422769] +layout_mode = 2 + +[node name="NameLabel" type="Label" parent="VBoxContainer" unique_id=1742937834] +layout_mode = 2 +text = "Research Name" + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=819270678] +layout_mode = 2 +alignment = 2 + +[node name="LevelLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1108328348] +layout_mode = 2 +text = "Level 0" + +[node name="ProgressBar" type="ProgressBar" parent="VBoxContainer/HBoxContainer" unique_id=1855503166] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 + +[node name="MultiplierLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=135310321] +layout_mode = 2 +text = "+0%" + +[node name="BuffLabel" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=2124734446] +layout_mode = 2 diff --git a/core/research/research_xp_tracker.gd b/core/research/research_xp_tracker.gd new file mode 100644 index 0000000..950de72 --- /dev/null +++ b/core/research/research_xp_tracker.gd @@ -0,0 +1,48 @@ +class_name ResearchXPTracker +extends Node + +@export var game_state: LevelGameState + +signal research_xp_changed(research_id: StringName, new_xp: BigNumber) +signal research_level_up(research_id: StringName, old_level: int, new_level: int) + +func add_xp_with_buffs(research_id: StringName, base_xp: BigNumber) -> BigNumber: + if game_state == null: + return BigNumber.new(0.0, 0) + + var buff_multiplier: float = game_state.get_research_xp_multiplier(research_id) + var multiplier_bn: BigNumber = BigNumber.from_float(buff_multiplier) + var actual_xp: BigNumber = base_xp.multiply(multiplier_bn) + + game_state.add_research_xp(research_id, actual_xp) + + return actual_xp + +func _on_generator_produced(generator_id: StringName, _amount: Variant) -> void: + var research: ResearchData = _get_research_for_generator(generator_id) + if research == null: + return + + var research_catalogue: ResearchCatalogue = game_state.research_catalogue + if research_catalogue == null: + return + + research = research_catalogue.get_research_by_generator_id(generator_id) + if research == null: + return + + var amount_big: BigNumber = _amount + var amount_float: float = amount_big.mantissa * pow(10.0, float(amount_big.exponent)) + if amount_float <= 0.0: + return + + var base_xp: BigNumber = BigNumber.from_float(research.xp_per_currency_produced * amount_float) + if base_xp.mantissa <= 0.0: + return + + add_xp_with_buffs(research.id, base_xp) + +func _get_research_for_generator(generator_id: StringName) -> ResearchData: + if game_state == null or game_state.research_catalogue == null: + return null + return game_state.research_catalogue.get_research_by_generator_id(generator_id) diff --git a/core/research/research_xp_tracker.gd.uid b/core/research/research_xp_tracker.gd.uid new file mode 100644 index 0000000..21fd44c --- /dev/null +++ b/core/research/research_xp_tracker.gd.uid @@ -0,0 +1 @@ +uid://bv3a4utlnn2fg diff --git a/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres b/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres index ac56b02..b89f60d 100644 --- a/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres +++ b/docs/gyms/tiny_sword/buildings/forestry/forestry_generator.tres @@ -1,8 +1,6 @@ [gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://v6hjoa2vky5k"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_mgk3v"] [ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="2_fs80u"] -[ext_resource type="Resource" uid="uid://kamgujbqqhg3" path="res://docs/gyms/tiny_sword/buffs/forestry_auto_flux_buff.tres" id="2_mgk3v"] [ext_resource type="Resource" uid="uid://bo463s6jt0ep7" path="res://docs/gyms/tiny_sword/goals/gold_total_13k_goal.tres" id="4_mgk3v"] [ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="4_rij12"] @@ -15,9 +13,7 @@ starts_available = false initial_owned = 1 purchase_currency = ExtResource("2_fs80u") unlock_goal = ExtResource("4_mgk3v") -unlock_goal_behavior = 1 initial_cost = 1.0 coefficient = 1.0 initial_productivity = 20.0 -buffs = Array[ExtResource("1_mgk3v")]([ExtResource("2_mgk3v")]) metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres index 1b6a03c..8de8fa6 100644 --- a/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres +++ b/docs/gyms/tiny_sword/buildings/gold_mine/gold_mine_generator.tres @@ -1,9 +1,7 @@ [gd_resource type="Resource" script_class="CurrencyGeneratorData" format=3 uid="uid://dliilvvjvksk1"] -[ext_resource type="Script" uid="uid://dnhocfsuvmeyb" path="res://core/generator/generator_buff_data.gd" id="1_1ni3y"] -[ext_resource type="Resource" uid="uid://dlq2ke6i2pfob" path="res://docs/gyms/tiny_sword/buffs/gold_click_buff.tres" id="2_cnabh"] [ext_resource type="Resource" uid="uid://dfxk30o34qe2s" path="res://docs/gyms/tiny_sword/currencies/worker.tres" id="2_j1lw5"] -[ext_resource type="Resource" uid="uid://c3op2mqy02sow" path="res://docs/gyms/tiny_sword/buffs/gold_auto_flux_buff.tres" id="3_baaj7"] +[ext_resource type="Resource" uid="uid://dif6w6kesmw6u" path="res://docs/gyms/tiny_sword/research/gold_mine_research.tres" id="2_qkhnt"] [ext_resource type="Script" uid="uid://b00tqsuhxdy0d" path="res://core/generator/currency_generator_data.gd" id="3_cnabh"] [resource] @@ -14,5 +12,8 @@ purchase_currency = ExtResource("2_j1lw5") initial_cost = 1.0 coefficient = 1.0 initial_productivity = 20.0 -buffs = Array[ExtResource("1_1ni3y")]([ExtResource("2_cnabh"), ExtResource("3_baaj7")]) +click_mantissa = 1.0 +click_exponent = 0 +click_cooldown_seconds = 0.2 +research_data = ExtResource("2_qkhnt") metadata/_custom_type_script = "uid://b00tqsuhxdy0d" diff --git a/docs/gyms/tiny_sword/research/gold_mine_research.tres b/docs/gyms/tiny_sword/research/gold_mine_research.tres new file mode 100644 index 0000000..603fef9 --- /dev/null +++ b/docs/gyms/tiny_sword/research/gold_mine_research.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="ResearchData" format=3 uid="uid://dif6w6kesmw6u"] + +[ext_resource type="Texture2D" uid="uid://bgtt3wu43tajh" path="res://icon.svg" id="1_2tci1"] +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="1_yn72k"] + +[resource] +script = ExtResource("1_yn72k") +id = &"gold_research" +generator_id = &"goldmine" +name = "Gold Mine Research" +description = "Research that improves gold mine production" +icon = ExtResource("1_2tci1") +xp_per_currency_produced = 0.01 +base_xp_required = 100.0 +xp_growth_multiplier = 1.5 +base_multiplier = 1.0 +multiplier_per_level = 0.1 +metadata/_custom_type_script = "uid://m7baywrfnpn0" diff --git a/docs/gyms/tiny_sword/research/ts_research_catalogue.tres b/docs/gyms/tiny_sword/research/ts_research_catalogue.tres new file mode 100644 index 0000000..65df7b2 --- /dev/null +++ b/docs/gyms/tiny_sword/research/ts_research_catalogue.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="ResearchCatalogue" format=3 uid="uid://umi37hotcq8m"] + +[ext_resource type="Script" uid="uid://m7baywrfnpn0" path="res://core/research/research_data.gd" id="1_wscpr"] +[ext_resource type="Resource" uid="uid://dif6w6kesmw6u" path="res://docs/gyms/tiny_sword/research/gold_mine_research.tres" id="2_migxn"] +[ext_resource type="Script" uid="uid://d2v6t6w2todfy" path="res://core/research/research_catalogue.gd" id="2_yh8dx"] + +[resource] +script = ExtResource("2_yh8dx") +research_entries = Array[ExtResource("1_wscpr")]([ExtResource("2_migxn")]) +metadata/_custom_type_script = "uid://d2v6t6w2todfy" diff --git a/docs/gyms/tiny_sword/tiny_sword.tscn b/docs/gyms/tiny_sword/tiny_sword.tscn index 296e793..e4807e5 100644 --- a/docs/gyms/tiny_sword/tiny_sword.tscn +++ b/docs/gyms/tiny_sword/tiny_sword.tscn @@ -6,6 +6,7 @@ [ext_resource type="PackedScene" uid="uid://cl05bdri38mxf" path="res://docs/gyms/tiny_sword/buildings/castle/castle.tscn" id="3_5ru58"] [ext_resource type="Resource" uid="uid://bgmj3lep0wmtf" path="res://docs/gyms/tiny_sword/buffs/ts_buff_catalogue.tres" id="3_wl838"] [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" uid="uid://dwmfvmusfskk6" path="res://docs/gyms/tiny_sword/prestige/primary_prestige.tres" id="6_xnhlc"] [ext_resource type="PackedScene" uid="uid://btkxru2gdjsgc" path="res://currency_tile.tscn" id="7_0cs5o"] @@ -18,6 +19,7 @@ [ext_resource type="Resource" uid="uid://bgsk8h4w80h45" path="res://docs/gyms/tiny_sword/currencies/wood.tres" id="12_l6a68"] [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="Resource" uid="uid://bfrb0ayrljac2" path="res://docs/gyms/tiny_sword/currencies/ascension.tres" id="15_ascension"] +[ext_resource type="PackedScene" uid="uid://dd8roif0mqirl" path="res://core/research/research_panel.tscn" id="21_no27p"] [node name="TinySwords" type="Node" unique_id=498237642] @@ -26,6 +28,7 @@ script = ExtResource("1_tma5j") currency_catalogue = ExtResource("2_dwrof") buff_catalogue = ExtResource("3_wl838") goal_catalogue = ExtResource("4_x77df") +research_catalogue = ExtResource("5_v0pty") save_file_path = "user://tiny_sword_save.json" [node name="PrestigeManager" type="Node" parent="LevelGameState" unique_id=1019028144 node_paths=PackedStringArray("game_state")] @@ -116,7 +119,7 @@ offset_bottom = 125.0 currency = ExtResource("12_l6a68") game_state = NodePath("../..") -[node name="AscensionCurrencyTile" parent="LevelGameState/UI" unique_id=5000000001 node_paths=PackedStringArray("game_state") instance=ExtResource("7_0cs5o")] +[node name="AscensionCurrencyTile" parent="LevelGameState/UI" unique_id=705032705 node_paths=PackedStringArray("game_state") instance=ExtResource("7_0cs5o")] layout_mode = 0 offset_top = 125.0 offset_right = 160.0 @@ -130,3 +133,15 @@ offset_left = 1275.0 offset_top = 4.0 offset_right = 1913.0 offset_bottom = 263.0 + +[node name="ResearchPanel" parent="LevelGameState/UI" unique_id=2145420920 instance=ExtResource("21_no27p")] +custom_minimum_size = Vector2(350, 100) +layout_mode = 1 +anchors_preset = -1 +anchor_left = 7.4500003 +anchor_right = 16.2 +anchor_bottom = 3.1750002 +offset_right = 0.0 +offset_bottom = -7.6293945e-06 +grow_horizontal = 2 +grow_vertical = 2 diff --git a/scripts/check_syntax.sh b/scripts/check_syntax.sh new file mode 100755 index 0000000..1dbbfdc --- /dev/null +++ b/scripts/check_syntax.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Godot Syntax Checker +# Usage: ./scripts/check_syntax.sh +# Example: ./scripts/check_syntax.sh res://tests/test_gold_mine_click.gd + +SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$SCRIPT" ]; then + echo "Usage: $0 " + echo "Example: $0 res://tests/test_gold_mine_click.gd" + exit 1 +fi + +if [ ! -f "$SCRIPT" ]; then + echo "Error: Script not found: $SCRIPT" + exit 1 +fi + +echo "==========================================" +echo "Checking syntax: $SCRIPT" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . --check-only --script "$SCRIPT" +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Syntax valid" +else + echo "✗ Syntax errors found" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/scripts/run_all.sh b/scripts/run_all.sh new file mode 100755 index 0000000..40032d2 --- /dev/null +++ b/scripts/run_all.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Godot Test Runner - All Tests +# Usage: ./scripts/run_all.sh + +set -e + +GODOT_BIN="${GODOT_BIN:-godot}" + +echo "==========================================" +echo "Running All Tests" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . -s res://tests/test_runner.gd +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ All tests passed" +else + echo "✗ Some tests failed" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/scripts/run_test.sh b/scripts/run_test.sh new file mode 100755 index 0000000..80d52f7 --- /dev/null +++ b/scripts/run_test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Godot Test Runner - Single Test +# Usage: ./scripts/run_test.sh +# Example: ./scripts/run_test.sh res://tests/test_gold_mine_click.gd + +set -e + +TEST_SCRIPT="$1" +GODOT_BIN="${GODOT_BIN:-godot}" + +if [ -z "$TEST_SCRIPT" ]; then + echo "Usage: $0 " + echo "Example: $0 res://tests/test_gold_mine_click.gd" + exit 1 +fi + +if [ ! -f "$TEST_SCRIPT" ]; then + echo "Error: Test script not found: $TEST_SCRIPT" + exit 1 +fi + +echo "==========================================" +echo "Running test: $TEST_SCRIPT" +echo "==========================================" +echo "" + +"$GODOT_BIN" --headless --path . -s "$TEST_SCRIPT" +EXIT_CODE=$? + +echo "" +echo "==========================================" +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ Test passed" +else + echo "✗ Test failed with exit code: $EXIT_CODE" +fi +echo "==========================================" + +exit $EXIT_CODE diff --git a/tests/test_gold_mine_click.gd b/tests/test_gold_mine_click.gd new file mode 100644 index 0000000..1d50e93 --- /dev/null +++ b/tests/test_gold_mine_click.gd @@ -0,0 +1,78 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + +func _ready(): + await run() + if failed == 0: + get_tree().quit(0) + else: + get_tree().quit(1) + +func run(): + print("\n=== TEST: Gold Mine Click ===\n") + TestUtils.set_test_name("gold_mine_click") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + + await _wait() + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var gold_mine = _game_root.find_child("GoldMine") as CurrencyGenerator + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + if gold_mine == null: + print("[ERROR] GoldMine node not found") + _print_summary() + return + + # Record initial gold + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: %s" % initial_gold.to_string_suffix(2)) + + # Simulate clicking the generator + gold_mine._on_pressed() + + await _wait() + + # Validate gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: %s" % final_gold.to_string_suffix(2)) + + TestUtils.assert_greater_than( + final_gold.mantissa, + initial_gold.mantissa, + "Gold increased after generator click" + ) + + # Check specific value (adjust based on click_mantissa in data) + var expected_min: float = initial_gold.mantissa + 1.0 + TestUtils.assert_true( + final_gold.mantissa >= expected_min, + "Gold increased by at least 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + 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_gold_mine_click.gd.uid b/tests/test_gold_mine_click.gd.uid new file mode 100644 index 0000000..f0103d4 --- /dev/null +++ b/tests/test_gold_mine_click.gd.uid @@ -0,0 +1 @@ +uid://c2xborf2klkc4 diff --git a/tests/test_prestige.gd b/tests/test_prestige.gd new file mode 100644 index 0000000..00d80de --- /dev/null +++ b/tests/test_prestige.gd @@ -0,0 +1,94 @@ +extends Node + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null + +func _ready(): + await run() + if failed == 0: + get_tree().quit(0) + else: + get_tree().quit(1) + +func run(): + print("\n=== TEST: Prestige Mechanics ===\n") + TestUtils.set_test_name("prestige") + + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + _game_root = scene.instantiate() + add_child(_game_root) + + await _wait() + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + var prestige_manager = _game_root.find_child("PrestigeManager") as PrestigeManager + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + return + + if prestige_manager == null: + print("[ERROR] PrestigeManager not found") + _print_summary() + return + + # Setup: Accumulate currencies to meet prestige threshold + game_state.add_currency_by_id(&"gold", BigNumber.from_float(100000.0)) + game_state.add_currency_by_id(&"gems", BigNumber.from_float(100.0)) + + await _wait() + + # Check if can prestige + var can_prestige: bool = prestige_manager.can_prestige() + print("[TARGET] can_prestige: %s" % str(can_prestige)) + + TestUtils.assert_true(can_prestige, "Should be able to prestige after accumulating currencies") + + if can_prestige: + # Perform prestige + prestige_manager.perform_prestige() + + await _wait() + + # Check ascension currency + var ascension: BigNumber = game_state.get_currency_amount_by_id(&"ascension") + print("[TARGET] ascension_currency: %s" % ascension.to_string_suffix(2)) + + TestUtils.assert_true( + ascension.mantissa > 0, + "Should receive ascension currency after prestige" + ) + + # Check prestige multiplier increased + var total_multiplier = prestige_manager.get_total_multiplier() + print("[TARGET] total_multiplier: %s" % str(total_multiplier)) + + var mult_value: float + if total_multiplier is float: + mult_value = total_multiplier + else: + mult_value = float(total_multiplier) + + TestUtils.assert_greater_than( + mult_value, + 1.0, + "Prestige multiplier should be > 1.0" + ) + + _print_summary() + +func _wait() -> void: + await get_tree().create_timer(0.5).timeout + +func _print_summary(): + 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_prestige.gd.uid b/tests/test_prestige.gd.uid new file mode 100644 index 0000000..dfec39d --- /dev/null +++ b/tests/test_prestige.gd.uid @@ -0,0 +1 @@ +uid://mm5vcjq44wf1 diff --git a/tests/test_research_xp_acquisition.gd b/tests/test_research_xp_acquisition.gd new file mode 100644 index 0000000..8593383 --- /dev/null +++ b/tests/test_research_xp_acquisition.gd @@ -0,0 +1,146 @@ +extends SceneTree + +var passed: int = 0 +var failed: int = 0 +var _game_root: Node = null +var _test_complete: bool = false + +func _init(): + print("\n=== TEST: Research XP Acquisition ===\n") + TestUtils.set_test_name("research_xp_acquisition") + + # Load tiny_sword scene + var scene = load("res://docs/gyms/tiny_sword/tiny_sword.tscn") + change_scene_to_packed(scene) + + await _wait() + + _game_root = root.get_child(0) + + var game_state: LevelGameState = _game_root.find_child("LevelGameState") + + if game_state == null: + print("[ERROR] LevelGameState not found") + _print_summary() + _test_complete = true + quit(1) + return + + # Find GoldMine CurrencyGenerator + var gold_mine: CurrencyGenerator = null + var world = game_state.get_node_or_null("World") + if world: + for child in world.get_children(): + if child.get_name() == "GoldMine": + for gc_child in child.get_children(): + if gc_child is CurrencyGenerator: + gold_mine = gc_child + break + + if gold_mine == null: + print("[ERROR] GoldMine CurrencyGenerator node not found") + _print_summary() + _test_complete = true + quit(1) + return + + # Give the generator initial ownership so clicks work properly + if gold_mine.owned <= 0: + game_state.add_currency_by_id(&"worker", BigNumber.from_float(100.0)) + await _wait(0.05) + gold_mine.buy(1) + await _wait(0.05) + + # Get initial gold currency + var initial_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] initial_gold: mantissa=%s, exponent=%s" % [initial_gold.mantissa, initial_gold.exponent]) + + # Get initial research XP + var initial_xp: BigNumber = game_state.get_research_xp(&"gold_research") + print("[TARGET] initial_research_xp: mantissa=%s, exponent=%s" % [initial_xp.mantissa, initial_xp.exponent]) + + # Set press_buys_generator to false so clicks grant currency instead of buying + gold_mine.press_buys_generator = false + + # Simulate clicking the generator multiple times to produce currency + var click_count: int = 5 + for i in range(click_count): + gold_mine._on_pressed() + await _wait(0.1) + + # Check gold increased + var final_gold: BigNumber = game_state.get_currency_amount_by_id(&"gold") + print("[TARGET] final_gold: mantissa=%s, exponent=%s" % [final_gold.mantissa, final_gold.exponent]) + + # Check owned count + var owned: int = gold_mine.owned + print("[TARGET] gold_mine_owned: %d" % owned) + + # Validate research XP increased + var final_xp: BigNumber = game_state.get_research_xp(&"gold_research") + print("[TARGET] final_research_xp: mantissa=%s, exponent=%s" % [final_xp.mantissa, final_xp.exponent]) + + TestUtils.assert_true( + final_xp.is_greater_than(initial_xp), + "Research XP increased after generator clicks" + ) + + # Test level calculation + var level: int = game_state.get_research_level(&"gold_research") + print("[TARGET] research_level: %d" % level) + + TestUtils.assert_true( + level >= 0, + "Research level is non-negative" + ) + + # Test research multiplier + var multiplier: float = game_state.get_research_multiplier(&"gold_research") + print("[TARGET] research_multiplier: %f" % multiplier) + + TestUtils.assert_greater_than( + multiplier, + 0.0, + "Research multiplier is positive" + ) + + # Test BigNumber XP progress calculation + var research_data: ResearchData = game_state.research_catalogue.get_research_by_id(&"gold_research") + if research_data != null: + var progress: float = research_data.get_xp_progress(final_xp) + print("[TARGET] xp_progress: %f" % progress) + + TestUtils.assert_true( + progress >= 0.0 and progress <= 1.0, + "XP progress is between 0.0 and 1.0" + ) + + # Test level calculation with BigNumber + var calculated_level: int = research_data.get_level_for_xp(final_xp) + print("[TARGET] calculated_level: %d" % calculated_level) + + TestUtils.assert_true( + calculated_level >= 0, + "Calculated level is non-negative" + ) + + _print_summary() + _test_complete = true + if failed == 0: + quit(0) + else: + quit(1) + +func _wait(seconds: float = 0.1) -> void: + await create_timer(seconds).timeout + +func _print_summary(): + 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_research_xp_acquisition.gd.uid b/tests/test_research_xp_acquisition.gd.uid new file mode 100644 index 0000000..d04c8b8 --- /dev/null +++ b/tests/test_research_xp_acquisition.gd.uid @@ -0,0 +1 @@ +uid://cq65acistj7ar diff --git a/tests/test_runner.gd b/tests/test_runner.gd new file mode 100644 index 0000000..ede31e2 --- /dev/null +++ b/tests/test_runner.gd @@ -0,0 +1,64 @@ +extends Node + +var test_scripts: Array[String] = [] +var test_index: int = 0 +var total_passed: int = 0 +var total_failed: int = 0 + +func _ready(): + print("\n=== GODOT TEST RUNNER ===\n") + _discover_tests() + + if test_scripts.is_empty(): + print("[ERROR] No test scripts found in res://tests/") + print("[RESULT] FAIL") + get_tree().quit(1) + return + + print("Found %d test(s): %s\n" % [test_scripts.size(), str(test_scripts)]) + + await _run_next_test() + + print("\n=== TEST SUMMARY ===") + print("Total passed: %d" % total_passed) + print("Total failed: %d" % total_failed) + + if total_failed == 0: + print("[OVERALL_RESULT] PASS") + get_tree().quit(0) + else: + print("[OVERALL_RESULT] FAIL") + get_tree().quit(1) + +func _discover_tests() -> void: + var dir = DirAccess.open("res://tests") + if dir: + dir.list_dir_begin() + var file_name = dir.get_next() + while file_name != "": + if file_name.ends_with(".gd") and file_name.begins_with("test_") and \ + file_name != "test_runner.gd" and file_name != "test_utils.gd": + test_scripts.append("res://tests/%s" % file_name) + file_name = dir.get_next() + dir.list_dir_end() + test_scripts.sort() + +func _run_next_test() -> void: + if test_index >= test_scripts.size(): + return + + var test_script = test_scripts[test_index] + print("--- Running test %d/%d: %s ---" % [test_index + 1, test_scripts.size(), test_script]) + print("") + + # Load and run test + var test_instance = load(test_script).new() + add_child(test_instance) + await test_instance.run() + + total_passed += test_instance.get_passed() + total_failed += test_instance.get_failed() + + print("") + test_index += 1 + await _run_next_test() diff --git a/tests/test_runner.gd.uid b/tests/test_runner.gd.uid new file mode 100644 index 0000000..06fc00f --- /dev/null +++ b/tests/test_runner.gd.uid @@ -0,0 +1 @@ +uid://chame3fmeexw6 diff --git a/tests/test_utils.gd b/tests/test_utils.gd new file mode 100644 index 0000000..a1a8519 --- /dev/null +++ b/tests/test_utils.gd @@ -0,0 +1,74 @@ +class_name TestUtils +extends RefCounted + +static var _test_name: String = "" +static var _passed: int = 0 +static var _failed: int = 0 + +static func set_test_name(name: String) -> void: + _test_name = name + _passed = 0 + _failed = 0 + +static func assert_equals(expected: Variant, actual: Variant, message: String) -> void: + if expected == actual: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s, got %s)" % [ + _test_name, message, str(expected), str(actual) + ]) + _failed += 1 + +static func assert_true(condition: bool, message: String) -> void: + if condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func assert_false(condition: bool, message: String) -> void: + if not condition: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected false, got true)" % [_test_name, message]) + _failed += 1 + +static func assert_greater_than(a: float, b: float, message: String) -> void: + if a > b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s > %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func assert_greater_or_equal(a: float, b: float, message: String) -> void: + if a >= b: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s (expected %s >= %s)" % [_test_name, message, str(a), str(b)]) + _failed += 1 + +static func assert_not_null(value: Variant, message: String) -> void: + if value != null: + print("[PASS] %s: %s" % [_test_name, message]) + _passed += 1 + else: + print("[FAIL] %s: %s" % [_test_name, message]) + _failed += 1 + +static func get_passed() -> int: + return _passed + +static func get_failed() -> int: + return _failed + +static func print_result() -> void: + print("[SUMMARY] %s: %d passed, %d failed" % [_test_name, _passed, _failed]) + if _failed == 0: + print("[RESULT] PASS") + else: + print("[RESULT] FAIL") diff --git a/tests/test_utils.gd.uid b/tests/test_utils.gd.uid new file mode 100644 index 0000000..da74c32 --- /dev/null +++ b/tests/test_utils.gd.uid @@ -0,0 +1 @@ +uid://ous5m4c7jotn