diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index 636531c..d8b952f 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -1,4 +1,4 @@ -# Void Bastion Online 0.20 +# Void Bastion Online 0.21 ## Fertiger Stand @@ -24,6 +24,8 @@ Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`. Die Godot-Anbindung öffnet nach der Anmeldung eine WSS/WS-Echtzeitverbindung. Die schnelle Spielersuche vermittelt exakt zwei passende Spieler. Alternativ erstellt ein Spieler einen privaten Raum mit einem sechsstelligen Code, dem der zweite Spieler gezielt beitritt. +Vor dem Start zeigt eine gemeinsame Lobby beide Pilotennamen und ihre zugewiesene Verteidigungsseite. Erst wenn beide Spieler ihren Bereit-Status bestätigt haben, beginnt die Partie. Bei einem kurzzeitigen Verbindungsabbruch bleibt der Spielerplatz 60 Sekunden reserviert, die Simulation pausiert und der Client versucht automatisch, demselben Match wieder beizutreten. + Der autoritative Matchserver verwaltet Spielerplätze, getrennte Energie, Türme, Gegner, Wellen und die gemeinsamen Reaktorleben. Beide Clients erhalten denselben geprüften Spielzustand. Der lokale KI-Koop bleibt unabhängig davon spielbar. ## Adressen @@ -41,4 +43,4 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag ` - echter Onlinetest im Heimnetz: `godot --headless --path . --script res://tests/online_integration_test.gd` - Produktionstest: `VOID_BASTION_TEST_HOST=api.void-bastion.gergames.de VOID_BASTION_TEST_SCHEME=https VOID_BASTION_TEST_PORT=443 godot --headless --path . --script res://tests/online_integration_test.gd` -Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, autoritative Spielzustände sowie Erstellen und Beitreten eines privaten Raums per Code. +Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, schnelles Matchmaking, die Bereit-Lobby, autoritative Spielzustände sowie Erstellen, Verlassen und Wiederbeitreten eines privaten Raums per Code. diff --git a/README.md b/README.md index ac57b32..c098913 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Void Bastion – spielbarer Prototyp 0.20 +# Void Bastion – spielbarer Prototyp 0.21 Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt. @@ -40,6 +40,8 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell - eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte - wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung - eigener Multiplayerbereich mit schneller Spielersuche sowie privaten Koop-Räumen mit sechsstelligen Raumcodes +- gemeinsame Online-Lobby mit Spielernamen, festen Verteidigungsseiten und manuellem Bereit-Status für beide Piloten +- reservierter Spielerplatz und automatische Wiederverbindung bei einem kurzzeitigen Netzverlust; die laufende Partie wartet bis zu 60 Sekunden - Vorschau einer symmetrisch gespiegelten Zwei-Spieler-Arena mit gemeinsamem Reaktor - getrennte Ressourcenmodelle für beide Koop-Spieler sowie gemeinsam geteilte Reaktorleben - sichtbarer und jederzeit abbrechbarer Wartezustand für schnelle und private Online-Partien @@ -102,7 +104,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. ## Steuerung -> **Online-Stand 0.20:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop und private Räume per Raumcode sind eingebaut. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let’s-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`. +> **Online-Stand 0.21:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets, serverautoritativer Zwei-Spieler-Koop, private Räume per Raumcode, eine Bereit-Lobby und Wiederverbindung sind eingebaut. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let’s-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`. - Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen. - Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau. @@ -118,6 +120,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. - Nach Welle 15 kann die erfolgreiche Mission beendet oder freiwillig im Endlosmodus fortgesetzt werden. - Unter **Multiplayer → Mit KI-Partner** startet die gespiegelte Koop-Arena sofort. Du baust auf der vollständigen linken Karte, die KI auf deren vollständiger Spiegelung; beide Seiten verteidigen denselben Reaktor. Mit **KI >** und **< ICH** wird ohne Verkleinerung zwischen beiden Seiten gewechselt. - Unter **Multiplayer → Raum erstellen** wird ein privater Koop-Raum angelegt und ein sechsstelliger Code angezeigt. Der Mitspieler wählt **Raum beitreten** und gibt diesen Code ein. +- In der anschließenden Lobby werden beide Pilotennamen und Verteidigungsseiten angezeigt. Die Partie startet erst, wenn beide **Bereit** gewählt haben. ## Nächste Produktionsschritte @@ -127,7 +130,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. 4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen 5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden 6. Private Raumcodes und die autoritative Koop-Synchronisierung auf echten Android-, iOS- und Windows-Geräten testen -7. Freundesliste, Einladungen und Wiederverbindung nach kurzzeitigem Netzverlust ergänzen +7. Freundesliste und direkte Einladungen ergänzen 8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion ## Godot Agent Tools MCP diff --git a/main.gd b/main.gd index 30685bc..f5bd0c0 100644 --- a/main.gd +++ b/main.gd @@ -137,6 +137,7 @@ var friend_match_button := Rect2(475, 665, 300, 64) var private_match_button := Rect2(825, 665, 300, 64) var ai_match_button := Rect2(1175, 665, 300, 64) var cancel_search_button := Rect2(625, 650, 350, 64) +var lobby_ready_button := Rect2(625, 580, 350, 58) var room_code_input_rect := Rect2(510, 340, 580, 90) var join_room_button := Rect2(625, 525, 350, 64) var coop_side_button := Rect2(920, 18, 140, 54) @@ -444,7 +445,9 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: queue_redraw() return if game_mode == "matchmaking": - if cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): + if lobby_ready_button.has_point(position) and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"]: + multiplayer_manager.toggle_lobby_ready() + elif cancel_search_button.has_point(position) or multiplayer_back_button.has_point(position): multiplayer_manager.cancel_search() game_mode = "multiplayer_menu" queue_redraw() @@ -1068,6 +1071,8 @@ func _apply_online_snapshot(snapshot: Dictionary) -> void: if not coop_online_mode or snapshot.is_empty(): return selected_difficulty = clampi(int(snapshot.get("difficulty", selected_difficulty)), 0, DIFFICULTY_DATA.size() - 1) + if bool(snapshot.get("connection_interrupted", false)): + _show_status("MITPILOT GETRENNT • WIEDERBEITRITT %d S" % int(snapshot.get("reconnect_seconds", 0))) reactor_lives = int(snapshot.get("reactor_lives", reactor_lives)) wave = int(snapshot.get("wave", wave)) wave_active = bool(snapshot.get("wave_active", false)) @@ -1467,7 +1472,7 @@ func _draw_menu() -> void: _draw_button(start_button, "MISSION STARTEN", false, Color("18556f")) _draw_button(multiplayer_button, "MULTIPLAYER", false, Color("254f75")) draw_string(font, Vector2(0, 792), "BESTE WELLE %02d • KERNKRISTALLE %04d • KARTENBONUS %d" % [best_wave, core_crystals, int(_difficulty().crystals)], HORIZONTAL_ALIGNMENT_CENTER, 1600, 17, Color("86c8d8")) - draw_string(font, Vector2(0, 860), "VERSION 0.20 • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) + draw_string(font, Vector2(0, 860), "VERSION 0.21 • GERGAMES-ONLINEKONTO • GLOBALE BESTENLISTEN", HORIZONTAL_ALIGNMENT_CENTER, 1600, 14, Color("688293")) func _draw_multiplayer_menu() -> void: @@ -1535,14 +1540,20 @@ func _draw_matchmaking() -> void: _draw_button(multiplayer_back_button, "< ZURÜCK", false, Color("26394a")) var private_mode: bool = multiplayer_manager.matchmaking_mode in ["private_host", "private_join"] draw_string(font, Vector2(0, 210), "PRIVATER KOOP-RAUM" if private_mode else "MITSPIELERSUCHE", HORIZONTAL_ALIGNMENT_CENTER, 1600, 46, Color("c7f5ff")) - var pulse := 56.0 + sin(elapsed * 3.0) * 8.0 - draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0) - draw_circle(Vector2(800, 390), 22.0, Color("8ffaff")) + var in_lobby: bool = multiplayer_manager.player_slot >= 0 and multiplayer_manager.matchmaking_state in ["hosting", "matched", "lobby", "reconnected"] + if in_lobby: + _draw_online_lobby_players(font) + else: + var pulse := 56.0 + sin(elapsed * 3.0) * 8.0 + draw_arc(Vector2(800, 390), pulse, -PI * 0.2, PI * 1.45, 52, Color("65eaff"), 5.0) + draw_circle(Vector2(800, 390), 22.0, Color("8ffaff")) var mode_labels := {"quick": "SCHNELLES SPIEL", "private_host": "RAUM ERSTELLEN", "private_join": "RAUM BEITRETEN"} var mode_text: String = mode_labels.get(multiplayer_manager.matchmaking_mode, "KOOP-SPIEL") if not multiplayer_manager.private_room_code.is_empty(): mode_text = "RAUMCODE %s" % " ".join(multiplayer_manager.private_room_code.split("")) draw_string(font, Vector2(0, 500), mode_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 23, Color.WHITE) + if in_lobby: + _draw_button(lobby_ready_button, "NICHT BEREIT" if multiplayer_manager.local_ready else "BEREIT", multiplayer_manager.local_ready, Color("8b6330") if multiplayer_manager.local_ready else Color("185f68")) _draw_button(cancel_search_button, "ZURÜCK" if multiplayer_manager.matchmaking_state == "room_not_found" else "SUCHE ABBRECHEN", false, Color("5c3342")) var online_text := "ONLINE • SUCHE NACH EINEM MITPILOTEN" var online_color := Color("83ffc8") @@ -1553,8 +1564,17 @@ func _draw_matchmaking() -> void: online_text = "SERVER NOCH NICHT ERREICHBAR • BITTE SPÄTER ERNEUT VERSUCHEN" online_color = Color("ff8e8e") elif multiplayer_manager.matchmaking_state == "matched": - online_text = "MITPILOT GEFUNDEN • KOOP-SITZUNG BEREIT" + online_text = "MITPILOT GEFUNDEN • WÄHLE BEREIT" online_color = Color("8ffaff") + elif multiplayer_manager.matchmaking_state in ["lobby", "reconnected"]: + online_text = "BEIDE SPIELER MÜSSEN BEREIT SEIN" + online_color = Color("8ffaff") + elif multiplayer_manager.matchmaking_state == "reconnecting": + online_text = "VERBINDUNG WIRD WIEDERHERGESTELLT ." + online_color = Color("ffcf7a") + elif multiplayer_manager.matchmaking_state == "connection_lost": + online_text = "VERBINDUNG VERLOREN • BITTE ZURÜCKKEHREN" + online_color = Color("ff8e8e") elif multiplayer_manager.matchmaking_state == "creating_room": online_text = "PRIVATER RAUM WIRD ERSTELLT ." online_color = Color("ffcf7a") @@ -1571,6 +1591,24 @@ func _draw_matchmaking() -> void: draw_string(font, Vector2(0, 548), online_text, HORIZONTAL_ALIGNMENT_CENTER, 1600, 16, online_color) +func _draw_online_lobby_players(font: Font) -> void: + for slot in 2: + var rect := Rect2(270 + slot * 560, 285, 500, 130) + var player = multiplayer_manager.lobby_players[slot] if slot < multiplayer_manager.lobby_players.size() else null + var occupied: bool = player is Dictionary + var connected: bool = occupied and bool(player.get("connected", false)) + var ready: bool = occupied and bool(player.get("ready", false)) + var border := Color("5de8a8") if ready else (Color("65eaff") if connected else Color("425567")) + draw_rect(rect, Color(0.012, 0.035, 0.065, 0.96), true) + draw_rect(rect, border, false, 3.0) + var name := String(player.get("name", "PILOT")) if occupied else "WARTE AUF MITPILOTEN" + var side := "LINKE VERTEIDIGUNG" if slot == 0 else "RECHTE VERTEIDIGUNG" + var state := "BEREIT" if ready else ("NICHT BEREIT" if connected else "NICHT VERBUNDEN") + draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 43), name.to_upper(), HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 24, Color("e8fbff")) + draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 78), side, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 14, Color("8fcddd")) + draw_string(font, Vector2(rect.position.x + 24, rect.position.y + 108), state, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x - 48, 16, border) + + func _draw_profile() -> void: var font := ThemeDB.fallback_font draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.76)) diff --git a/multiplayer_manager.gd b/multiplayer_manager.gd index 8b9fcf0..04c05c4 100644 --- a/multiplayer_manager.gd +++ b/multiplayer_manager.gd @@ -13,6 +13,9 @@ var matchmaking_state := "idle" var matchmaking_mode := "" var matchmaking_difficulty := 1 var private_room_code := "" +var local_ready := false +var lobby_players: Array = [null, null] +var reconnect_seconds := 0 var online_match = null var online_mode := false var player_slot := -1 @@ -28,6 +31,9 @@ func reset_coop_session(start_credits := COOP_START_CREDITS, reactor_lives := CO player_slot = -1 latest_snapshot = {} private_room_code = "" + local_ready = false + lobby_players = [null, null] + reconnect_seconds = 0 func spend_player_credits(player_index: int, amount: int) -> bool: @@ -109,10 +115,21 @@ func cancel_search() -> void: online_manager.cancel_matchmaking() matchmaking_mode = "" private_room_code = "" + local_ready = false + lobby_players = [null, null] matchmaking_state = "idle" state_changed.emit(matchmaking_state) +func toggle_lobby_ready() -> void: + if player_slot < 0 or matchmaking_state not in ["hosting", "matched", "lobby", "reconnected"]: + return + local_ready = not local_ready + var online_manager = get_parent().get_node_or_null("OnlineManager") + if online_manager: + await online_manager.set_lobby_ready(local_ready) + + func _begin_online_search(mode: String) -> void: if matchmaking_state == "idle" or matchmaking_mode != mode: return @@ -172,13 +189,17 @@ func _on_online_matchmaking_state_changed(value: String) -> void: func _on_online_match_found(match_data) -> void: online_match = match_data - online_mode = true + online_mode = false matchmaking_state = "matched" state_changed.emit(matchmaking_state) func _on_online_snapshot(snapshot: Dictionary) -> void: latest_snapshot = snapshot + var players = snapshot.get("players", []) + if players is Array and players.size() >= PLAYER_COUNT: + lobby_players = players.duplicate(true) + reconnect_seconds = int(snapshot.get("reconnect_seconds", 0)) var credits = snapshot.get("credits", []) if credits is Array and credits.size() >= PLAYER_COUNT: player_credits = [int(credits[0]), int(credits[1])] @@ -189,6 +210,11 @@ func _on_online_snapshot(snapshot: Dictionary) -> void: var resolved_slot := int(slots.get(online_manager.session.user_id, -1)) if resolved_slot >= 0 and resolved_slot != player_slot: player_slot = resolved_slot + if player_slot >= 0 and player_slot < lobby_players.size() and lobby_players[player_slot] is Dictionary: + local_ready = bool(lobby_players[player_slot].get("ready", false)) + if not bool(snapshot.get("started", false)) and player_slot >= 0 and matchmaking_state in ["hosting", "matched", "reconnected"]: + matchmaking_state = "lobby" + state_changed.emit(matchmaking_state) if bool(snapshot.get("started", false)) and player_slot >= 0 and not online_mode: online_mode = true online_game_ready.emit(player_slot) diff --git a/online_manager.gd b/online_manager.gd index c128b15..93bacce 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -32,6 +32,9 @@ var current_host := "" var current_match = null var matchmaker_ticket := "" var private_room_code := "" +var lobby_ready := false +var intentional_disconnect := false +var reconnect_in_progress := false var account_manager: AccountManager var auto_connect_enabled := true @@ -83,21 +86,27 @@ func _authenticate_on(host: String, port: int, scheme: String) -> bool: func _after_authentication() -> void: socket = Nakama.create_socket_from(client) - socket.received_matchmaker_matched.connect(_on_matchmaker_matched) - socket.received_match_state.connect(_on_match_state) - socket.closed.connect(_on_socket_closed) + _bind_socket_signals() var result = await socket.connect_async(session, true, 5) if result == null or result.is_exception(): last_error = "Echtzeitverbindung konnte nicht aufgebaut werden" socket = null _set_connection_state("authenticated") else: + intentional_disconnect = false _set_connection_state("online") authenticated.emit(session.user_id, session.username) await flush_pending_scores() +func _bind_socket_signals() -> void: + socket.received_matchmaker_matched.connect(_on_matchmaker_matched) + socket.received_match_state.connect(_on_match_state) + socket.closed.connect(_on_socket_closed) + + func disconnect_from_server() -> void: + intentional_disconnect = true if socket: socket.close() socket = null @@ -105,6 +114,7 @@ func disconnect_from_server() -> void: current_match = null matchmaker_ticket = "" private_room_code = "" + lobby_ready = false _set_connection_state("offline") @@ -119,7 +129,7 @@ func submit_run(record: Dictionary) -> bool: if not is_authenticated(): return false var payload := record.duplicate(true) - payload["client_version"] = "0.20" + payload["client_version"] = "0.21" var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload)) var parsed := _parse_rpc_result(result) if parsed.is_empty() or not bool(parsed.get("accepted", false)): @@ -160,6 +170,7 @@ func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limi func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: + intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false @@ -178,6 +189,7 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: func cancel_matchmaking() -> void: + intentional_disconnect = true if socket and not matchmaker_ticket.is_empty(): await socket.remove_matchmaker_async(matchmaker_ticket) matchmaker_ticket = "" @@ -189,6 +201,7 @@ func cancel_matchmaking() -> void: func create_private_room(difficulty := 1) -> bool: + intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false @@ -209,11 +222,13 @@ func create_private_room(difficulty := 1) -> bool: return false private_room_created.emit(private_room_code) matchmaking_state_changed.emit("hosting") - await send_match_command(OP_READY, {}) + lobby_ready = false + await set_lobby_ready(false) return true func join_private_room(room_code: String) -> bool: + intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false @@ -233,11 +248,13 @@ func join_private_room(room_code: String) -> bool: matchmaking_state_changed.emit("room_not_found") return false matchmaking_state_changed.emit("matched") - await send_match_command(OP_READY, {}) + lobby_ready = false + await set_lobby_ready(false) return true func _on_matchmaker_matched(matched) -> void: + intentional_disconnect = false matchmaker_ticket = "" current_match = await socket.join_matched_async(matched) if current_match == null or current_match.is_exception(): @@ -245,7 +262,14 @@ func _on_matchmaker_matched(matched) -> void: return matchmaking_state_changed.emit("matched") match_found.emit(current_match) - await send_match_command(OP_READY, {}) + lobby_ready = false + await set_lobby_ready(false) + + +func set_lobby_ready(value: bool) -> bool: + lobby_ready = value + var player_name := account_manager.display_name if account_manager else session.username + return await send_match_command(OP_READY, {"ready": value, "name": player_name}) func send_match_command(op_code: int, payload: Dictionary) -> bool: @@ -276,6 +300,7 @@ func leave_current_match() -> void: await socket.leave_match_async(current_match.match_id) current_match = null private_room_code = "" + lobby_ready = false func _on_match_state(data) -> void: @@ -291,8 +316,38 @@ func _on_match_state(data) -> void: func _on_socket_closed() -> void: + var reconnect_match_id := "" + if not intentional_disconnect and current_match != null: + reconnect_match_id = String(current_match.match_id) + current_match = null if connection_state != "offline": _set_connection_state("authenticated") + if not reconnect_match_id.is_empty() and not reconnect_in_progress: + reconnect_in_progress = true + matchmaking_state_changed.emit("reconnecting") + _recover_match.call_deferred(reconnect_match_id) + + +func _recover_match(match_id: String) -> void: + for attempt in 5: + await get_tree().create_timer(1.5 + attempt).timeout + if intentional_disconnect or not is_authenticated(): + break + socket = Nakama.create_socket_from(client) + _bind_socket_signals() + var connected = await socket.connect_async(session, true, 5) + if connected == null or connected.is_exception(): + continue + var rejoined = await socket.join_match_async(match_id) + if rejoined != null and not rejoined.is_exception(): + current_match = rejoined + intentional_disconnect = false + reconnect_in_progress = false + _set_connection_state("online") + matchmaking_state_changed.emit("reconnected") + return + reconnect_in_progress = false + matchmaking_state_changed.emit("connection_lost") func _device_id() -> String: diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index fd30d2d..db101d7 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -1,4 +1,4 @@ -var GAME_VERSION = "0.20"; +var GAME_VERSION = "0.21"; var MAP_COUNT = 3; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; @@ -97,24 +97,37 @@ function matchmakerMatched(ctx, logger, nk, matches) { function initialState(params) { var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1)))); var credits = DIFFICULTIES[difficulty].startCredits; - return {presences:{},slots:{},started:false,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0}; + return {presences:{},slots:{},players:{},ready:{},started:false,pausedForReconnect:false,reconnectDeadlineTick:0,gameOver:false,difficulty:difficulty,mode:String(params.mode||"quick"),roomCode:normalizeRoomCode(params.roomCode),hostUserId:String(params.hostUserId||""),emptyTicks:0,credits:[credits,credits],reactorLives:30,wave:0,waveActive:false,spawnRemaining:0,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0}; } function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); } function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; } -function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:Object.keys(state.presences).length<2}; } +function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; } function firstFreeSlot(state) { var used={},ids=Object.keys(state.slots); for (var i=0;i= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; } } else state.emptyTicks = 0; for (var i=0;i=state.reconnectDeadlineTick) { state.gameOver=true; state.pausedForReconnect=false; state.waveActive=false; broadcastEvent(dispatcher,"partner_timeout",{}); } + if (state.started&&!state.pausedForReconnect&&!state.gameOver) { var delta=1/TICK_RATE; updateWave(state,delta,dispatcher); updateEnemies(state,delta); updateTowers(state,delta); } if (tick-state.lastSnapshotTick>=SNAPSHOT_TICKS||messages.length) broadcastSnapshot(dispatcher,state,tick); return {state:state}; } diff --git a/server/nakama/tests/runtime_sim_test.js b/server/nakama/tests/runtime_sim_test.js index 42322a9..b50e7a3 100644 --- a/server/nakama/tests/runtime_sim_test.js +++ b/server/nakama/tests/runtime_sim_test.js @@ -27,10 +27,10 @@ const presenceB = { userId: "pilot-b", sessionId: "b", username: "Pilot B" }; let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state; state = context.matchJoin({}, logger, nk, dispatcher, 1, state, [presenceA]).state; state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state; -assert.strictEqual(state.started, true); +assert.strictEqual(state.started, false); assert.strictEqual(state.slots["pilot-a"], 0); assert.strictEqual(state.slots["pilot-b"], 1); -assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false)); +assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.players[0].name === "Pilot A")); assert.strictEqual(context.normalizeRoomCode(" ab-c12! "), "ABC12"); const privateMatch = context.matchInit({}, logger, nk, { difficulty: 2, mode: "private", roomCode: "AB23CD", hostUserId: "pilot-a" }); @@ -53,7 +53,14 @@ function message(sender, opCode, payload) { return { sender, opCode, data: Buffer.from(JSON.stringify(payload)) }; } -state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [ +state = context.matchLoop({}, logger, nk, dispatcher, 3, state, [message(presenceA, 14, { ready: true, name: "Kommandant A" })]).state; +assert.strictEqual(state.started, false); +state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceB, 14, { ready: true, name: "Kommandant B" })]).state; +assert.strictEqual(state.started, true); +assert.strictEqual(state.players["pilot-a"].name, "Kommandant A"); +assert.ok(sent.some(entry => entry.opCode === "label" && entry.data.open === false)); + +state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [ message(presenceA, 10, { tower_type: 0, x: 200, y: 200 }), message(presenceB, 10, { tower_type: 1, x: 2760, y: 200 }) ]).state; @@ -61,10 +68,10 @@ assert.strictEqual(state.towers.length, 2); assert.strictEqual(state.credits[0], 310); assert.strictEqual(state.credits[1], 250); -state = context.matchLoop({}, logger, nk, dispatcher, 4, state, [message(presenceA, 13, {})]).state; +state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state; assert.strictEqual(state.wave, 1); assert.strictEqual(state.waveActive, true); -for (let tick = 5; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; +for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state; assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0); assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true)); @@ -74,4 +81,13 @@ assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2); state = context.matchLoop({}, logger, nk, dispatcher, 31, state, [message(presenceA, 12, { tower_id: towerA.id })]).state; assert.ok(!state.towers.some(tower => tower.id === towerA.id)); +state = context.matchLeave({}, logger, nk, dispatcher, 32, state, [presenceB]).state; +assert.strictEqual(state.started, true); +assert.strictEqual(state.pausedForReconnect, true); +assert.strictEqual(context.snapshot(state, 32).players[1].connected, false); +assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true); +state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state; +assert.strictEqual(state.pausedForReconnect, false); +assert.strictEqual(state.slots["pilot-b"], 1); + console.log("VOID_BASTION_SERVER_SIM_OK"); diff --git a/tests/online_integration_test.gd b/tests/online_integration_test.gd index 68a9588..f98f9f3 100644 --- a/tests/online_integration_test.gd +++ b/tests/online_integration_test.gd @@ -32,9 +32,9 @@ 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.20", "Der Server muss die aktuelle Runtime 0.20 geladen haben: %s" % info_data) + assert(info_data.get("version") == "0.21", "Der Server muss die aktuelle Runtime 0.21 geladen haben: %s" % info_data) - var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.20-integration"}) + var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.21-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) @@ -62,6 +62,10 @@ func _run() -> void: var match_b = await socket_b.join_matched_async(matched_b) assert(not match_a.is_exception() and not match_b.is_exception(), "Beide Spieler müssen der autoritativen Koop-Partie beitreten können.") assert(match_a.match_id == match_b.match_id, "Beide Spieler müssen dieselbe Koop-Partie erhalten.") + await create_timer(0.2).timeout + assert(not bool(snapshot_a.get("started", false)), "Die Partie darf vor der Bereitschaft beider Spieler nicht starten.") + await socket_a.send_match_state_async(match_a.match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT ALPHA"})) + await socket_b.send_match_state_async(match_b.match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT BETA"})) for attempt in 100: if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)): break @@ -103,6 +107,18 @@ func _run() -> void: assert(found_data is Dictionary and String(found_data.get("match_id", "")) == private_match_id, "Der Raumcode muss genau zur erstellten Partie führen: %s" % found_data) var private_match_b = await socket_b.join_match_async(private_match_id) assert(not private_match_b.is_exception(), "Der Mitspieler muss dem privaten Raum beitreten können.") + await create_timer(0.2).timeout + assert(not bool(snapshot_a.get("started", false)), "Auch private Räume müssen auf die Bereitschaft beider Spieler warten.") + var lobby_players: Array = snapshot_a.get("players", []) + assert(lobby_players.size() == 2 and lobby_players[0] is Dictionary and lobby_players[1] is Dictionary, "Die Lobby muss beide Spielerplätze anzeigen.") + assert(not (await socket_b.leave_match_async(private_match_id)).is_exception(), "Ein Mitspieler muss die Lobby verlassen können.") + await create_timer(0.2).timeout + lobby_players = snapshot_a.get("players", []) + assert(lobby_players.size() == 2 and not bool(lobby_players[1].get("connected", true)), "Die Lobby muss einen getrennten Mitspieler erkennen.") + private_match_b = await socket_b.join_match_async(private_match_id) + assert(not private_match_b.is_exception(), "Der reservierte Spieler muss demselben Raum wieder beitreten können.") + await socket_a.send_match_state_async(private_match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT ALPHA"})) + await socket_b.send_match_state_async(private_match_id, 14, JSON.stringify({"ready": true, "name": "TESTPILOT BETA"})) for attempt in 100: if bool(snapshot_a.get("started", false)) and bool(snapshot_b.get("started", false)): break