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

+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")