Prepare alpha 11 with online diagnostics

This commit is contained in:
Emporex committed 2026-10-10 20:17:39 +02:00
1 parent d53467baf2
commit 4d9e6d9185
14 files changed
+550 -77

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Online-Diagnose für Zwei-PC-Tests
Ab dem nächsten gemeinsamen Client-/Nakama-Update schreibt das Spiel während jeder Online-Partie Messwerte lokal mit. Beim Sieg (Welle 15), bei einer Niederlage oder beim Verlassen sendet jeder PC seinen Bericht automatisch an Nakama. Beide Berichte bekommen dieselbe 16-stellige Rundenkennung und die Seitenzahl `0` oder `1`. Die Spieler müssen dafür keinen Diagnose-Button bedienen. Bei vorübergehendem Übertragungsfehler versucht der Client es dreimal; die lokale `.jsonl`-Datei bleibt als Rückfallebene erhalten.
Die private Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine Übersicht der letzten Berichte. Mit `{"round_id":"<16-stellige Kennung>"}` liefert sie die zwei vollständigen Seitenberichte. Sie ist wie `private_stats` nur mit dem privaten Nakama-HTTP-Schlüssel erreichbar; Spielclients können sie nicht aufrufen. Für den Vergleich einer Partie erst die Rundenkennung in der Übersicht auswählen und anschließend beide Seiten laden. Die Kennung steht außerdem nach erfolgreichem Upload in der Spielausgabe als `ONLINE DIAG hochgeladen: Runde ...`.
Online gespeichert werden nur technische Messwerte. Nachrichten, IP-Adressen, Spielernamen, Raumcodes und der rohe Match-Identifier werden nicht als Bericht gespeichert. Der Server nimmt nur Berichte von Konten an, die tatsächlich Teilnehmer der Runde waren. Berichte und Teilnehmerzuordnung werden nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
Jede `window`-Zeile fasst etwa zehn Sekunden zusammen:
| Feld | Bedeutung |
| --- | --- |
| `snapshots_per_second`, `gap_p95_ms`, `gap_max_ms` | Wie regelmäßig Zustände beim PC eintreffen; erwartet sind ungefähr 10/s bzw. 100 ms Abstand. |
| `tick_jump_max`, `tick_jumps_over_1` | Ob Server-Schritte zwischen verarbeiteten Zuständen fehlen. Zusammen mit den Empfangsabständen und Warteschlangenwerten zeigt das, ob Zustände ausblieben oder erst verspätet gebündelt ankamen; allein beweist es noch keine Ursache. |
| `bytes_avg`, `bytes_max` | Größe der vollständigen Zustandsnachrichten. Wachsende Werte bei vielen Gegnern/Türmen weisen auf Datenmenge als möglichen Faktor hin. |
| `coalesced`, `queue_dropped`, `queue_max` | Mehrere Zustände kamen vor einem Bild zusammen oder die Warteschlange lief voll. Das deutet auf einen Client-Rückstau hin. |
| `parse_max_us`, `apply_max_us` | Höchste Zeit für JSON-Verarbeitung bzw. Anwenden des Zustands in Godot. |
| `frame_max_ms`, `frame_stalls_over_200_ms` | Lokale Bildaussetzer, auch wenn das Netz regelmäßig liefert. |
| `enemies`, `towers` | Last im Spiel zum Zeitpunkt der Messung. |
Zusätzliche `snapshot_gap`, `tick_jump`, `frame_stall`, `socket_closed` und `socket_reconnected`-Zeilen markieren einzelne Ereignisse. Die Uhrzeit ist UTC, damit beide PCs direkt verglichen werden können. Ein Empfangsabstand allein beweist keine Paketverluste: Er kann auch durch Serverlast, Netzwerk-Jitter oder einen blockierten Client entstehen. Für eine eindeutige Zuordnung die gleichzeitigen Berichte **beider PCs** vergleichen; serverseitige `ONLINE DIAG tick_gap_ms`- und `loop_ms`-Warnungen helfen zusätzlich.
Wichtig: Alte Spielversionen laden noch nichts hoch. Vor einem echten Zwei-PC-Test müssen sowohl der neue Windows-Build als auch der aktualisierte Nakama-Runtime-Code veröffentlicht sein.
+2 -2
View File
@@ -66,8 +66,8 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `
Der Onlinetest prüft zwei Gastanmeldungen, Änderung und Ablehnung von Spielernamen, die Löschung eines eigens erzeugten Wegwerf-Testkontos, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, die Bereit-Lobby, autoritative Spielzustände sowie Erstellen, Verlassen und Wiederbeitreten eines privaten Raums per Code.
Bei einem stockenden Onlinespiel schreibt der Client alle zehn Sekunden eine Zeile `ONLINE DIAG` ins Godot-Protokoll: Anzahl und maximale Lücke der Zustandspakete, Lücken über 500 ms, größte Paketgröße, maximale JSON-Lese- und Zustandsanwendungszeit. Zusätzliche Zeilen melden Bildaussetzer über 200 ms und WebSocket-Abbruch/Wiederverbindung. Eine große Paketlücke bei normaler Bildrate deutet auf Übertragung oder Server hin; eine lange Bildlücke ohne Paketlücke eher auf den Client. Für eine Fehlermeldung möglichst die Zeilen direkt vor und nach dem Hänger sichern; Spielerkennungen und Zugangsdaten werden darin nicht ausgegeben.
Bei einem stockenden Onlinespiel schreibt der Client jetzt einen fortlaufenden Diagnosebericht pro Partie. Im Online-Pausemenü kann der Bericht mit **DIAGNOSE KOPIEREN** vollständig in die Zwischenablage kopiert werden. Er enthält Zehn-Sekunden-Werte und einzelne Hänger-/Verbindungsereignisse, aber keine Spielernamen, Raumcodes oder Kontenkennungen. Für Ablauf und Auswertung auf zwei PCs siehe [ONLINE_DIAGNOSE.md](ONLINE_DIAGNOSE.md).
Der Client fasst bei Rückstau maximal fünf eingetroffene Spielstände zusammen: Nur der neueste Zustand wird angewendet, die Schusseffekte dieser wenigen Pakete bleiben erhalten. Ältere, verworfene Zustände sind nur noch historische Zwischenbilder. `coalesced` in `ONLINE DIAG` zählt die dabei übersprungenen Zustände. Weg- und Baufeldgeometrie wird nur beim Karten- bzw. Arenenwechsel neu berechnet. Der lokale Snapshot-Test prüft außerdem, dass Geschosse nach einem langen Bildaussetzer ihr Ziel erreichen und verschwinden.
Der Client fasst bei Rückstau maximal fünf eingetroffene Spielstände zusammen: Nur der neueste Zustand wird angewendet, die Schusseffekte dieser wenigen Pakete bleiben erhalten. Ältere, verworfene Zustände sind nur noch historische Zwischenbilder. `coalesced` und `queue_dropped` im Diagnosebericht trennen zusammengefasste von wegen voller Warteschlange verworfenen Zuständen. Weg- und Baufeldgeometrie wird nur beim Karten- bzw. Arenenwechsel neu berechnet. Der lokale Snapshot-Test prüft außerdem, dass Geschosse nach einem langen Bildaussetzer ihr Ziel erreichen und verschwinden.
Der Server meldet mit `ONLINE DIAG tick_gap_ms` verspätete Match-Ticks über 250 ms und mit `ONLINE DIAG loop_ms` eine Verarbeitung über 70 ms. Die Meldungen enthalten nur Ticknummer und Objektanzahl. Wenn beim Client Paketlücken auftreten, aber zur gleichen Zeit keine Tick- oder Loop-Warnung auf dem Server, liegt die Suche als Nächstes bei WebSocket-Verbindung und Reverse Proxy.
+1 -1
View File
@@ -93,7 +93,7 @@ Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path
Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.10.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.11.md`.
## Werbung und Datenschutz
+16
View File
@@ -0,0 +1,16 @@
# Void Bastion 0.1.0-alpha.11
## Neu in dieser Testversion
- Private Koop-Räume können jetzt auch nach dem Wechsel in die Lobby Freunde einladen.
- Freunde lassen sich über den sichtbaren Pilotennamen suchen, statt lange technische Kennungen manuell zu kopieren.
- Die Schilde des Mutterschiff-Bosses in Welle 5 wurden halbiert.
- Online-Partieberichte werden beim Ende oder Verlassen automatisch pseudonymisiert an Nakama übertragen und mit einer gemeinsamen Rundenkennung für beide PCs abgelegt.
- Die geschützte Statistikseite enthält nun eine Übersicht und Detailansicht der Online-Diagnoseberichte.
## Testhinweise
- Für automatische Diagnoseberichte müssen beide Spieler Alpha 11 und der aktualisierte Nakama-Server verwenden.
- Bitte mit zwei Konten Namenssuche, Freundeseinladung aus der Lobby und einen Online-Spielverlauf einschließlich Verlassen testen.
- Die Diagnoseberichte enthalten technische Messwerte, keine Namen, Chatnachrichten, IP-Adressen oder Raumcodes. Sie werden serverseitig nach 14 Tagen entfernt.
- Dies ist eine unsignierte Alpha-Testversion. Windows SmartScreen kann vor dem ersten Start warnen.
+2 -2
View File
@@ -24,8 +24,8 @@ application/modify_resources=true
application/icon="res://assets/app_icon.png"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0.10"
application/product_version="0.1.0.10"
application/file_version="0.1.0.11"
application/product_version="0.1.0.11"
application/company_name="GerGames"
application/product_name="Void Bastion"
application/file_description="Void Bastion – Alpha-Testversion"
+43 -44
View File
@@ -12,13 +12,13 @@ const ENGLISH_UI := {
"KOOPERATIVE VERTEIDIGUNG": "COOPERATIVE DEFENSE", "ZWEI KOMMANDANTEN • EIN GEMEINSAMER REAKTOR": "TWO COMMANDERS • ONE SHARED REACTOR",
"GEMEINSAMER REAKTOR": "SHARED REACTOR", "SCHNELLES SPIEL": "QUICK MATCH", "RAUM BEITRETEN": "JOIN ROOM",
"RAUM ERSTELLEN": "CREATE ROOM", "MIT KI-PARTNER": "WITH AI PARTNER", "FREUNDESLISTE": "FRIENDS",
"FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "KENNUNG KOPIEREN": "COPY PLAYER ID",
"FREUNDE EINLADEN": "INVITE FRIENDS", "AKTUALISIEREN": "REFRESH", "NAMEN KOPIEREN": "COPY NAME",
"ANFRAGE SENDEN": "SEND REQUEST", "EINLADEN": "INVITE", "ENTFERNEN": "REMOVE", "ZURÜCKZIEHEN": "WITHDRAW",
"ANNEHMEN": "ACCEPT", "ONLINE": "ONLINE", "OFFLINE": "OFFLINE", "IM SPIEL": "IN GAME",
"ANFRAGE GESENDET": "REQUEST SENT", "ANFRAGE ERHALTEN": "REQUEST RECEIVED", "BLOCKIERT": "BLOCKED",
"Noch keine Freunde oder offenen Anfragen.": "No friends or pending requests yet.",
"Eindeutige Spielerkennung eingeben": "Enter unique player ID",
"Füge Freunde über ihre eindeutige Spielerkennung hinzu. Beide müssen die Anfrage bestätigen.": "Add friends using their unique player ID. Both players must confirm the request.",
"Spielername eingeben": "Enter player name",
"Füge Freunde über ihren Spielernamen hinzu. Beide müssen die Anfrage bestätigen.": "Add friends by player name. Both players must confirm the request.",
"Kein Chat: Einladungen enthalten nur einen Raumcode, keine freie Nachricht.": "No chat: invites contain only a room code, never a custom message.",
"PRIVATEN RAUM BEITRETEN": "JOIN A PRIVATE ROOM", "GIB DEN SECHSSTELLIGEN CODE DEINES MITPILOTEN EIN": "ENTER YOUR PARTNER'S SIX-CHARACTER CODE",
"BUCHSTABEN UND ZAHLEN • GROSS-/KLEINSCHREIBUNG IST EGAL": "LETTERS AND NUMBERS • CASE DOES NOT MATTER",
@@ -42,12 +42,13 @@ const ENGLISH_UI := {
"RW %.0f • %s": "RANGE %.0f • %s", "LUFT": "AIR", "BODEN": "GROUND", "LUFT + BODEN": "AIR + GROUND",
"EINZELZIEL": "SINGLE TARGET", "FLÄCHE 76": "AREA 76", "PLASMA-FLÄCHE 92": "PLASMA AREA 92", "EMP-FLÄCHE 118 · ALLE": "EMP AREA 118 · ALL",
"PIERCING-STRAHL": "PIERCING BEAM", "LUFT-EINZELZIEL": "AIR SINGLE TARGET",
"DEINE SPIELERKENNUNG: %s": "YOUR PLAYER ID: %s", "EINLADUNG VON %s ANNEHMEN": "ACCEPT INVITE FROM %s",
"Anfrage an %s gesendet.": "Request sent to %s.", "Spielerkennung nicht gefunden oder Anfrage fehlgeschlagen.": "Player ID not found or request failed.",
"DEIN SPIELERNAME: %s": "YOUR PLAYER NAME: %s", "EINLADUNG VON %s ANNEHMEN": "ACCEPT INVITE FROM %s",
"Anfrage an %s gesendet.": "Request sent to %s.", "Spielername nicht gefunden oder Anfrage fehlgeschlagen.": "Player name not found or request failed.",
"Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.": "Multiple players have this name. Please use a unique name.",
"Anfrage bestätigt.": "Request accepted.", "Bestätigung fehlgeschlagen.": "Could not accept request.",
"Einladung gesendet.": "Invite sent.", "Einladung fehlgeschlagen oder gerade erst gesendet.": "Invite failed or was sent too recently.",
"Anfrage zurückgezogen.": "Request withdrawn.", "Freund entfernt.": "Friend removed.", "Aktion fehlgeschlagen.": "Action failed.",
"Spielerkennung kopiert.": "Player ID copied.", "Keine Serververbindung. Bitte später erneut versuchen.": "No server connection. Please try again later.",
"Spielername kopiert.": "Player name copied.", "Keine Serververbindung. Bitte später erneut versuchen.": "No server connection. Please try again later.",
"Freundesliste konnte nicht geladen werden.": "Could not load friends.",
"Privater Raum %s: Freunde mit EINLADEN direkt benachrichtigen.": "Private room %s: use INVITE to notify friends directly.",
"MITSPIELER": "PARTNER", "REAKTORLEBEN %02d • GETRENNTE RESSOURCEN": "REACTOR HEALTH %02d • SEPARATE RESOURCES",
@@ -362,10 +363,6 @@ var coop_side_button := Rect2(916, 14, 140, 54)
var online_projectiles: Array[Dictionary] = []
var online_snapshot_elapsed := 0.0
var online_snapshot_interval := 0.1
var online_frame_stall_count := 0
var online_frame_stall_report_ms := 0
var online_apply_max_us := 0
var online_apply_report_ms := 0
var room_code_input := ""
var profile_name_input := ""
var profile_message := ""
@@ -438,8 +435,8 @@ func _create_friend_name_input() -> void:
friend_name_line_edit = LineEdit.new()
friend_name_line_edit.position = friend_name_rect.position + Vector2(10, 3)
friend_name_line_edit.size = friend_name_rect.size - Vector2(20, 6)
friend_name_line_edit.max_length = 40
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
friend_name_line_edit.max_length = 20
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
friend_name_line_edit.text_submitted.connect(func(_value: String) -> void: _add_friend_from_input())
add_child(friend_name_line_edit)
friend_name_line_edit.hide()
@@ -477,13 +474,18 @@ func _refresh_friends() -> void:
func _add_friend_from_input() -> void:
var username := friend_name_line_edit.text.strip_edges()
if username.is_empty():
var player_name := friend_name_line_edit.text.strip_edges()
if player_name.is_empty():
return
var ok: bool = await online_manager.add_friend_by_username(username)
friends_message = _ui("Anfrage an %s gesendet.") % username if ok else _ui("Spielerkennung nicht gefunden oder Anfrage fehlgeschlagen.")
var result: Dictionary = await online_manager.add_friend_by_display_name(player_name)
var ok := bool(result.get("accepted", false))
if ok:
friends_message = _ui("Anfrage an %s gesendet.") % String(result.get("display_name", player_name))
friend_name_line_edit.clear()
elif String(result.get("error", "")) == "ambiguous":
friends_message = _ui("Mehrere Spieler haben diesen Namen. Bitte einen eindeutigen Namen verwenden.")
else:
friends_message = _ui("Spielername nicht gefunden oder Anfrage fehlgeschlagen.")
queue_redraw()
@@ -502,7 +504,7 @@ func _friend_row_action(friend: Dictionary) -> void:
func _can_invite_friends() -> bool:
return friend_return_mode == "matchmaking" and multiplayer_manager.matchmaking_mode == "private_host" and multiplayer_manager.matchmaking_state == "hosting" and not multiplayer_manager.private_room_code.is_empty()
return friend_return_mode == "matchmaking" and multiplayer_manager.matchmaking_mode == "private_host" and multiplayer_manager.matchmaking_state in ["hosting", "lobby"] and not multiplayer_manager.private_room_code.is_empty()
func _create_promo_code_input() -> void:
@@ -596,13 +598,8 @@ func _promo_reward_label(reward: String) -> String:
func _process(delta: float) -> void:
elapsed += delta
if coop_online_mode and delta > 0.2:
online_frame_stall_count += 1
var now_ms := Time.get_ticks_msec()
if now_ms - online_frame_stall_report_ms > 10000:
print("ONLINE DIAG frame_stall_ms=%d count=%d" % [roundi(delta * 1000.0), online_frame_stall_count])
online_frame_stall_report_ms = now_ms
online_frame_stall_count = 0
if coop_online_mode:
online_manager.record_online_frame(delta)
if status_timer > 0.0:
status_timer -= delta
if game_mode == "playing" or (game_mode == "paused" and coop_online_mode):
@@ -660,6 +657,8 @@ func _spawn_enemy() -> void:
var health_scale := _enemy_health_scale(wave, is_boss) * enemy_strength_multiplier
var has_shield := bool(data.get("default_shield", false)) or (wave >= 4 and randf() < minf(0.42, 0.10 + wave * 0.018 + selected_difficulty * 0.06))
var max_shield_hp: float = data.hp * health_scale * 0.55 if has_shield else 0.0
if is_boss and wave == 5:
max_shield_hp *= 0.5
var route_id := 0
var route := path_points
if coop_mode:
@@ -1259,8 +1258,8 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void:
elif friend_add_button.has_point(position):
_add_friend_from_input()
elif friend_copy_button.has_point(position) and online_manager.session != null:
DisplayServer.clipboard_set(String(online_manager.session.username))
friends_message = _ui("Spielerkennung kopiert.")
DisplayServer.clipboard_set(account_manager.display_name)
friends_message = _ui("Spielername kopiert.")
else:
for index in mini(friend_action_buttons.size(), online_manager.friends.size()):
if friend_action_buttons[index].has_point(position):
@@ -1483,8 +1482,9 @@ func _toggle_coop_side() -> void:
pending_touch_build_position = Vector2(-1, -1)
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if camera_offset.x < 1.0 else 0.0
cursor_position = cursor_screen_position + camera_offset
var side_name := "PARTNER-SEITE" if coop_online_mode else "KI-SEITE"
_show_status(side_name if camera_offset.x > 0.0 else "DEINE SEITE")
var viewing_right := camera_offset.x > 0.0
var viewing_own_side := (viewing_right and _local_credit_slot() == 1) or (not viewing_right and _local_credit_slot() == 0)
_show_status("DEINE SEITE" if viewing_own_side else ("PARTNER-SEITE" if coop_online_mode else "KI-SEITE"))
func _start_coop_ai_game() -> void:
@@ -1549,6 +1549,7 @@ func _start_coop_online_game(player_slot: int) -> void:
game_speed = 1.0
tutorial_step = 3
camera_offset.x = COOP_WORLD_WIDTH - SCREEN.x if local_player_slot == 1 else 0.0
online_manager.set_diagnostic_slot(local_player_slot, local_player_slot == 1)
ads_manager.begin_mission()
_apply_online_snapshot(multiplayer_manager.latest_snapshot)
_show_status("ONLINE-KOOP BEREIT • DU VERTEIDIGST %s" % ("RECHTS" if local_player_slot == 1 else "LINKS"))
@@ -1782,6 +1783,8 @@ func _toggle_pause() -> void:
func _game_over() -> void:
if game_mode != "playing":
return
if coop_online_mode:
online_manager._finish_match_diagnostics("game_over")
if endless_mode:
_endless_run_complete()
return
@@ -2325,14 +2328,7 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void:
selected_tower_index = -1
if bool(snapshot.get("game_over", false)) and game_mode == "playing":
_game_over()
online_apply_max_us = maxi(online_apply_max_us, Time.get_ticks_usec() - apply_start_us)
var now_ms := Time.get_ticks_msec()
if online_apply_report_ms == 0:
online_apply_report_ms = now_ms
elif now_ms - online_apply_report_ms >= 10000:
print("ONLINE DIAG max_apply_us=%d enemies=%d towers=%d" % [online_apply_max_us, enemies.size(), towers.size()])
online_apply_max_us = 0
online_apply_report_ms = now_ms
online_manager.record_snapshot_apply(Time.get_ticks_usec() - apply_start_us, enemies.size(), towers.size())
func _update_online_interpolation(delta: float) -> void:
@@ -2360,6 +2356,8 @@ func _handle_online_event(event: Dictionary) -> void:
match String(event.get("type", "")):
"wave_complete":
_show_status("WELLE %d ABGEWEHRT +%d" % [int(event.get("wave", wave)), int(event.get("bonus", 0))])
if int(event.get("wave", wave)) >= MAX_WAVES:
online_manager._finish_match_diagnostics("victory")
"partner_left":
_show_status("VERBINDUNG ZUM PARTNER VERLOREN")
"command_rejected":
@@ -2382,7 +2380,7 @@ func _load_audio_settings() -> void:
ui_language = "de"
_apply_master_volume()
if friend_name_line_edit:
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
func _ui(text: String) -> String:
@@ -2394,7 +2392,7 @@ func _set_ui_language(value: String) -> void:
return
ui_language = value
if friend_name_line_edit:
friend_name_line_edit.placeholder_text = _ui("Eindeutige Spielerkennung eingeben")
friend_name_line_edit.placeholder_text = _ui("Spielername eingeben")
_save_audio_settings()
queue_redraw()
@@ -2661,8 +2659,9 @@ func _prepare_draw_geometry() -> void:
var center := Vector2(x_index * GRID_SIZE + GRID_SIZE * 0.5, y_index * GRID_SIZE + GRID_SIZE * 0.5)
if not _is_in_build_zone(center) or _is_in_reactor_zone(center) or _is_path_cell(center):
continue
var cell_side := 1 if center.x > COOP_MIRROR_X else 0
build_cell_cache.append({"rect": Rect2(center - Vector2(34, 34), Vector2(68, 68)),
"partner": coop_mode and center.x > COOP_MIRROR_X})
"partner": coop_mode and cell_side != _local_credit_slot()})
draw_geometry_dirty = false
@@ -2761,7 +2760,7 @@ func _draw_build_grid() -> void:
func _draw_tower(tower: Dictionary) -> void:
var size: float = 74.0 + (tower.level - 1) * 4.0
if coop_mode and int(tower.get("owner", 0)) == 1:
if coop_mode and int(tower.get("owner", 0)) != _local_credit_slot():
draw_circle(tower.pos, size * 0.54, Color(1.0, 0.56, 0.20, 0.18))
draw_arc(tower.pos, size * 0.54, 0.0, TAU, 28, Color(1.0, 0.70, 0.34, 0.72), 2.0)
draw_set_transform(tower.pos - camera_offset, tower.angle, Vector2.ONE)
@@ -3253,13 +3252,13 @@ func _draw_friends() -> void:
_draw_button(friends_back_button, "< ZURÜCK", false, Color("26394a"))
_draw_button(friends_refresh_button, "AKTUALISIEREN", false, Color("24516a"))
draw_string(font, Vector2(0, 105), _ui("FREUNDESLISTE"), HORIZONTAL_ALIGNMENT_CENTER, 1600, 40, Color("c7f5ff"))
var own_username := String(online_manager.session.username) if online_manager.session != null else "OFFLINE"
draw_string(font, Vector2(260, 169), _ui("DEINE SPIELERKENNUNG: %s") % own_username, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff"))
_draw_button(friend_copy_button, "KENNUNG KOPIEREN", false, Color("285568"))
var own_name: String = account_manager.display_name if account_manager != null else "OFFLINE"
draw_string(font, Vector2(260, 169), _ui("DEIN SPIELERNAME: %s") % own_name, HORIZONTAL_ALIGNMENT_LEFT, 740, 18, Color("8ff4ff"))
_draw_button(friend_copy_button, "NAMEN KOPIEREN", false, Color("285568"))
draw_rect(friend_name_rect, Color(0.01, 0.035, 0.065, 0.98), true)
draw_rect(friend_name_rect, Color("4ab8d1"), false, 2.0)
_draw_button(friend_add_button, "ANFRAGE SENDEN", false, Color("185f68"))
draw_string(font, Vector2(260, 292), _ui("Füge Freunde über ihre eindeutige Spielerkennung hinzu. Beide müssen die Anfrage bestätigen."), HORIZONTAL_ALIGNMENT_LEFT, 1090, 15, Color("9fdce9"))
draw_string(font, Vector2(260, 292), _ui("Füge Freunde über ihren Spielernamen hinzu. Beide müssen die Anfrage bestätigen."), HORIZONTAL_ALIGNMENT_LEFT, 1090, 15, Color("9fdce9"))
draw_rect(Rect2(220, 315, 1160, 455), Color(0.01, 0.035, 0.065, 0.96), true)
draw_rect(Rect2(220, 315, 1160, 455), Color("39788e"), false, 2.0)
friend_action_buttons.clear()
+240 -25
View File
@@ -48,6 +48,27 @@ var snapshot_bytes_max := 0
var snapshot_parse_max_us := 0
var snapshot_large_gap_count := 0
var snapshot_coalesced_count := 0
var snapshot_queue_dropped_count := 0
var snapshot_queue_max := 0
var snapshot_bytes_total := 0
var snapshot_tick_previous := -1
var snapshot_tick_jump_max := 0
var snapshot_tick_jumps := 0
var snapshot_gap_samples: Array[int] = []
var diagnostic_file: FileAccess
var diagnostic_path := ""
var diagnostic_match_key := ""
var diagnostic_match_id := ""
var diagnostic_finished_match_key := ""
var diagnostic_open_failed := false
var diagnostic_slot := -1
var diagnostic_frame_max_ms := 0
var diagnostic_frame_stalls := 0
var diagnostic_apply_max_us := 0
var diagnostic_enemy_count := 0
var diagnostic_tower_count := 0
var diagnostic_last_summary := ""
var diagnostic_records: Array[Dictionary] = []
var pending_snapshot_payloads: Array[String] = []
var friends: Array[Dictionary] = []
var friend_statuses: Dictionary = {}
@@ -63,6 +84,8 @@ func _ready() -> void:
func _process(_delta: float) -> void:
_flush_pending_snapshots()
if diagnostic_file != null and Time.get_ticks_msec() - snapshot_report_ms >= 10000:
_report_diagnostic_window()
func _flush_pending_snapshots() -> void:
@@ -78,6 +101,16 @@ func _flush_pending_snapshots() -> void:
snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us)
if not parsed is Dictionary:
continue
var server_tick := int(parsed.get("tick", -1))
if server_tick >= 0:
if snapshot_tick_previous >= 0:
var tick_jump := server_tick - snapshot_tick_previous
snapshot_tick_jump_max = maxi(snapshot_tick_jump_max, tick_jump)
if tick_jump > 1:
snapshot_tick_jumps += 1
if tick_jump > 3:
_write_diagnostic_record("tick_jump", {"from_tick": snapshot_tick_previous, "to_tick": server_tick, "steps": tick_jump})
snapshot_tick_previous = server_tick
latest = parsed
var shots = parsed.get("shots", [])
if shots is Array:
@@ -90,6 +123,179 @@ func _flush_pending_snapshots() -> void:
match_snapshot_received.emit(latest)
func _begin_match_diagnostics() -> void:
if current_match == null:
return
var match_key := String(current_match.match_id).sha256_text().substr(0, 12)
if match_key == diagnostic_finished_match_key:
return
if diagnostic_match_key == match_key and (diagnostic_file != null or diagnostic_open_failed):
return
if diagnostic_file != null:
_finish_match_diagnostics("new_match")
diagnostic_match_key = match_key
diagnostic_match_id = String(current_match.match_id)
diagnostic_finished_match_key = ""
diagnostic_open_failed = false
diagnostic_slot = -1
diagnostic_records.clear()
snapshot_previous_ms = 0
snapshot_report_ms = Time.get_ticks_msec()
snapshot_tick_previous = -1
_reset_diagnostic_window()
var stamp := Time.get_datetime_string_from_system(true).replace(":", "-")
diagnostic_path = "user://online-diag-%s-%s-%d.jsonl" % [stamp, match_key, Time.get_ticks_usec()]
diagnostic_file = FileAccess.open(diagnostic_path, FileAccess.WRITE)
if diagnostic_file == null:
diagnostic_open_failed = true
push_warning("Online-Diagnose konnte nicht gespeichert werden: %s" % diagnostic_path)
return
_write_diagnostic_record("start", {
"match": match_key,
"host": current_host,
"version": str(ProjectSettings.get_setting("application/config/version", "")),
"transport": "nakama_websocket",
"expected_snapshots_per_second": 10
})
print("ONLINE DIAG file=%s" % ProjectSettings.globalize_path(diagnostic_path))
func set_diagnostic_slot(slot: int, viewing_right: bool) -> void:
if diagnostic_slot == slot:
return
diagnostic_slot = slot
_write_diagnostic_record("side", {"slot": slot, "viewing_right": viewing_right})
func record_online_frame(delta: float) -> void:
if diagnostic_file == null:
return
var frame_ms := roundi(delta * 1000.0)
diagnostic_frame_max_ms = maxi(diagnostic_frame_max_ms, frame_ms)
if frame_ms > 200:
diagnostic_frame_stalls += 1
if frame_ms > 1000:
_write_diagnostic_record("frame_stall", {"duration_ms": frame_ms, "last_server_tick": snapshot_tick_previous})
func record_snapshot_apply(duration_us: int, enemy_count: int, tower_count: int) -> void:
diagnostic_apply_max_us = maxi(diagnostic_apply_max_us, duration_us)
diagnostic_enemy_count = enemy_count
diagnostic_tower_count = tower_count
func _write_diagnostic_record(event_name: String, details: Dictionary) -> void:
var record := details.duplicate(true)
record["event"] = event_name
record["utc"] = Time.get_datetime_string_from_system(true)
record["slot"] = diagnostic_slot
if event_name != "start":
diagnostic_records.append(record)
if diagnostic_records.size() > 160:
diagnostic_records.pop_front()
if diagnostic_file == null:
return
diagnostic_file.store_line(JSON.stringify(record))
diagnostic_file.flush()
func _report_diagnostic_window() -> void:
var now_ms := Time.get_ticks_msec()
var sorted_gaps := snapshot_gap_samples.duplicate()
sorted_gaps.sort()
var p95_gap := 0
if not sorted_gaps.is_empty():
p95_gap = sorted_gaps[clampi(int(ceil(float(sorted_gaps.size()) * 0.95)) - 1, 0, sorted_gaps.size() - 1)]
var seconds := maxf(0.001, float(now_ms - snapshot_report_ms) / 1000.0)
var record := {
"seconds": snappedf(seconds, 0.001),
"snapshots": snapshot_count,
"snapshots_per_second": snappedf(float(snapshot_count) / seconds, 0.01),
"gap_p95_ms": p95_gap,
"gap_max_ms": snapshot_gap_max_ms,
"gaps_over_500_ms": snapshot_large_gap_count,
"last_server_tick": snapshot_tick_previous,
"tick_jump_max": snapshot_tick_jump_max,
"tick_jumps_over_1": snapshot_tick_jumps,
"bytes_avg": snapshot_bytes_total / maxi(1, snapshot_count),
"bytes_max": snapshot_bytes_max,
"parse_max_us": snapshot_parse_max_us,
"apply_max_us": diagnostic_apply_max_us,
"frame_max_ms": diagnostic_frame_max_ms,
"frame_stalls_over_200_ms": diagnostic_frame_stalls,
"coalesced": snapshot_coalesced_count,
"queue_dropped": snapshot_queue_dropped_count,
"queue_max": snapshot_queue_max,
"enemies": diagnostic_enemy_count,
"towers": diagnostic_tower_count
}
diagnostic_last_summary = JSON.stringify(record)
_write_diagnostic_record("window", record)
print("ONLINE DIAG %s" % diagnostic_last_summary)
snapshot_report_ms = now_ms
_reset_diagnostic_window()
func _reset_diagnostic_window() -> void:
snapshot_count = 0
snapshot_gap_max_ms = 0
snapshot_bytes_max = 0
snapshot_parse_max_us = 0
snapshot_large_gap_count = 0
snapshot_coalesced_count = 0
snapshot_queue_dropped_count = 0
snapshot_queue_max = 0
snapshot_bytes_total = 0
snapshot_tick_jump_max = 0
snapshot_tick_jumps = 0
snapshot_gap_samples.clear()
diagnostic_frame_max_ms = 0
diagnostic_frame_stalls = 0
diagnostic_apply_max_us = 0
func _finish_match_diagnostics(reason: String) -> void:
if diagnostic_match_key.is_empty():
return
if snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000:
_report_diagnostic_window()
_write_diagnostic_record("end", {"reason": reason})
var match_id := diagnostic_match_id
if not match_id.is_empty() and is_authenticated() and not diagnostic_records.is_empty():
var report := {"match_id": match_id, "version": str(ProjectSettings.get_setting("application/config/version", "development")),
"records": diagnostic_records.duplicate(true)}
while JSON.stringify(report).to_utf8_buffer().size() > 60000 and report["records"].size() > 1:
report["records"].pop_front()
_upload_match_diagnostics.call_deferred(report)
diagnostic_file = null
diagnostic_finished_match_key = diagnostic_match_key
diagnostic_match_key = ""
diagnostic_match_id = ""
diagnostic_open_failed = false
diagnostic_records.clear()
func _upload_match_diagnostics(report: Dictionary) -> void:
for attempt in 3:
if not is_authenticated():
break
var result = await client.rpc_async(session, "submit_online_diagnostics", JSON.stringify(report))
var parsed := _parse_rpc_result(result)
if bool(parsed.get("accepted", false)):
print("ONLINE DIAG hochgeladen: Runde %s, Seite %d" % [String(parsed.get("round_id", "")), int(parsed.get("slot", -1))])
return
await get_tree().create_timer(2.0 * (attempt + 1)).timeout
push_warning("Online-Diagnose konnte nicht hochgeladen werden; lokale Datei bleibt erhalten.")
func get_diagnostic_bundle() -> String:
if diagnostic_file != null and (snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000):
_report_diagnostic_window()
if diagnostic_path.is_empty() or not FileAccess.file_exists(diagnostic_path):
return "Noch keine Online-Diagnose vorhanden."
return "Void Bastion Online-Diagnose\nDatei: %s\n\n%s" % [ProjectSettings.globalize_path(diagnostic_path), FileAccess.get_file_as_string(diagnostic_path)]
func _is_headless_or_editor() -> bool:
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
@@ -298,15 +504,22 @@ func refresh_friends() -> bool:
return true
func add_friend_by_username(username: String) -> bool:
var normalized := username.strip_edges()
if not is_authenticated() or normalized.is_empty() or normalized == session.username:
return false
var result = await client.add_friends_async(session, null, PackedStringArray([normalized]))
func add_friend_by_display_name(display_name: String) -> Dictionary:
var normalized := display_name.strip_edges()
if not is_authenticated() or normalized.is_empty():
return {"accepted": false, "error": "offline"}
var lookup_result = await client.rpc_async(session, "find_player_by_name", JSON.stringify({"display_name": normalized}))
var found := _parse_rpc_result(lookup_result)
if not bool(found.get("found", false)):
return {"accepted": false, "error": String(found.get("error", "not_found"))}
var user_id := String(found.get("user_id", ""))
if user_id.is_empty() or user_id == session.user_id:
return {"accepted": false, "error": "self"}
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
if result == null or result.is_exception():
return false
return {"accepted": false, "error": "send_failed"}
await refresh_friends()
return true
return {"accepted": true, "display_name": String(found.get("display_name", normalized))}
func accept_friend(user_id: String) -> bool:
@@ -416,12 +629,14 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
func cancel_matchmaking() -> void:
intentional_disconnect = true
pending_snapshot_payloads.clear()
_finish_match_diagnostics("cancelled_matchmaking")
var match_to_leave = current_match
current_match = null
if socket and not matchmaker_ticket.is_empty():
await socket.remove_matchmaker_async(matchmaker_ticket)
matchmaker_ticket = ""
if current_match != null and not private_room_code.is_empty():
await socket.leave_match_async(current_match.match_id)
current_match = null
if socket and match_to_leave != null:
await socket.leave_match_async(match_to_leave.match_id)
private_room_code = ""
matchmaking_state_changed.emit("idle")
@@ -526,9 +741,11 @@ func request_wave_start() -> bool:
func leave_current_match() -> void:
pending_snapshot_payloads.clear()
if socket and current_match != null:
await socket.leave_match_async(current_match.match_id)
_finish_match_diagnostics("left_match")
var match_to_leave = current_match
current_match = null
if socket and match_to_leave != null:
await socket.leave_match_async(match_to_leave.match_id)
private_room_code = ""
lobby_ready = false
@@ -537,30 +754,25 @@ func _on_match_state(data) -> void:
if current_match == null or data.match_id != current_match.match_id:
return
if data.op_code == OP_SNAPSHOT:
_begin_match_diagnostics()
var now_ms := Time.get_ticks_msec()
if snapshot_previous_ms > 0:
var gap_ms := now_ms - snapshot_previous_ms
snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms)
snapshot_gap_samples.append(gap_ms)
if gap_ms > 500:
snapshot_large_gap_count += 1
_write_diagnostic_record("snapshot_gap", {"duration_ms": gap_ms, "last_server_tick": snapshot_tick_previous})
snapshot_previous_ms = now_ms
snapshot_count += 1
snapshot_bytes_max = maxi(snapshot_bytes_max, String(data.data).to_utf8_buffer().size())
var payload_bytes := String(data.data).to_utf8_buffer().size()
snapshot_bytes_max = maxi(snapshot_bytes_max, payload_bytes)
snapshot_bytes_total += payload_bytes
pending_snapshot_payloads.append(String(data.data))
snapshot_queue_max = maxi(snapshot_queue_max, pending_snapshot_payloads.size())
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
pending_snapshot_payloads.pop_front()
snapshot_coalesced_count += 1
if snapshot_report_ms == 0:
snapshot_report_ms = now_ms
elif now_ms - snapshot_report_ms >= 10000:
print("ONLINE DIAG snapshots=%d coalesced=%d max_gap_ms=%d gaps_over_500ms=%d max_bytes=%d max_parse_us=%d" % [snapshot_count, snapshot_coalesced_count, snapshot_gap_max_ms, snapshot_large_gap_count, snapshot_bytes_max, snapshot_parse_max_us])
snapshot_report_ms = now_ms
snapshot_count = 0
snapshot_coalesced_count = 0
snapshot_gap_max_ms = 0
snapshot_large_gap_count = 0
snapshot_bytes_max = 0
snapshot_parse_max_us = 0
snapshot_queue_dropped_count += 1
elif data.op_code == OP_EVENT:
var parsed = JSON.parse_string(data.data)
if parsed is Dictionary:
@@ -569,6 +781,7 @@ func _on_match_state(data) -> void:
func _on_socket_closed() -> void:
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
_write_diagnostic_record("socket_closed", {"reconnect": not intentional_disconnect and current_match != null})
pending_snapshot_payloads.clear()
presence_status = ""
friend_statuses.clear()
@@ -621,6 +834,7 @@ func _recover_match(match_id: String) -> void:
var rejoined = await socket.join_match_async(match_id)
if rejoined != null and not rejoined.is_exception():
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
_write_diagnostic_record("socket_reconnected", {"attempt": attempt + 1})
current_match = rejoined
intentional_disconnect = false
reconnect_in_progress = false
@@ -631,6 +845,7 @@ func _recover_match(match_id: String) -> void:
return
reconnect_in_progress = false
print("ONLINE DIAG websocket_reconnect_failed")
_write_diagnostic_record("socket_reconnect_failed", {})
matchmaking_state_changed.emit("connection_lost")
+2 -2
View File
@@ -6,7 +6,7 @@ RequestExecutionLevel user
!include "LogicLib.nsh"
!define PRODUCT_NAME "Void Bastion"
!define PRODUCT_VERSION "0.1.0-alpha.10"
!define PRODUCT_VERSION "0.1.0-alpha.11"
!define PRODUCT_PUBLISHER "GerGames"
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
!define PRODUCT_EXE "Void-Bastion.exe"
@@ -21,7 +21,7 @@ BrandingText "GerGames"
ShowInstDetails show
ShowUninstDetails show
VIProductVersion "0.1.0.10"
VIProductVersion "0.1.0.11"
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
+1 -1
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Void Bastion"
config/version="0.1.0-alpha.10"
config/version="0.1.0-alpha.11"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7")
config/icon="res://assets/app_icon.png"
+8
View File
@@ -32,6 +32,14 @@ Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. V
Freundschaftsanfragen, bestätigte Freundschaften und Online-Präsenz verwenden die eingebauten Nakama-Funktionen. Die app-eigene RPC `invite_friend` nimmt ausschließlich die Freundes-ID und einen sechsstelligen privaten Raumcode entgegen. Sie prüft, dass der Absender den Raum erstellt hat und eine gegenseitige Freundschaft besteht. Je Empfänger gilt ein Mindestabstand von 15 Sekunden. Die Benachrichtigung ist nicht persistent und enthält nur Absendername und Raumcode; ein Chat oder frei eingebbarer Einladungstext existiert nicht. Die Funktion muss zusammen mit dem aktualisierten Godot-Client ausgerollt werden.
`find_player_by_name` löst einen sichtbaren Spielernamen per exakter, nicht groß-/kleinschreibungsabhängiger Suche in eine interne Benutzer-ID auf. Wenn mehrere Konten denselben Anzeigenamen haben, wird die Anfrage als `ambiguous` abgewiesen, statt versehentlich das falsche Konto hinzuzufügen. Der Client zeigt und kopiert im Freundemenü nur den sichtbaren Namen. Im privaten Warteraum ist für bestätigte Freunde `EINLADEN` verfügbar.
## Online-Diagnose
`submit_online_diagnostics` nimmt am Ende einer Online-Partie automatisch technische Berichte der beiden Clients entgegen. Der Server prüft anhand der beim Matchbeitritt gespeicherten Teilnehmerzuordnung, ob das sendende Konto zur Runde gehört. Beide Berichte werden mit derselben 16-stelligen Rundenkennung und unterschiedlichen Seitenzahlen gespeichert. Es werden nur erlaubte numerische Messwerte und vordefinierte Ereignisse übernommen; keine Namen, Nachrichten, IP-Adressen oder Raumcodes. Die Speicherung ist serverprivat und wird nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakte Übersicht. Mit `{"round_id":"<Kennung>"}` liefert sie beide vollständigen Seitenberichte. Der private HTTP-Schlüssel gehört ausschließlich in die Server-/Webhosting-Konfiguration, niemals in den Spielclient. Weitere Auswertungshinweise stehen in `ONLINE_DIAGNOSE.md`.
## Private Statistik-RPCs
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
+118
View File
@@ -12,6 +12,9 @@ var PROMO_CODE_COLLECTION = "unlock_codes";
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
var PROMO_UNLOCK_KEY = "website_codes";
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
var DIAG_PARTICIPANT_COLLECTION = "online_diag_participants";
var DIAG_REPORT_COLLECTION = "online_diag_reports";
var DIAG_RETENTION_MS = 14 * 86400000;
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
@@ -320,6 +323,18 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
return JSON.stringify({accepted:true,display_name:name});
}
function rpcFindPlayerByName(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({found:false,error:"invalid_json"}); }
var name = normalizeDisplayName(request.display_name);
if (validateDisplayName(name)) return JSON.stringify({found:false,error:"invalid_name"});
var rows = nk.sqlQuery("SELECT id, display_name FROM users WHERE lower(display_name) = lower($1) LIMIT 3", [name]);
var candidates = rows.filter(function(row) { return String(row.id) !== ctx.userId; });
if (!candidates.length) return JSON.stringify({found:false,error:"not_found"});
if (candidates.length !== 1 || rows.length > 2) return JSON.stringify({found:false,error:"ambiguous"});
return JSON.stringify({found:true,user_id:String(candidates[0].id),display_name:String(candidates[0].display_name)});
}
function rpcDeleteAccount(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
@@ -449,6 +464,16 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
}
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
state.emptyTicks=0;
if (ctx.matchId) {
var roundId = nk.sha256Hash(ctx.matchId).slice(0, 16);
var participantSlots = {}, participantIds = Object.keys(state.slots);
for (var participantIndex = 0; participantIndex < participantIds.length; participantIndex++) {
var participantId = participantIds[participantIndex];
participantSlots[nk.sha256Hash(ctx.matchId + ":" + participantId)] = state.slots[participantId];
}
nk.storageWrite([{collection:DIAG_PARTICIPANT_COLLECTION,key:roundId,userId:SYSTEM_USER_ID,
value:{participants:participantSlots,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
}
dispatcher.matchLabelUpdate(matchLabel(state));
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
broadcastSnapshot(dispatcher,state,tick);
@@ -535,6 +560,7 @@ function spawnEnemy(state) {
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
if (boss && state.wave === 5) maxShieldHp *= 0.5;
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:type===4?0.18:0.28});
}
function applyDamageToEnemy(enemy,damage,towerType) {
@@ -642,6 +668,95 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
function sanitizeDiagnosticRecord(input) {
if (!input || typeof input !== "object") return null;
var allowedEvents = ["window","snapshot_gap","tick_jump","frame_stall","socket_closed","socket_reconnected","socket_reconnect_failed","side","end"];
var event = String(input.event || "");
if (allowedEvents.indexOf(event) < 0) return null;
var result = {event:event};
var utc = String(input.utc || "");
if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/.test(utc)) result.utc = utc.slice(0, 25);
var fields = ["seconds","snapshots","snapshots_per_second","gap_p95_ms","gap_max_ms","gaps_over_500_ms","last_server_tick","tick_jump_max","tick_jumps_over_1","bytes_avg","bytes_max","parse_max_us","apply_max_us","frame_max_ms","frame_stalls_over_200_ms","coalesced","queue_dropped","queue_max","enemies","towers","duration_ms","from_tick","to_tick","steps","attempt"];
for (var i = 0; i < fields.length; i++) {
var value = Number(input[fields[i]]);
if (input[fields[i]] !== undefined && isFinite(value) && value >= 0 && value <= 1000000000) result[fields[i]] = value;
}
if (event === "end") result.reason = ["left_match","cancelled_matchmaking","game_over","victory","new_match"].indexOf(String(input.reason)) >= 0 ? String(input.reason) : "other";
if (event === "side") result.viewing_right = input.viewing_right === true;
return result;
}
function rpcSubmitOnlineDiagnostics(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
if (!payload || payload.length > 65000) return JSON.stringify({accepted:false,error:"too_large"});
var request;
try { request = JSON.parse(payload); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
var matchId = String(request.match_id || "");
if (!matchId || matchId.length > 128 || !Array.isArray(request.records) || request.records.length > 160)
return JSON.stringify({accepted:false,error:"invalid_payload"});
var roundId = nk.sha256Hash(matchId).slice(0, 16);
var participant = readStorageRecord(nk, DIAG_PARTICIPANT_COLLECTION, roundId, SYSTEM_USER_ID);
var slots = participant && participant.value ? participant.value.participants || {} : {};
var participantKey = nk.sha256Hash(matchId + ":" + ctx.userId);
if (slots[participantKey] === undefined || Date.now() - Number(participant.value.created_at || 0) > DIAG_RETENTION_MS)
return JSON.stringify({accepted:false,error:"not_participant"});
var slot = Number(slots[participantKey]);
if (slot !== 0 && slot !== 1) return JSON.stringify({accepted:false,error:"invalid_slot"});
var records = request.records.map(sanitizeDiagnosticRecord).filter(function(record) { return record !== null; });
if (!records.length) return JSON.stringify({accepted:false,error:"no_records"});
var key = roundId + "-" + slot;
nk.storageWrite([{collection:DIAG_REPORT_COLLECTION,key:key,userId:SYSTEM_USER_ID,
value:{round_id:roundId,slot:slot,created_at:Date.now(),version:normalizeMetricLabel(request.version,"unknown"),records:records},
permissionRead:0,permissionWrite:0}]);
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
if (logger.info) logger.info("ONLINE DIAG received round=%s slot=%d records=%d", roundId, slot, records.length);
return JSON.stringify({accepted:true,round_id:roundId,slot:slot});
}
function pruneOnlineDiagnostics(nk) {
var collections = [DIAG_REPORT_COLLECTION, DIAG_PARTICIPANT_COLLECTION];
var cutoff = Date.now() - DIAG_RETENTION_MS;
for (var c = 0; c < collections.length; c++) {
var cursor = "", pages = 0, stale = [];
do {
var page = nk.storageList(SYSTEM_USER_ID, collections[c], 100, cursor);
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
for (var i = 0; i < objects.length; i++) {
if (Number(objects[i].value && objects[i].value.created_at || 0) < cutoff && objects[i].key)
stale.push({collection:collections[c],key:objects[i].key,userId:SYSTEM_USER_ID});
}
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
for (var deleteIndex = 0; deleteIndex < stale.length; deleteIndex += 100)
nk.storageDelete(stale.slice(deleteIndex, deleteIndex + 100));
}
}
function rpcListOnlineDiagnostics(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
var requestedRound = String(request.round_id || "");
if (requestedRound) {
if (!/^[a-f0-9]{16}$/.test(requestedRound)) return JSON.stringify({reports:[],error:"invalid_round_id"});
var pair = nk.storageRead([0,1].map(function(slot) { return {collection:DIAG_REPORT_COLLECTION,key:requestedRound+"-"+slot,userId:SYSTEM_USER_ID}; }));
return JSON.stringify({reports:pair.map(function(record) { return record.value; })});
}
var reports = [], cursor = "", pages = 0, cutoff = Date.now() - DIAG_RETENTION_MS;
do {
var page = nk.storageList(SYSTEM_USER_ID, DIAG_REPORT_COLLECTION, 100, cursor);
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
for (var i = 0; i < objects.length; i++) {
var object = objects[i], value = object && object.value ? object.value : {};
if (Number(value.created_at || 0) >= cutoff) reports.push({round_id:value.round_id,slot:value.slot,
created_at:value.created_at,version:value.version,record_count:Array.isArray(value.records)?value.records.length:0});
}
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
reports.sort(function(a,b) { return b.created_at - a.created_at; });
return JSON.stringify({reports:reports.slice(0,100),truncated:!!cursor||reports.length>100});
}
function InitModule(ctx,logger,nk,initializer) {
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
initializer.registerRpc("server_info",rpcServerInfo);
@@ -654,6 +769,9 @@ function InitModule(ctx,logger,nk,initializer) {
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
initializer.registerRpc("find_player_by_name",rpcFindPlayerByName);
initializer.registerRpc("submit_online_diagnostics",rpcSubmitOnlineDiagnostics);
initializer.registerRpc("list_online_diagnostics",rpcListOnlineDiagnostics);
initializer.registerRpc("delete_account",rpcDeleteAccount);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
+68
View File
@@ -49,6 +49,20 @@ assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, J
assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend({ userId: friendId }, {}, inviteNk, JSON.stringify({ friend_id: hostId, room_code: "ABC123" }))).accepted, false, "Only the private room host may invite.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, { ...inviteNk, friendsList: () => ({ friends: [] }) }, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, false, "Only mutual friends may be invited.");
const nameLookupNk = {
sqlQuery(_query, params) {
assert.strictEqual(params[0], "Emporex2", "Player search must use parameter binding.");
return [{ id: friendId, display_name: "Emporex2" }];
}
};
assert.deepStrictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {}, nameLookupNk, JSON.stringify({ display_name: "Emporex2" }))),
{ found: true, user_id: friendId, display_name: "Emporex2" });
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
{ sqlQuery: () => [{ id: friendId, display_name: "Same" }, { id: "third", display_name: "Same" }] },
JSON.stringify({ display_name: "Same" }))).error, "ambiguous", "Duplicate display names must not invite the wrong account.");
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
{ sqlQuery: () => [{ id: hostId, display_name: "Self" }] },
JSON.stringify({ display_name: "Self" }))).error, "not_found");
const flakState = {
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
enemies: [
@@ -344,4 +358,58 @@ state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).st
assert.strictEqual(state.pausedForReconnect, false);
assert.strictEqual(state.slots["pilot-b"], 1);
const bossState = context.initialState({ difficulty: 1 });
bossState.wave = 5;
bossState.waveSchedule = [{ type: 5, boss: true }];
const originalRandom = context.Math.random;
context.Math.random = () => 0;
context.spawnEnemy(bossState);
context.Math.random = originalRandom;
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
assert.strictEqual(bossState.enemies[0].max_shield_hp, bossState.enemies[0].max_hp * 0.275,
"Wave-five mothership shields must be half their former strength.");
const laterBossState = context.initialState({ difficulty: 1 });
laterBossState.wave = 10;
laterBossState.waveSchedule = [{ type: 8, boss: true }];
context.Math.random = () => 0;
context.spawnEnemy(laterBossState);
context.Math.random = originalRandom;
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
"Later bosses retain their original shield balance.");
const diagnosticStore = new Map();
const diagnosticNk = {
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
storageRead(ops) { return ops.map(op => diagnosticStore.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
storageWrite(ops) { for (const op of ops) diagnosticStore.set(`${op.collection}:${op.userId}:${op.key}`, { key: op.key, value: op.value }); },
storageList(userId, collection) {
return { objects: [...diagnosticStore.entries()].filter(([key]) => key.startsWith(`${collection}:${userId}:`)).map(([, object]) => object), cursor: "" };
},
storageDelete(ops) { for (const op of ops) diagnosticStore.delete(`${op.collection}:${op.userId}:${op.key}`); }
};
const diagnosticMatchId = "sample-match.123";
let diagState = context.initialState({ difficulty: 1 });
diagState = context.matchJoin({ matchId: diagnosticMatchId }, logger, diagnosticNk, dispatcher, 1, diagState,
[{ userId: hostId, username: "Host" }, { userId: friendId, username: "Guest" }]).state;
const report = { match_id: diagnosticMatchId, version: "0.26", records: [
{ event: "window", utc: "2026-10-10T13:00:00", snapshots: 90, gap_p95_ms: 103, player_name: "must_not_store" },
{ event: "end", utc: "2026-10-10T13:00:01", reason: "left_match" }
] };
const firstReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, JSON.stringify(report)));
const secondReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: friendId }, logger, diagnosticNk, JSON.stringify(report)));
assert.strictEqual(firstReport.round_id, secondReport.round_id, "Both players need one shared round ID.");
assert.strictEqual(firstReport.slot, 0);
assert.strictEqual(secondReport.slot, 1);
assert.strictEqual(JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: "stranger" }, logger, diagnosticNk, JSON.stringify(report))).error,
"not_participant", "A third account cannot attach data to the round.");
const listedReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk, "{}")).reports;
assert.strictEqual(listedReports.length, 2);
assert.ok(!JSON.stringify(listedReports).includes("must_not_store"), "The server must discard arbitrary client strings.");
const pairReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk,
JSON.stringify({ round_id: firstReport.round_id }))).reports;
assert.strictEqual(pairReports.length, 2);
assert.strictEqual(pairReports[0].round_id, pairReports[1].round_id);
assert.ok(!JSON.stringify(pairReports).includes("must_not_store"), "Detailed reports must also omit unapproved strings.");
assert.throws(() => context.rpcListOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, "{}"), /server-to-server/);
console.log("VOID_BASTION_SERVER_SIM_OK");
+25
View File
@@ -22,6 +22,8 @@ func _run() -> void:
game.multiplayer_manager.matchmaking_state = "hosting"
game.multiplayer_manager.private_room_code = "ABC123"
assert(game._can_invite_friends())
game.multiplayer_manager.matchmaking_state = "lobby"
assert(game._can_invite_friends(), "Im privaten Warteraum muss EINLADEN sichtbar bleiben.")
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
@@ -66,6 +68,15 @@ func _run() -> void:
online_manager._flush_pending_snapshots()
assert(received.size() == 1 and received[0].tick == 7)
assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS)
var diagnostic_bundle: String = online_manager.get_diagnostic_bundle()
assert(diagnostic_bundle.contains("\"event\":\"window\""), "Die Online-Diagnose muss einen kopierbaren Messbericht erzeugen.")
assert(diagnostic_bundle.contains("\"queue_dropped\":3"), "Verworfene Zustände im Rückstau müssen erfasst werden.")
online_manager._finish_match_diagnostics("test_finished")
assert(online_manager.diagnostic_match_key.is_empty())
assert(online_manager.diagnostic_finished_match_key == "test-match".sha256_text().substr(0, 12))
online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT,
"data": JSON.stringify({"tick": 8, "shots": []})})
assert(online_manager.diagnostic_file == null, "Nach dem Rundenende darf kein zweiter Diagnosebericht geöffnet werden.")
game._select_map(0)
game._prepare_draw_geometry()
assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty())
@@ -79,5 +90,19 @@ func _run() -> void:
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.")
assert(partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x > game.COOP_MIRROR_X),
"Für Spieler 1 muss nur die rechte Seite als Partnerseite markiert sein.")
game.coop_online_mode = true
game.local_player_slot = 1
game.draw_geometry_dirty = true
game._prepare_draw_geometry()
partner_cells = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
assert(not partner_cells.is_empty() and partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x < game.COOP_MIRROR_X),
"Für Spieler 2 muss nur die linke Seite als Partnerseite markiert sein.")
game.camera_offset.x = game.COOP_WORLD_WIDTH - game.SCREEN.x
game._toggle_coop_side()
assert(game.status_text == "PARTNER-SEITE", "Der Seitenwechsel muss Spieler 2 links als Partnerseite benennen.")
game._toggle_coop_side()
assert(game.status_text == "DEINE SEITE", "Der Seitenwechsel muss Spieler 2 rechts als eigene Seite benennen.")
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://kwjokxtwfr0u