Files
Grovewright/game/tests/test_session.gd
T
Steven Wroblewski 2faa3ccce3 Brücke für Host und Gast
GwSession führt eine Sitzung als Host oder als Gast und reicht die
Nachrichten des Kerns an GDScript weiter. Nur der Host speichert. Die
Behandlung interner Fehler liegt jetzt in einem reinen Rust-Teil und hat
einen Test, und das Ereignis join_refused nennt den Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 19:20:34 +02:00

237 lines
9.1 KiB
GDScript

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")
var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
## Carries every waiting message between `host` and `guest`, which is
## connected to the host as peer 5, until none are left. Returns the notices
## the guest got.
func _carry(host: GwSession, guest: GwSession) -> Array[Dictionary]:
var notices: Array[Dictionary] = []
var moved := true
while moved:
moved = false
for message in guest.take_messages():
check_eq(message["peer"], GwSession.HOST_PEER, "a guest writes to its host")
host.receive(5, message["bytes"])
moved = true
for message in host.take_messages():
if message["peer"] == 5:
notices.append_array(guest.receive(GwSession.HOST_PEER, message["bytes"]))
moved = true
return notices
## A host whose player Ada is in the world, and the guest Grace at peer 5,
## whose join has run.
func _host_and_guest() -> Array[GwSession]:
var host := _joined_session()
host.connect_peer(5)
var guest := GwSession.new()
guest.start_guest(guest_key, "Grace")
_carry(host, guest)
host.step()
_carry(host, guest)
while guest.pending_ticks() > 0:
guest.step()
return [host, guest]
func test_a_guest_follows_its_host_through_the_bridge() -> void:
var host := _joined_session()
host.connect_peer(5)
var guest := GwSession.new()
check(guest.start_guest(guest_key, "Grace"), "the guest starts")
check(not guest.is_host(), "as a guest")
check(not guest.is_running(), "without a world before the host answers")
check_eq(_carry(host, guest), [{"type": "world_received", "resync": false}], "the host's world arrives")
check_eq(guest.tick(), host.tick(), "at the host's tick")
for tick in 25:
host.step()
_carry(host, guest)
check_eq(guest.pending_ticks(), 25, "the ticks wait until the guest runs them")
while guest.pending_ticks() > 0:
guest.step()
check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same")
check_eq(guest.local_player(), 1, "the guest is player 1")
check_eq(host.player_of_peer(5), 1, "the host knows whose connection it is")
check_eq(host.guest_count(), 1, "one guest")
check_eq(guest.player_name(0), "Ada", "the guest knows the host's player")
check_eq(guest.online_players(), PackedInt64Array([0, 1]), "two players online")
check_eq(guest.save().size(), 0, "a guest never saves the host's world")
check(guest.recording_bytes().size() > 0, "but it records its session")
func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void:
var pair := _host_and_guest()
var host := pair[0]
var guest := pair[1]
var rock := _first_soft_rock(host)
guest.dig(rock.x, rock.y)
_carry(host, guest)
host.step()
_carry(host, guest)
var events: Array[Dictionary] = []
while guest.pending_ticks() > 0:
events.append_array(guest.step())
check_eq(events.map(func(event: Dictionary) -> String: return event["type"]), ["tile_hit"], "the guest sees its hit")
check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host")
func test_a_guest_learns_why_it_is_turned_away() -> void:
var host := _joined_session()
host.connect_peer(5)
var twin := GwSession.new()
twin.start_guest(key, "Ada")
check_eq(_carry(host, twin), [{"type": "refused", "reason": GwSession.REFUSED_ALREADY_PLAYING}], "Ada is in the world already")
check_eq(host.take_closing(), PackedInt64Array([5]), "the host gives up the connection")
func test_an_unreadable_message_from_the_host_is_reported() -> void:
var guest := GwSession.new()
guest.start_guest(guest_key, "Grace")
check_eq(guest.receive(GwSession.HOST_PEER, PackedByteArray([0xEE])), [{"type": "protocol_error"}], "a notice")
check(guest.last_error().contains("unreadable"), "with the reason: %s" % guest.last_error())
check(not guest.start_guest(PackedByteArray([1, 2]), "Grace"), "a short key is refused")