Brücke zu Godot und Godot-Projekt
Die GDExtension grovewright-bridge gibt den Kern als Klasse GwSession an GDScript weiter: Befehle einreihen, einen Takt ausführen, Karte und Inventar lesen, speichern, laden, aufzeichnen. Eine Panik im Kern macht die Sitzung defekt, statt einen guten Speicherstand zu überschreiben. Das Godot-Projekt bekommt einen Testläufer, der auch Laufzeitfehler als Fehlschlag zählt. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
extends SceneTree
|
||||
## Runs every test script in res://tests whose file name starts with "test_".
|
||||
## Every method whose name starts with "test_" is one test. A test fails if a
|
||||
## check fails or if an error is reported while it runs, for example a call
|
||||
## on a null value. Exits with code 1 if any test failed.
|
||||
##
|
||||
## From the repository root:
|
||||
## godot --headless --path game -s res://tests/run_tests.gd
|
||||
|
||||
const TEST_DIR := "res://tests"
|
||||
|
||||
|
||||
## Counts the errors that are reported while tests run. Warnings do not count.
|
||||
class ErrorCounter extends Logger:
|
||||
var _mutex := Mutex.new()
|
||||
var _errors := 0
|
||||
|
||||
func errors() -> int:
|
||||
_mutex.lock()
|
||||
var count := _errors
|
||||
_mutex.unlock()
|
||||
return count
|
||||
|
||||
func _log_error(
|
||||
_function: String,
|
||||
_file: String,
|
||||
_line: int,
|
||||
_code: String,
|
||||
_rationale: String,
|
||||
_editor_notify: bool,
|
||||
error_type: int,
|
||||
_script_backtraces: Array[ScriptBacktrace]
|
||||
) -> void:
|
||||
if error_type == ERROR_TYPE_WARNING:
|
||||
return
|
||||
_mutex.lock()
|
||||
_errors += 1
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_all.call_deferred()
|
||||
|
||||
|
||||
func _run_all() -> void:
|
||||
var counter := ErrorCounter.new()
|
||||
OS.add_logger(counter)
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var files := DirAccess.get_files_at(TEST_DIR)
|
||||
files.sort()
|
||||
for file in files:
|
||||
if not (file.begins_with("test_") and file.ends_with(".gd")):
|
||||
continue
|
||||
var script: GDScript = load(TEST_DIR.path_join(file))
|
||||
if script == null or not script.can_instantiate():
|
||||
failed += 1
|
||||
print("FAIL %s the script does not load; see the error above" % file)
|
||||
continue
|
||||
for method in script.get_script_method_list():
|
||||
var test_name: String = method["name"]
|
||||
if not test_name.begins_with("test_"):
|
||||
continue
|
||||
var test: TestCase = script.new()
|
||||
test.tree = self
|
||||
var errors_before := counter.errors()
|
||||
await test.call(test_name)
|
||||
var errors := counter.errors() - errors_before
|
||||
if errors > 0:
|
||||
test.failures.append("%d error(s) reported while the test ran; see above" % errors)
|
||||
if test.failures.is_empty():
|
||||
passed += 1
|
||||
print("ok %s %s" % [file, test_name])
|
||||
else:
|
||||
failed += 1
|
||||
print("FAIL %s %s" % [file, test_name])
|
||||
for failure in test.failures:
|
||||
print(" " + failure)
|
||||
OS.remove_logger(counter)
|
||||
print("%d passed, %d failed" % [passed, failed])
|
||||
quit(1 if failed > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1bhpsppk18k2
|
||||
@@ -0,0 +1,19 @@
|
||||
class_name TestCase
|
||||
extends RefCounted
|
||||
## Base of every test script. Checks collect failures instead of stopping at
|
||||
## the first one, so a run shows everything that is wrong.
|
||||
|
||||
var failures: PackedStringArray = []
|
||||
## The running SceneTree, for tests that need frames or nodes.
|
||||
var tree: SceneTree
|
||||
|
||||
|
||||
func check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
## Fails unless `actual` and `expected` have the same type and value.
|
||||
func check_eq(actual: Variant, expected: Variant, message: String) -> void:
|
||||
if typeof(actual) != typeof(expected) or actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [message, expected, actual])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlwjcshat6wd2
|
||||
@@ -0,0 +1,140 @@
|
||||
extends TestCase
|
||||
## Tests of the bridge: GwSession is the Rust core, seen from GDScript.
|
||||
|
||||
var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
|
||||
|
||||
|
||||
func _joined_session() -> GwSession:
|
||||
var session := GwSession.new()
|
||||
session.start_new(7)
|
||||
session.join(key, "Ada")
|
||||
session.step()
|
||||
return session
|
||||
|
||||
|
||||
func _first_soft_rock(session: GwSession) -> Vector2i:
|
||||
var terrain := session.terrain_codes()
|
||||
var width := session.map_width()
|
||||
for index in terrain.size():
|
||||
if terrain[index] == GwSession.TERRAIN_SOFT_ROCK:
|
||||
@warning_ignore("integer_division")
|
||||
return Vector2i(index % width, index / width)
|
||||
return Vector2i(-1, -1)
|
||||
|
||||
|
||||
## Hits `tile` three times, waiting out the cooldown, and returns all events.
|
||||
func _dig_three_times(session: GwSession, tile: Vector2i) -> Array[Dictionary]:
|
||||
var events: Array[Dictionary] = []
|
||||
for hit in 3:
|
||||
session.dig(tile.x, tile.y)
|
||||
events.append_array(session.step())
|
||||
for wait in GwSession.DIG_COOLDOWN_TICKS:
|
||||
session.step()
|
||||
return events
|
||||
|
||||
|
||||
func test_a_session_without_a_world_is_harmless() -> void:
|
||||
var session := GwSession.new()
|
||||
check(not session.is_running(), "nothing runs yet")
|
||||
check_eq(session.tick(), 0, "tick")
|
||||
check_eq(session.step().size(), 0, "no events")
|
||||
check_eq(session.save().size(), 0, "nothing to save")
|
||||
check_eq(session.local_player(), -1, "no local player")
|
||||
|
||||
|
||||
func test_joining_makes_the_player_local() -> void:
|
||||
var session := GwSession.new()
|
||||
session.start_new(7)
|
||||
check(session.join(key, "Ada"), "join accepted")
|
||||
check_eq(session.local_player(), -1, "not before the tick")
|
||||
var events := session.step()
|
||||
check_eq(events.size(), 1, "one event")
|
||||
check_eq(events[0]["type"], "player_joined", "event type")
|
||||
check_eq(session.local_player(), 0, "local player after the tick")
|
||||
check_eq(session.tick(), 1, "one tick ran")
|
||||
|
||||
|
||||
func test_a_key_needs_sixteen_bytes() -> void:
|
||||
var session := GwSession.new()
|
||||
session.start_new(7)
|
||||
check(not session.join(PackedByteArray([1, 2, 3]), "Ada"), "short key refused")
|
||||
check(not session.last_error().is_empty(), "the reason is given")
|
||||
|
||||
|
||||
func test_the_map_can_be_read() -> void:
|
||||
var session := _joined_session()
|
||||
check_eq(session.map_width(), 64, "width")
|
||||
check_eq(session.map_height(), 64, "height")
|
||||
check_eq(session.terrain_codes().size(), 64 * 64, "one code per tile")
|
||||
check_eq(session.damage_codes().size(), 64 * 64, "one damage per tile")
|
||||
var spawn := session.spawn_point()
|
||||
check_eq(spawn, Vector2i(32, 32), "spawn in the centre")
|
||||
check(session.is_walkable(spawn.x, spawn.y), "the spawn can be walked on")
|
||||
check_eq(session.terrain_at(0, 0), GwSession.TERRAIN_BEDROCK, "the border is bedrock")
|
||||
check_eq(session.terrain_at(-1, 0), -1, "outside the map")
|
||||
check(not session.is_walkable(500, 500), "outside the map is not walkable")
|
||||
|
||||
|
||||
func test_three_hits_break_soft_rock_and_give_a_stone() -> void:
|
||||
var session := _joined_session()
|
||||
var rock := _first_soft_rock(session)
|
||||
check(rock.x >= 0, "the map has soft rock")
|
||||
var events := _dig_three_times(session, rock)
|
||||
var types := events.map(func(event: Dictionary) -> String: return event["type"])
|
||||
check_eq(types, ["tile_hit", "tile_hit", "tile_changed", "item_gained"], "event order")
|
||||
check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone")
|
||||
check_eq(session.item_count(GwSession.ITEM_STONE), 1, "one stone")
|
||||
|
||||
|
||||
func test_digging_before_joining_does_nothing() -> void:
|
||||
var session := GwSession.new()
|
||||
session.start_new(7)
|
||||
var rock := _first_soft_rock(session)
|
||||
session.dig(rock.x, rock.y)
|
||||
check_eq(session.step().size(), 0, "no events")
|
||||
check_eq(session.damage_codes()[rock.y * session.map_width() + rock.x], 0, "no damage")
|
||||
|
||||
|
||||
func test_a_save_keeps_the_world_and_the_player_comes_back() -> void:
|
||||
var session := _joined_session()
|
||||
_dig_three_times(session, _first_soft_rock(session))
|
||||
var save := session.save()
|
||||
check(save.size() > 0, "a save was made")
|
||||
|
||||
var loaded := GwSession.new()
|
||||
check(loaded.start_loaded(save), "the save loads")
|
||||
check_eq(loaded.tick(), session.tick(), "same tick")
|
||||
check_eq(loaded.terrain_codes(), session.terrain_codes(), "same map")
|
||||
check_eq(loaded.local_player(), -1, "nobody has joined the loaded world yet")
|
||||
loaded.join(key, "Ada")
|
||||
loaded.step()
|
||||
check_eq(loaded.local_player(), 0, "the player is the same")
|
||||
check_eq(loaded.item_count(GwSession.ITEM_STONE), 1, "the stone is still there")
|
||||
|
||||
|
||||
func test_a_damaged_save_is_refused_and_the_running_world_stays() -> void:
|
||||
var session := _joined_session()
|
||||
var hash_before := session.state_hash_hex()
|
||||
var save := session.save()
|
||||
var middle := save.size() >> 1
|
||||
save[middle] = save[middle] ^ 0xFF
|
||||
check(not session.start_loaded(save), "a damaged save is refused")
|
||||
check(session.last_error().contains("checksum"), "the reason names the checksum: %s" % session.last_error())
|
||||
check(not session.start_loaded(PackedByteArray([1, 2, 3])), "random bytes are refused")
|
||||
check_eq(session.state_hash_hex(), hash_before, "the running world is untouched")
|
||||
|
||||
|
||||
func test_the_recording_of_a_session_is_available() -> void:
|
||||
var session := _joined_session()
|
||||
session.step()
|
||||
var recording := session.recording_bytes()
|
||||
check(recording.size() > 0, "a recording exists")
|
||||
check_eq(recording.slice(0, 4), "GRWR".to_ascii_buffer(), "it starts with the recording marker")
|
||||
|
||||
|
||||
func test_the_state_hash_changes_with_every_tick() -> void:
|
||||
var session := _joined_session()
|
||||
var first := session.state_hash_hex()
|
||||
session.step()
|
||||
check_eq(first.length(), 16, "sixteen hexadecimal digits")
|
||||
check(session.state_hash_hex() != first, "the tick is part of the state")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cm1pa0uh5tmn5
|
||||
Reference in New Issue
Block a user