Release alpha 10 with bilingual UI and social co-op

This commit is contained in:
Emporex committed 2026-10-10 12:50:27 +02:00
1 parent 7d5cad7277
commit d53467baf2
22 files changed
+1714 -271

No files matched your search

+24 -5
View File
@@ -32,7 +32,7 @@ func _run() -> void:
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
var info_data = JSON.parse_string(info.payload)
assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
assert(info_data.get("version") == "0.23", "Der Server muss die aktuelle Runtime 0.23 geladen haben: %s" % info_data)
assert(info_data.get("version") == "0.25", "Der Server muss die aktuelle Runtime 0.25 geladen haben: %s" % info_data)
var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
@@ -51,13 +51,13 @@ func _run() -> void:
var public_leaderboard_name := "Rangpilot %s" % suffix.right(6)
var renamed_a = await client_a.rpc_async(session_a, "update_display_name", JSON.stringify({"display_name": public_leaderboard_name}))
assert(not renamed_a.is_exception() and bool(JSON.parse_string(renamed_a.payload).get("accepted", false)), "Der Ranglistenpilot muss einen öffentlichen Namen setzen können.")
var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.23-integration"})
var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.25-integration"})
var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload)
assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.")
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10)
assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", [session_a.user_id], null, 10)
assert(not records.is_exception() and (records.records.size() >= 1 or records.owner_records.size() >= 1), "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
var own_record = null
for record in records.records:
for record in records.owner_records:
if record.owner_id == session_a.user_id:
own_record = record
break
@@ -67,6 +67,17 @@ func _run() -> void:
var socket_b: NakamaSocket = nakama.create_socket_from(client_b)
assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception(), "Spieler A benötigt eine Echtzeitverbindung.")
assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception(), "Spieler B benötigt eine Echtzeitverbindung.")
var friend_request = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username]))
assert(not friend_request.is_exception(), "Spieler A muss eine Freundschaftsanfrage senden können.")
var friend_accept = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id]))
assert(not friend_accept.is_exception(), "Spieler B muss die Freundschaftsanfrage bestätigen können.")
var friend_list = await client_a.list_friends_async(session_a, 0, 100)
assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id,
"Die bestätigte Freundschaft muss beim Gastgeber sichtbar sein.")
var received_invites: Array = []
socket_b.received_notification.connect(func(note):
if int(note.code) == 101:
received_invites.append(JSON.parse_string(note.content)))
socket_a.received_matchmaker_matched.connect(func(value): matched_a = value)
socket_b.received_matchmaker_matched.connect(func(value): matched_b = value)
socket_a.received_match_state.connect(func(value): _capture_snapshot(value, true))
@@ -124,6 +135,14 @@ func _run() -> void:
assert(room_code.length() == 6 and not private_match_id.is_empty(), "Der Server muss einen sechsstelligen Raumcode und eine Partie liefern.")
var private_match_a = await socket_a.join_match_async(private_match_id)
assert(not private_match_a.is_exception(), "Der Gastgeber muss seinem privaten Raum beitreten können.")
var invite = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": room_code}))
assert(not invite.is_exception() and bool(JSON.parse_string(invite.payload).get("accepted", false)), "Der Gastgeber muss einen bestätigten Freund einladen können.")
for attempt in 30:
if not received_invites.is_empty():
break
await create_timer(0.05).timeout
assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == room_code,
"Die Einladung muss als Echtzeitbenachrichtigung mit dem richtigen Raumcode ankommen.")
var found_room = await client_b.rpc_async(session_b, "join_private_room", JSON.stringify({"room_code": room_code.to_lower()}))
assert(not found_room.is_exception(), "Der Mitspieler muss den Raumcode unabhängig von Groß-/Kleinschreibung finden können.")
var found_data = JSON.parse_string(found_room.payload)
+83
View File
@@ -0,0 +1,83 @@
extends Node
func _ready() -> void:
call_deferred("_run")
func _run() -> void:
var game = load("res://main.tscn").instantiate()
get_tree().root.add_child(game)
await get_tree().process_frame
game.ui_language = "en"
assert(game._ui("FREUNDESLISTE") == "FRIENDS")
assert(game._ui("WELLE") == "WAVE")
game.ui_language = "de"
assert(game._ui("FREUNDESLISTE") == "FREUNDESLISTE")
game._on_match_invite_received({"room_code": "ABC123", "from": "Testpilot"})
assert(game.pending_match_invite.room_code == "ABC123")
game.pending_match_invite.clear()
game.friend_return_mode = "matchmaking"
game.multiplayer_manager.matchmaking_mode = "private_host"
game.multiplayer_manager.matchmaking_state = "hosting"
game.multiplayer_manager.private_room_code = "ABC123"
assert(game._can_invite_friends())
game.multiplayer_manager.matchmaking_state = "offline"
assert(not game._can_invite_friends(), "Ein alter Raumcode darf keine Einladung mehr ermöglichen.")
game.coop_online_mode = true
var base_snapshot := {
"difficulty": 1, "reactor_lives": 30, "wave": 1, "wave_active": true,
"enemies_to_spawn": 0, "wave_types_remaining": [], "towers": [],
"enemies": [{"id": 4, "type": 0, "x": 100.0, "y": 200.0, "hp": 40.0,
"max_hp": 40.0, "shield": false, "boss": false}], "shots": []
}
game._apply_online_snapshot(base_snapshot)
assert(game.enemies.size() == 1)
assert(game.enemies[0].pos == Vector2(100, 200))
var first_enemy: Dictionary = game.enemies[0]
var next_snapshot: Dictionary = base_snapshot.duplicate(true)
next_snapshot["enemies"][0]["x"] = 200.0
next_snapshot["enemies"][0]["hp"] = 25.0
game._apply_online_snapshot(next_snapshot)
assert(is_same(first_enemy, game.enemies[0]), "Gegnerdaten sollen zwischen Paketen wiederverwendet werden.")
game._update_online_interpolation(0.05)
assert(game.enemies[0].pos.x > 100.0 and game.enemies[0].pos.x < 200.0,
"Die Position soll zwischen zwei Serverständen gleichmäßig übergehen.")
game._update_online_interpolation(0.1)
assert(is_equal_approx(game.enemies[0].pos.x, 200.0))
game.online_projectiles.append({"pos": Vector2.ZERO, "target": Vector2(30, 0), "speed": 920.0, "color": Color.WHITE})
game._update_online_projectiles(0.1)
assert(game.online_projectiles.is_empty(), "Geschosse dürfen bei einem langen Frame nicht am Ziel vorbeipendeln.")
var online_manager: OnlineManager = game.get_node("OnlineManager")
var received: Array = []
online_manager.match_snapshot_received.connect(func(snapshot: Dictionary) -> void: received.append(snapshot))
online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 1, "shots": [{"id": 1}]}))
online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 2, "shots": [{"id": 2}]}))
online_manager._flush_pending_snapshots()
assert(received.size() == 1 and received[0].tick == 2, "Nur der neueste Spielstand darf angewendet werden.")
assert(received[0].shots.size() == 2, "Schusseffekte aus zusammengefassten Spielständen müssen erhalten bleiben.")
received.clear()
online_manager.current_match = {"match_id": "test-match"}
for tick in 8:
online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT,
"data": JSON.stringify({"tick": tick, "shots": [{"id": tick}]})})
assert(online_manager.pending_snapshot_payloads.size() == online_manager.MAX_PENDING_SNAPSHOTS,
"Die Warteschlange muss auch nach einem längeren Rückstau begrenzt bleiben.")
online_manager._flush_pending_snapshots()
assert(received.size() == 1 and received[0].tick == 7)
assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS)
game._select_map(0)
game._prepare_draw_geometry()
assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty())
assert(not game.build_cell_cache.is_empty())
game._select_map(1)
assert(game.draw_geometry_dirty, "Ein Kartenwechsel muss die Weggeometrie erneuern.")
game._prepare_draw_geometry()
assert(not game.draw_geometry_dirty)
game.coop_mode = true
game._configure_coop_arena()
game._prepare_draw_geometry()
var partner_cells: Array = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
assert(not partner_cells.is_empty(), "Die gespiegelte Koop-Seite braucht eigene Baufelder.")
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://cg5nghnbri5bo
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/online_snapshot_test.gd" id="1_test"]
[node name="OnlineSnapshotTest" type="Node"]
script = ExtResource("1_test")
+16 -13
View File
@@ -39,8 +39,8 @@ func _run() -> void:
assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.")
assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
game.set("unlocked_maps", [0, 1, 2])
assert(game.get("TOWER_DATA").size() == 6, "Das Arsenal muss sechs unterschiedliche Turmtypen enthalten.")
assert(game.get("tower_buttons").size() == 6, "Alle sechs Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.")
assert(game.get("TOWER_DATA").size() == 7, "Das Arsenal muss sieben unterschiedliche Turmtypen enthalten.")
assert(game.get("tower_buttons").size() == 7, "Alle sieben Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.")
game.call("_handle_pointer", Vector2(760, 815), false)
assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.")
await process_frame
@@ -75,7 +75,8 @@ func _run() -> void:
assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.")
game.set("unlocked_towers", [0, 1, 2])
game.set("game_mode", "playing")
game.call("_handle_pointer", game.get("tower_buttons")[3].get_center(), false)
var locked_tower_slot: int = game.call("_tower_display_order").find(3)
game.call("_handle_pointer", game.get("tower_buttons")[locked_tower_slot].get_center(), false)
assert(game.get("game_mode") == "store", "Ein gesperrter Turm muss den Shop auch aus einer laufenden Mission öffnen.")
game.call("_handle_pointer", game.get("shop_back_button").get_center(), false)
assert(game.get("game_mode") == "playing", "Der Shop-Zurück-Button muss in die laufende Mission zurückführen.")
@@ -141,10 +142,11 @@ func _run() -> void:
assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.")
assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.")
assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.")
assert(game.call("_app_version") == "0.1.0-alpha.8", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var current_version: String = str(ProjectSettings.get_setting("application/config/version", ""))
assert(game.call("_app_version") == current_version and not current_version.is_empty(), "Profil und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var support_url: String = game.call("_support_email_url")
assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.")
assert(support_url.contains("0.1.0-alpha.8") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
assert(support_url.contains(current_version) and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
account_manager.storage_enabled = false
var original_display_name: String = account_manager.display_name
assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")
@@ -163,9 +165,9 @@ func _run() -> void:
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.6"), "Die installierte Version darf nicht als Update angeboten werden.")
var announced_update := {}
update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true))
var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.9", "name": "Alpha.9", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
var fake_release_body := JSON.stringify([{"tag_name": "v9.0.0", "name": "Future test release", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body)
assert(announced_update.get("version") == "0.1.0-alpha.9", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(announced_update.get("version") == "9.0.0", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.")
assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.")
_assert_grid_aligned_path(game.get("path_points"), "Bastion Prime")
@@ -228,16 +230,17 @@ func _run() -> void:
game.set("selected_difficulty", 0)
game.call("_start_game")
assert(game.get("credits") == 450 and game.get("reactor_lives") == 20, "Leicht muss dem bisherigen Balancing entsprechen.")
game.call("_handle_pointer", Vector2(1180, 45), false)
game.call("_handle_pointer", game.get("speed_button").get_center(), false)
assert(game.get("speed_dropdown_open"), "Die Spielgeschwindigkeit muss über eine Auswahl bedienbar sein.")
game.call("_handle_pointer", Vector2(1190, 198), false)
game.call("_handle_pointer", game.call("_speed_option_rect", 4).get_center(), false)
assert(is_equal_approx(game.get("game_speed"), 5.0), "Die Geschwindigkeitsauswahl muss bis 5× reichen.")
game.set("game_speed", 1.0)
game.call("_handle_pointer", Vector2(1330, 82), false)
game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false)
assert(game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox aktivierbar sein.")
game.call("_handle_pointer", Vector2(1330, 82), false)
game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false)
assert(not game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox abschaltbar sein.")
game.call("_handle_pointer", Vector2(1110, 45), false)
game.call("_handle_pointer", game.get("pause_button").get_center(), false)
game.call("_handle_pointer", game.get("pause_exit_button").get_center(), false)
assert(game.get("game_mode") == "menu", "Der Zurück-Button muss eine laufende Runde abbrechen und zum Kartenmenü führen.")
assert(game.get("towers").is_empty() and game.get("enemies").is_empty(), "Beim Abbruch müssen alle Einheiten der laufenden Runde entfernt werden.")
game.call("_start_game")
@@ -279,7 +282,7 @@ func _run() -> void:
assert(game.get("TOWER_DATA")[0].target_class == "ground" and game.get("TOWER_DATA")[1].target_class == "both", "Der Laser muss neben Luft- auch Bodenziele angreifen können.")
assert(game.get("TOWER_DATA")[1].both_damage_scale < 1.0 and game.get("TOWER_DATA")[3].both_damage_scale < 1.0 and game.get("TOWER_DATA")[5].both_damage_scale < 1.0, "Laser, Plasma und Railgun müssen für ihre Mehrzweckfähigkeit einen Schadensmultiplikator erhalten.")
assert(game.get("TOWER_DATA")[4].damage == 20.0 and game.get("TOWER_DATA")[4].target_class == "both", "EMP muss Luft- und Bodenziele mit rund 20 Schaden erfassen.")
assert(game.call("_tower_display_order") == [0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_display_order") == [6, 0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_class_color", 0).g > game.call("_tower_class_color", 0).r and game.call("_tower_class_color", 1).b > game.call("_tower_class_color", 1).r, "Bodenfelder müssen grün und Mehrzweckfelder blau-cyan eingefärbt sein.")
game.get("enemies").clear()
var rail_front := {"id": 96001, "type": 0, "pos": Vector2(400, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
+68
View File
@@ -0,0 +1,68 @@
extends SceneTree
var received_invites: Array = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var nakama = root.get_node("Nakama")
var host := OS.get_environment("VOID_BASTION_TEST_HOST")
var scheme := OS.get_environment("VOID_BASTION_TEST_SCHEME")
var port_text := OS.get_environment("VOID_BASTION_TEST_PORT")
if host.is_empty():
host = "192.168.178.20"
if scheme.is_empty():
scheme = "http"
var port := int(port_text) if not port_text.is_empty() else 7350
var client_a: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
var client_b: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
var suffix := str(Time.get_ticks_usec())
var session_a: NakamaSession = await client_a.authenticate_device_async("social_test_a_" + suffix, "social_a_" + suffix, true)
var session_b: NakamaSession = await client_b.authenticate_device_async("social_test_b_" + suffix, "social_b_" + suffix, true)
assert(not session_a.is_exception() and not session_b.is_exception(), "Beide Testkonten müssen sich anmelden können.")
var socket_a: NakamaSocket = nakama.create_socket_from(client_a)
var socket_b: NakamaSocket = nakama.create_socket_from(client_b)
assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception())
assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception())
socket_b.received_notification.connect(func(note):
if int(note.code) == 101:
received_invites.append(JSON.parse_string(note.content)))
var requested = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username]))
assert(not requested.is_exception(), "Eine Freundschaftsanfrage muss gesendet werden können.")
var accepted = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id]))
assert(not accepted.is_exception(), "Die Anfrage muss angenommen werden können.")
var friend_list = await client_a.list_friends_async(session_a, 0, 100)
assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id,
"Die gegenseitige Freundschaft muss auf dem Server gespeichert sein.")
var followed = await socket_a.follow_users_async(PackedStringArray([session_b.user_id]), PackedStringArray())
assert(not followed.is_exception(), "Der Freundesstatus muss abonnierbar sein.")
assert(not (await socket_b.update_status_async("playing")).is_exception(), "Der Im-Spiel-Status muss gesetzt werden können.")
var created = await client_a.rpc_async(session_a, "create_private_room", JSON.stringify({"difficulty": 1}))
assert(not created.is_exception(), "Ein privater Raum muss erstellbar sein.")
var room: Dictionary = JSON.parse_string(created.payload)
var code := String(room.get("room_code", ""))
assert(code.length() == 6)
var joined = await socket_a.join_match_async(String(room.get("match_id", "")))
assert(not joined.is_exception(), "Der Gastgeber muss den privaten Raum betreten können.")
var invited = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": code}))
assert(not invited.is_exception() and bool(JSON.parse_string(invited.payload).get("accepted", false)),
"Der Gastgeber muss einen bestätigten Freund einladen können.")
for attempt in 40:
if not received_invites.is_empty():
break
await create_timer(0.05).timeout
assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == code,
"Die Echtzeit-Einladung muss den Raumcode enthalten.")
var unauthorized = await client_b.rpc_async(session_b, "invite_friend", JSON.stringify({"friend_id": session_a.user_id, "room_code": code}))
assert(not unauthorized.is_exception() and not bool(JSON.parse_string(unauthorized.payload).get("accepted", true)),
"Ein Mitspieler ohne Gastgeberrolle darf keine Einladung versenden.")
socket_a.close()
socket_b.close()
var deleted_a = await client_a.rpc_async(session_a, "delete_account", "{}")
var deleted_b = await client_b.rpc_async(session_b, "delete_account", "{}")
assert(not deleted_a.is_exception() and not deleted_b.is_exception(), "Die Testkonten müssen entfernt werden können.")
print("VOID_BASTION_SOCIAL_INTEGRATION_OK")
quit()