Fix research

This commit is contained in:
2026-04-15 12:01:28 +02:00
parent 0e8c682ae7
commit 45e1df5a46
37 changed files with 2848 additions and 20 deletions

View File

@@ -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("<test_name>")
# Load scene
var scene = load("<path_to_gym_scene>") # 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("<GeneratorNodeName>") 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(&"<currency_id>")
print("[TARGET] initial_<currency>: %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(&"<currency_id>")
print("[TARGET] final_<currency>: %s" % final_value.to_string_suffix(2))
TestUtils.assert_greater_than(
final_value.mantissa,
initial_value.mantissa,
"<currency> 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 <test_script_path>
# 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 <test_script_path>"
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_path>
SCRIPT="$1"
GODOT_BIN="${GODOT_BIN:-godot}"
if [ -z "$SCRIPT" ]; then
echo "Usage: $0 <script_path>"
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_<feature>.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)
</content>
<parameter=filePath>
/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md

View File

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

View File

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

View File

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

View File

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

459
core/research/README.md Normal file
View File

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

645
core/research/TODO.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://d2v6t6w2todfy

View File

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

View File

@@ -0,0 +1 @@
uid://m7baywrfnpn0

View File

@@ -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])

View File

@@ -0,0 +1 @@
uid://fdl7ftxw8w1e

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://gk3mr3k5yvp7

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://bv3a4utlnn2fg

View File

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

View File

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

View File

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

View File

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

View File

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

37
scripts/check_syntax.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Godot Syntax Checker
# Usage: ./scripts/check_syntax.sh <script_path>
# 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 <script_path>"
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

26
scripts/run_all.sh Executable file
View File

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

39
scripts/run_test.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Godot Test Runner - Single Test
# Usage: ./scripts/run_test.sh <test_script_path>
# 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 <test_script_path>"
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

View File

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

View File

@@ -0,0 +1 @@
uid://c2xborf2klkc4

94
tests/test_prestige.gd Normal file
View File

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

View File

@@ -0,0 +1 @@
uid://mm5vcjq44wf1

View File

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

View File

@@ -0,0 +1 @@
uid://cq65acistj7ar

64
tests/test_runner.gd Normal file
View File

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

1
tests/test_runner.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://chame3fmeexw6

74
tests/test_utils.gd Normal file
View File

@@ -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")

1
tests/test_utils.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://ous5m4c7jotn