Files
idle/.agents/skills/godot-test-instrumentation/SKILL.md
Michele Rossi 81a4058b04 systems rework: buffs, prestige graph, research, modular architecture
Decouples core systems from centralized globals in favor of catalogue-based architecture and data-driven content.

   Key changes:
   - Prestige: node-based buff tech tree, graph panel, progress bar
   - Research: multi-worker system, per-generator research, xp generation
   - Ascension: meta-currency layer via multi-currency prestige resets
   - Buffs: refactored as generator-independent via catalogues
   - UI: currency panel, edge-scrolling camera + zoom, current-goal panel
   - Alchemy Tower: crafting building with recipe/cost system
   - Testing: 7 test suites (ascension, prestige, research, goals)
   - Content: migrated from hardcoded idles/ to gym format (tiny_sword)
2026-05-07 20:36:21 +00:00

17 KiB

name, description
name description
godot-test-instrumentation 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.

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.

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:

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

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.

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.

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

#!/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

#!/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

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

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

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:

    touch res://tests/test_my_feature.gd
    
  2. Copy template:

    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:

    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

/home/mikymod/work/jmp/idle/.agents/skills/godot-test-instrumentation/SKILL.md