diff --git a/LEGAL_RELEASE_CHECKLIST.md b/LEGAL_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..1781d66 --- /dev/null +++ b/LEGAL_RELEASE_CHECKLIST.md @@ -0,0 +1,32 @@ +# Datenschutz- und Rechtscheckliste + +Stand: Prototyp 0.22. Diese Arbeitsunterlage ersetzt keine individuelle Rechtsberatung. + +## Vor Veröffentlichung zwingend ergänzen + +- vollständiger bürgerlicher Name des Diensteanbieters +- ladungsfähige Anschrift +- erreichbare Kontakt-E-Mail-Adresse +- separate Support-Adresse, falls gewünscht +- Prüfung, ob weitere Angaben wie Umsatzsteuer-ID oder Registerangaben bestehen + +Die lokalen Webseitenentwürfe `impressum.html`, `privacy.html`, `support.html` und `delete-account.html` enthalten dafür deutlich markierte Platzhalter und dürfen so noch nicht veröffentlicht werden. + +## Bereits technisch vorbereitet + +- Datenschutz-Link direkt im Pilotenprofil +- serverseitig geprüfte öffentliche Spielernamen +- zweistufige, ausdrückliche Kontolöschung im Spiel +- neues Gastprofil nach Löschung der bisherigen Online- und lokalen Profildaten +- eigenes öffentliches Webziel für eine spätere Löschanfrage außerhalb der App +- Werbung bleibt bis zu Einwilligung, Store-Deklarationen und echten Anzeigenkennungen im Testbetrieb + +## Vor Store-Einreichung prüfen + +- Datenschutzerklärung und Impressum ohne Platzhalter veröffentlichen +- tatsächlich verarbeitete Daten in App Store Connect und Google Play deklarieren +- Kontolöschung auf echtem iOS- und Android-Gerät testen +- externen Löschweg in der Google-Play-Konsole hinterlegen +- Apple-/Game-Center- und Google-Play-Games-Verknüpfung samt Wiederherstellung testen +- Einwilligungsdialog, Widerruf der Werbeeinwilligung und `app-ads.txt` prüfen +- Aufbewahrungs- und Löschfristen mit Server-Backups praktisch verifizieren diff --git a/ONLINE_SETUP.md b/ONLINE_SETUP.md index d8b952f..0b6a88d 100644 --- a/ONLINE_SETUP.md +++ b/ONLINE_SETUP.md @@ -1,9 +1,11 @@ -# Void Bastion Online 0.21 +# Void Bastion Online 0.22 ## Fertiger Stand Das Spiel nutzt den offiziellen Nakama-Godot-Client 3.4.0. Beim normalen Start wird automatisch ein app-eigenes Gastkonto angemeldet. Das lokale Profil bleibt auch offline nutzbar; neue Bestwerte werden gespeichert und bei der nächsten erfolgreichen Verbindung nachgereicht. +Spielernamen werden lokal und serverseitig auf 3 bis 20 erlaubte Zeichen geprüft. Reservierte Bezeichnungen wie `ADMIN`, `GERGAMES`, `MODERATOR`, `SUPPORT` und `SYSTEM` sind gesperrt. Im Pilotenprofil kann das aktuell angemeldete Online-Konto nach einer zweiten ausdrücklichen Bestätigung gelöscht werden; anschließend verwirft das Spiel die lokale Identität und erstellt ein neues Gastprofil. + Der Unraid-Stack besteht aus: - Nakama 3.37.0 mit eigener JavaScript-Serverlogik @@ -43,4 +45,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, die Bereit-Lobby, autoritative Spielzustände sowie Erstellen, Verlassen und Wiederbeitreten eines privaten Raums per Code. +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. diff --git a/README.md b/README.md index c098913..1abfbb3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Void Bastion – spielbarer Prototyp 0.21 +# Void Bastion – spielbarer Prototyp 0.22 Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt. @@ -38,6 +38,9 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell - dauerhaft gespeicherte Kernkristalle als spätere Freischaltwährung - dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung - eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte +- frei wählbarer Spielername mit identischer Prüfung im Spiel und auf dem Server +- zweistufige Kontolöschung direkt im Pilotenprofil; danach wird ein neues lokales Gastprofil erstellt +- direkter Datenschutz-Link im Pilotenprofil - 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 @@ -104,7 +107,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. ## Steuerung -> **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`. +> **Online-Stand 0.22:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, servergeprüfte Spielernamen, Kontolöschung im Spiel, 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. diff --git a/account_manager.gd b/account_manager.gd index 67d7436..af3374f 100644 --- a/account_manager.gd +++ b/account_manager.gd @@ -4,6 +4,9 @@ extends Node signal profile_changed const ACCOUNT_PATH := "user://void_bastion_account.json" +const MIN_DISPLAY_NAME_LENGTH := 3 +const MAX_DISPLAY_NAME_LENGTH := 20 +const RESERVED_DISPLAY_NAMES := ["ADMIN", "ADMINISTRATOR", "GERGAMES", "MODERATOR", "SUPPORT", "SYSTEM"] var player_id := "" var display_name := "" @@ -100,6 +103,42 @@ func attach_online_identity(online_user_id: String, online_username: String) -> profile_changed.emit() +func normalize_display_name(value: String) -> String: + var normalized := " ".join(value.strip_edges().split(" ", false)) + return normalized.left(MAX_DISPLAY_NAME_LENGTH) + + +func validate_display_name(value: String) -> String: + var normalized := normalize_display_name(value) + if normalized.length() < MIN_DISPLAY_NAME_LENGTH: + return "Der Spielername muss mindestens %d Zeichen lang sein." % MIN_DISPLAY_NAME_LENGTH + if normalized.length() > MAX_DISPLAY_NAME_LENGTH: + return "Der Spielername darf höchstens %d Zeichen lang sein." % MAX_DISPLAY_NAME_LENGTH + if normalized.to_upper() in RESERVED_DISPLAY_NAMES: + return "Dieser Spielername ist reserviert." + var allowed_characters := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 _-ÄÖÜäöüß" + for character in normalized: + if not allowed_characters.contains(character): + return "Erlaubt sind Buchstaben, Zahlen, Leerzeichen, _ und -." + return "" + + +func set_display_name(value: String) -> bool: + var normalized := normalize_display_name(value) + if not validate_display_name(normalized).is_empty(): + return false + display_name = normalized + _save_profile() + profile_changed.emit() + return true + + +func reset_after_account_deletion() -> void: + _create_guest_profile() + _save_profile() + profile_changed.emit() + + func mark_submission_complete(record: Dictionary) -> void: for index in range(pending_submissions.size() - 1, -1, -1): var queued = pending_submissions[index] diff --git a/main.gd b/main.gd index f5bd0c0..44fe8c0 100644 --- a/main.gd +++ b/main.gd @@ -142,6 +142,14 @@ 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) var room_code_input := "" +var profile_name_input := "" +var profile_message := "" +var profile_delete_armed := false +var profile_rename_button := Rect2(220, 305, 250, 48) +var profile_privacy_button := Rect2(840, 685, 250, 48) +var profile_delete_button := Rect2(1110, 685, 250, 48) +var profile_name_input_rect := Rect2(460, 330, 680, 82) +var profile_name_save_button := Rect2(625, 470, 350, 62) @onready var ads_manager = $AdsManager @onready var account_manager = $AccountManager @@ -370,6 +378,21 @@ func _update_effects(delta: float) -> void: func _unhandled_input(event: InputEvent) -> void: + if game_mode == "profile_name_entry" and event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_ESCAPE: + _hide_virtual_keyboard() + game_mode = "profile" + elif event.keycode == KEY_BACKSPACE: + profile_name_input = profile_name_input.left(maxi(0, profile_name_input.length() - 1)) + elif event.keycode == KEY_ENTER: + _save_profile_name() + elif event.unicode > 0 and profile_name_input.length() < AccountManager.MAX_DISPLAY_NAME_LENGTH: + var character := char(event.unicode) + var allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 _-ÄÖÜäöüß" + if allowed.contains(character): + profile_name_input += character + queue_redraw() + return if game_mode == "room_code_entry" and event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE: _hide_virtual_keyboard() @@ -454,9 +477,32 @@ func _handle_pointer(position: Vector2, is_touch := false) -> void: return if game_mode == "profile": if profile_back_button.has_point(position): + profile_delete_armed = false game_mode = "menu" + elif profile_rename_button.has_point(position): + _open_profile_name_entry() + elif profile_privacy_button.has_point(position): + OS.shell_open("https://void-bastion.gergames.de/privacy.html") + elif profile_delete_button.has_point(position): + if not online_manager.is_authenticated(): + profile_message = "Kontolöschung benötigt eine Verbindung zum GerGames-Server." + elif not profile_delete_armed: + profile_delete_armed = true + profile_message = "Noch einmal bestätigen: Online-Konto, Ranglisten und lokale Werte werden gelöscht." + else: + _delete_profile_account() queue_redraw() return + if game_mode == "profile_name_entry": + if profile_back_button.has_point(position): + _hide_virtual_keyboard() + game_mode = "profile" + elif profile_name_input_rect.has_point(position): + _show_profile_name_keyboard() + elif profile_name_save_button.has_point(position): + _save_profile_name() + queue_redraw() + return if back_button.has_point(position): _return_to_menu() return @@ -672,6 +718,56 @@ func _hide_virtual_keyboard() -> void: DisplayServer.virtual_keyboard_hide() +func _open_profile_name_entry() -> void: + profile_name_input = account_manager.display_name + profile_message = "" + profile_delete_armed = false + game_mode = "profile_name_entry" + _show_profile_name_keyboard() + queue_redraw() + + +func _show_profile_name_keyboard() -> void: + if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD): + DisplayServer.virtual_keyboard_show(profile_name_input, profile_name_input_rect, DisplayServer.KEYBOARD_TYPE_DEFAULT, AccountManager.MAX_DISPLAY_NAME_LENGTH, profile_name_input.length(), profile_name_input.length()) + + +func _save_profile_name() -> void: + var validation_error: String = account_manager.validate_display_name(profile_name_input) + if not validation_error.is_empty(): + profile_message = validation_error + queue_redraw() + return + profile_message = "Spielername wird gespeichert …" + queue_redraw() + var result: Dictionary = await online_manager.update_display_name(profile_name_input) + if not bool(result.get("accepted", false)): + profile_message = str(result.get("error", "Der Spielername konnte nicht gespeichert werden.")) + queue_redraw() + return + _hide_virtual_keyboard() + profile_message = "Spielername gespeichert%s." % (" und online synchronisiert" if bool(result.get("online", false)) else "") + game_mode = "profile" + queue_redraw() + + +func _delete_profile_account() -> void: + profile_delete_armed = false + profile_message = "Konto wird unwiderruflich gelöscht …" + queue_redraw() + var result: Dictionary = await online_manager.delete_online_account() + if not bool(result.get("accepted", false)): + profile_message = str(result.get("error", "Das Konto konnte nicht gelöscht werden.")) + queue_redraw() + return + best_wave = 0 + best_endless_wave = 0 + core_crystals = 0 + _save_progress() + profile_message = "Online-Konto und lokale Profildaten gelöscht. Ein neues Gastprofil wurde erstellt." + queue_redraw() + + func _start_game() -> void: coop_online_mode = false credits = int(_difficulty().start_credits) @@ -1178,13 +1274,15 @@ func _load_progress() -> void: func _draw() -> void: - if game_mode in ["menu", "profile", "multiplayer_menu", "room_code_entry", "matchmaking"]: + if game_mode in ["menu", "profile", "profile_name_entry", "multiplayer_menu", "room_code_entry", "matchmaking"]: draw_texture_rect(MAP_TEXTURES[selected_map], Rect2(Vector2.ZERO, SCREEN), false, Color("f3f6fb")) draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.01, 0.025, 0.055, 0.06)) if game_mode == "menu": _draw_menu() elif game_mode == "profile": _draw_profile() + elif game_mode == "profile_name_entry": + _draw_profile_name_entry() elif game_mode == "multiplayer_menu": _draw_multiplayer_menu() elif game_mode == "room_code_entry": @@ -1619,6 +1717,7 @@ func _draw_profile() -> void: draw_string(font, Vector2(220, 220), account_manager.display_name, HORIZONTAL_ALIGNMENT_LEFT, 520, 30, Color("8ff4ff")) draw_string(font, Vector2(220, 258), account_manager.provider_label(), HORIZONTAL_ALIGNMENT_LEFT, 650, 16, Color("ffcf7a")) draw_string(font, Vector2(220, 292), "SPIELER-ID %s" % account_manager.short_id(), HORIZONTAL_ALIGNMENT_LEFT, 650, 14, Color("91a8bc")) + _draw_button(profile_rename_button, "SPIELERNAME ÄNDERN", false, Color("24516a")) draw_string(font, Vector2(840, 215), "KONTO & CLOUD", HORIZONTAL_ALIGNMENT_LEFT, 470, 18, Color("8feeff")) var cloud_label := "GERGAMES ONLINE VERBUNDEN" if online_manager.is_authenticated() else "GERGAMES ONLINE OFFLINE" var cloud_color := Color("83ffc8") if online_manager.is_authenticated() else Color("ffcf7a") @@ -1639,7 +1738,25 @@ func _draw_profile() -> void: draw_string(font, Vector2(930, y), "WELLE %02d" % account_manager.get_map_best_wave(index, true), HORIZONTAL_ALIGNMENT_CENTER, 180, 16, Color("ffcf7a")) var score := maxi(account_manager.get_map_best_score(index, false), account_manager.get_map_best_score(index, true)) draw_string(font, Vector2(1160, y), "%07d" % score, HORIZONTAL_ALIGNMENT_CENTER, 180, 16, Color("8fffea")) - draw_string(font, Vector2(220, 710), "Das Gastprofil funktioniert bereits lokal. Plattform-Anmeldungen und globale Ranglisten werden nach Einrichtung der Store-Konten aktiviert.", HORIZONTAL_ALIGNMENT_CENTER, 1160, 14, Color("86c8d8")) + if not profile_message.is_empty(): + draw_string(font, Vector2(220, 658), profile_message, HORIZONTAL_ALIGNMENT_CENTER, 1160, 14, Color("ffcf7a")) + _draw_button(profile_privacy_button, "DATENSCHUTZ", false, Color("24475b")) + _draw_button(profile_delete_button, "LÖSCHEN BESTÄTIGEN" if profile_delete_armed else "KONTO LÖSCHEN", profile_delete_armed, Color("6b2d3b")) + + +func _draw_profile_name_entry() -> void: + var font := ThemeDB.fallback_font + draw_rect(Rect2(Vector2.ZERO, SCREEN), Color(0.005, 0.015, 0.035, 0.82)) + _draw_button(profile_back_button, "< ZURÜCK", false, Color("26394a")) + draw_string(font, Vector2(0, 190), "SPIELERNAME", HORIZONTAL_ALIGNMENT_CENTER, 1600, 44, Color("c7f5ff")) + draw_string(font, Vector2(0, 250), "Dieser Name ist für Mitspieler und in Ranglisten sichtbar.", HORIZONTAL_ALIGNMENT_CENTER, 1600, 18, Color("a9d8e5")) + draw_rect(profile_name_input_rect, Color(0.012, 0.035, 0.065, 0.96), true) + draw_rect(profile_name_input_rect, Color("65dfff"), false, 3.0) + draw_string(font, profile_name_input_rect.position + Vector2(24, 53), profile_name_input, HORIZONTAL_ALIGNMENT_CENTER, profile_name_input_rect.size.x - 48, 26, Color.WHITE) + draw_string(font, Vector2(0, 448), "3–20 Zeichen · Buchstaben, Zahlen, Leerzeichen, _ und -", HORIZONTAL_ALIGNMENT_CENTER, 1600, 15, Color("91a8bc")) + _draw_button(profile_name_save_button, "SPIELERNAME SPEICHERN", false, Color("185f68")) + if not profile_message.is_empty(): + draw_string(font, Vector2(300, 585), profile_message, HORIZONTAL_ALIGNMENT_CENTER, 1000, 16, Color("ffcf7a")) func _draw_overlay(title: String, subtitle: String) -> void: diff --git a/online_manager.gd b/online_manager.gd index 93bacce..bec721b 100644 --- a/online_manager.gd +++ b/online_manager.gd @@ -129,7 +129,7 @@ func submit_run(record: Dictionary) -> bool: if not is_authenticated(): return false var payload := record.duplicate(true) - payload["client_version"] = "0.21" + payload["client_version"] = "0.22" 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)): @@ -149,6 +149,36 @@ func flush_pending_scores() -> void: await submit_run(record) +func update_display_name(value: String) -> Dictionary: + if not account_manager: + return {"accepted": false, "error": "Lokales Profil fehlt."} + var validation_error := account_manager.validate_display_name(value) + if not validation_error.is_empty(): + return {"accepted": false, "error": validation_error} + var normalized := account_manager.normalize_display_name(value) + if is_authenticated(): + var result = await client.rpc_async(session, "update_display_name", JSON.stringify({"display_name": normalized})) + var parsed := _parse_rpc_result(result) + if parsed.is_empty() or not bool(parsed.get("accepted", false)): + return {"accepted": false, "error": str(parsed.get("error", "Der Name konnte online nicht gespeichert werden."))} + normalized = str(parsed.get("display_name", normalized)) + account_manager.set_display_name(normalized) + return {"accepted": true, "display_name": normalized, "online": is_authenticated()} + + +func delete_online_account() -> Dictionary: + if not is_authenticated(): + return {"accepted": false, "error": "Für die Kontolöschung ist eine Serververbindung erforderlich."} + var result = await client.rpc_async(session, "delete_account", "{}") + var parsed := _parse_rpc_result(result) + if parsed.is_empty() or not bool(parsed.get("accepted", false)): + return {"accepted": false, "error": str(parsed.get("error", "Das Online-Konto konnte nicht gelöscht werden."))} + disconnect_from_server() + if account_manager: + account_manager.reset_after_account_deletion() + return {"accepted": true} + + func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array: if not is_authenticated(): return [] diff --git a/server/nakama/runtime/build/index.js b/server/nakama/runtime/build/index.js index db101d7..98d4ac3 100644 --- a/server/nakama/runtime/build/index.js +++ b/server/nakama/runtime/build/index.js @@ -1,4 +1,4 @@ -var GAME_VERSION = "0.21"; +var GAME_VERSION = "0.22"; var MAP_COUNT = 3; var DIFFICULTY_COUNT = 3; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; @@ -48,6 +48,33 @@ function rpcSubmitRun(ctx, logger, nk, payload) { return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave}); } +function normalizeDisplayName(value) { + return String(value || "").trim().replace(/\s+/g, " ").substring(0, 20); +} +function validateDisplayName(value) { + var name = normalizeDisplayName(value); + if (name.length < 3) return "name_too_short"; + if (name.length > 20) return "name_too_long"; + if (!/^[A-Za-z0-9 ÄÖÜäöüß_-]+$/.test(name)) return "invalid_characters"; + var reserved = ["ADMIN","ADMINISTRATOR","GERGAMES","MODERATOR","SUPPORT","SYSTEM"]; + if (reserved.indexOf(name.toUpperCase()) >= 0) return "reserved_name"; + return ""; +} +function rpcUpdateDisplayName(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("authentication required"); + var request; + try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); } + var name = normalizeDisplayName(request.display_name), errorCode = validateDisplayName(name); + if (errorCode) return JSON.stringify({accepted:false,error:errorCode}); + nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null); + return JSON.stringify({accepted:true,display_name:name}); +} +function rpcDeleteAccount(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("authentication required"); + nk.accountDeleteId(ctx.userId, false); + return JSON.stringify({accepted:true}); +} + function normalizeRoomCode(value) { return String(value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6); } @@ -232,7 +259,8 @@ function handleCommand(nk,dispatcher,state,message) { else if (message.opCode===OP_START_WAVE) changed=startWave(state); else if (message.opCode===OP_READY&&!state.started) { state.ready[message.sender.userId]=payload.ready!==false; - var name=String(payload.name||message.sender.username||"PILOT").trim().substring(0,24); + var requestedName=normalizeDisplayName(payload.name||message.sender.username||"PILOT"); + var name=validateDisplayName(requestedName) ? "PILOT" : requestedName; state.players[message.sender.userId]={name:name||"PILOT"}; var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2; for (var i=0;i 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.21", "Der Server muss die aktuelle Runtime 0.21 geladen haben: %s" % info_data) + assert(info_data.get("version") == "0.22", "Der Server muss die aktuelle Runtime 0.22 geladen haben: %s" % info_data) - var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.21-integration"}) + var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) + var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true) + assert(not deletion_session.is_exception() and deletion_session.is_valid(), "Ein Wegwerf-Testkonto muss angelegt werden können.") + var renamed = await deletion_client.rpc_async(deletion_session, "update_display_name", JSON.stringify({"display_name": "Testpilot Ägir"})) + assert(not renamed.is_exception(), "Ein gültiger Spielername muss serverseitig gespeichert werden können.") + var renamed_data = JSON.parse_string(renamed.payload) + assert(renamed_data is Dictionary and bool(renamed_data.get("accepted", false)), "Der Server muss die Namensänderung bestätigen.") + var invalid_name = await deletion_client.rpc_async(deletion_session, "update_display_name", JSON.stringify({"display_name": "ADMIN"})) + assert(not invalid_name.is_exception(), "Ein ungültiger Spielername muss kontrolliert abgelehnt werden.") + var invalid_name_data = JSON.parse_string(invalid_name.payload) + assert(invalid_name_data is Dictionary and not bool(invalid_name_data.get("accepted", true)), "Reservierte Spielernamen dürfen nicht angenommen werden.") + var deleted = await deletion_client.rpc_async(deletion_session, "delete_account", "{}") + assert(not deleted.is_exception(), "Das Wegwerf-Testkonto muss im Spiel löschbar sein.") + + var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.22-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) diff --git a/tests/smoke_test.gd b/tests/smoke_test.gd index b872bd2..cfb2e09 100644 --- a/tests/smoke_test.gd +++ b/tests/smoke_test.gd @@ -79,6 +79,13 @@ func _run() -> void: assert(not account_manager.player_id.is_empty(), "Beim ersten Start muss automatisch ein stabiles Gastprofil angelegt werden.") assert(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.") account_manager.storage_enabled = false + var original_display_name: String = account_manager.display_name + assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.") + assert(not account_manager.validate_display_name("ADMIN").is_empty(), "Reservierte Spielernamen müssen lokal abgewiesen werden.") + assert(not account_manager.validate_display_name("A!").is_empty(), "Zu kurze Namen mit Sonderzeichen müssen lokal abgewiesen werden.") + assert(account_manager.set_display_name(" Kommandant Ägir "), "Mehrfache Leerzeichen müssen beim Speichern normalisiert werden.") + assert(account_manager.display_name == "Kommandant Ägir", "Der sichtbare Spielername muss normalisiert gespeichert werden.") + account_manager.set_display_name(original_display_name) var previous_test_score: int = account_manager.get_map_best_score(0, false) assert(account_manager.record_run(0, 0, false, 3, previous_test_score + 1000), "Ein neuer lokaler Highscore muss gespeichert werden.") assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.")