Show public names in leaderboards

This commit is contained in:
Emporex committed 2026-10-04 09:02:38 +02:00
1 parent 1b5b8dc0a4
commit 31ddba0d45
8 files changed
+98 -12

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Datenschutz- und Rechtscheckliste # Datenschutz- und Rechtscheckliste
Stand: Prototyp 0.22. Diese Arbeitsunterlage ersetzt keine individuelle Rechtsberatung. Stand: Prototyp 0.23. Diese Arbeitsunterlage ersetzt keine individuelle Rechtsberatung.
## Vor Veröffentlichung zwingend ergänzen ## Vor Veröffentlichung zwingend ergänzen
+2 -2
View File
@@ -1,10 +1,10 @@
# Void Bastion Online 0.22 # Void Bastion Online 0.23
## Fertiger Stand ## 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. 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. Spielernamen werden lokal und serverseitig auf 3 bis 20 erlaubte Zeichen geprüft. Reservierte Bezeichnungen wie `ADMIN`, `GERGAMES`, `MODERATOR`, `SUPPORT` und `SYSTEM` sind gesperrt. Ranglisten schreiben den öffentlichen Namen und lösen beim Anzeigen den aktuellen Profilnamen über die Spieler-ID auf; dadurch wird keine technische Gast-ID gezeigt und eine spätere Namensänderung gilt auch für ältere Bestwerte. 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: Der Unraid-Stack besteht aus:
+40
View File
@@ -0,0 +1,40 @@
# Plattformkonten: nächste Freischaltung
Void Bastion verwendet bereits eine eigene, plattformübergreifende Nakama-Spieler-ID und startet ohne Pflichtregistrierung als Gast. Apple-, Game-Center- und Google-Konten sollen dieses bestehende Konto später **verknüpfen**, nicht ungefragt ein zweites Spielkonto erzeugen. Dadurch bleiben Name, Kristalle, Fortschritt und Ranglisten-ID beim Gerätewechsel erhalten.
## Technisch bereits vorhanden
- eigener öffentlicher Spielername, getrennt von der technischen Konto-ID
- serverseitige Namensprüfung
- Gastkonto und lokale Offline-Nutzung
- Kontolöschung direkt im Spiel
- Nakama-Godot-SDK mit Apple-, Game-Center- und Google-Anmeldung sowie Link-/Unlink-Funktionen
## Von den Store-Konten benötigt
### Apple / iOS
- aktives Apple-Developer-Konto
- endgültige Bundle-ID des Spiels
- in App Store Connect angelegte App
- aktivierte Game-Center- und Sign-in-with-Apple-Funktionen, sobald diese Anmeldewege eingesetzt werden
- echtes iOS-Gerät für Sandbox- und Wiederherstellungstests
### Google / Android
- aktives Google-Play-Console-Konto
- endgültiger Android-Paketname
- Play-Games-Services-Spielprojekt
- OAuth-Clientdaten für Debug- und Release-Signatur einschließlich der jeweiligen SHA-1-Fingerprints
- internes Test-Release und Testkonto
## Geplanter Ablauf im Spiel
1. Das Spiel erstellt weiterhin sofort ein Gastkonto; Spielen bleibt ohne Anmeldung möglich.
2. Im Pilotenprofil kann der Spieler freiwillig **Mit Apple/Game Center verbinden** oder **Mit Google Play Games verbinden** wählen.
3. Die Plattform bestätigt die Identität über ihr natives SDK.
4. Das erhaltene Plattform-Token wird mit der bereits angemeldeten Nakama-Spieler-ID verknüpft.
5. Auf einem neuen Gerät stellt dieselbe Plattformanmeldung dieses Konto wieder her.
6. Vor dem Trennen der letzten Wiederherstellungsmethode warnt das Spiel deutlich.
Es werden keine provisorischen Schlüssel oder simulierten Store-Anmeldungen in den Produktionscode aufgenommen. Die eigentliche native Verknüpfung beginnt, sobald Bundle-ID, Paketname und die beiden Store-Projekte feststehen.
+2 -2
View File
@@ -1,4 +1,4 @@
# Void Bastion – spielbarer Prototyp 0.22 # Void Bastion – spielbarer Prototyp 0.23
Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt. Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt.
@@ -107,7 +107,7 @@ Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar.
## Steuerung ## Steuerung
> **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`. > **Online-Stand 0.23:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, servergeprüfte öffentliche 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. Ranglisten lösen den aktuellen öffentlichen Profilnamen über die unveränderliche Spieler-ID auf. 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. - 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. - 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.
+13 -2
View File
@@ -129,7 +129,7 @@ func submit_run(record: Dictionary) -> bool:
if not is_authenticated(): if not is_authenticated():
return false return false
var payload := record.duplicate(true) var payload := record.duplicate(true)
payload["client_version"] = "0.22" payload["client_version"] = "0.23"
var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload)) var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload))
var parsed := _parse_rpc_result(result) var parsed := _parse_rpc_result(result)
if parsed.is_empty() or not bool(parsed.get("accepted", false)): if parsed.is_empty() or not bool(parsed.get("accepted", false)):
@@ -186,11 +186,22 @@ func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limi
var result = await client.list_leaderboard_records_async(session, leaderboard_id, null, null, clampi(limit, 1, 100)) var result = await client.list_leaderboard_records_async(session, leaderboard_id, null, null, clampi(limit, 1, 100))
if result == null or result.is_exception(): if result == null or result.is_exception():
return [] return []
var owner_ids := PackedStringArray()
for record in result.records:
if not record.owner_id.is_empty() and not owner_ids.has(record.owner_id):
owner_ids.append(record.owner_id)
var current_names := {}
if not owner_ids.is_empty():
var users_result = await client.get_users_async(session, owner_ids)
if users_result != null and not users_result.is_exception():
for user in users_result.users:
if not user.display_name.is_empty():
current_names[user.id] = user.display_name
var rows: Array = [] var rows: Array = []
for record in result.records: for record in result.records:
rows.append({ rows.append({
"rank": int(record.rank), "rank": int(record.rank),
"username": record.username, "username": str(current_names.get(record.owner_id, record.username)),
"score": int(record.score), "score": int(record.score),
"wave": int(record.subscore), "wave": int(record.subscore),
"owner_id": record.owner_id "owner_id": record.owner_id
+11 -3
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.22"; var GAME_VERSION = "0.23";
var MAP_COUNT = 3; var MAP_COUNT = 3;
var DIFFICULTY_COUNT = 3; var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"]; var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
@@ -43,8 +43,16 @@ function rpcSubmitRun(ctx, logger, nk, payload) {
var wave = clampInteger(run.wave,1,mode === "mission" ? 15 : 9999), score = clampInteger(run.score,0,1000000000); var wave = clampInteger(run.wave,1,mode === "mission" ? 15 : 9999), score = clampInteger(run.score,0,1000000000);
if (score > wave*5000+100000) throw new Error("implausible score"); if (score > wave*5000+100000) throw new Error("implausible score");
var id = leaderboardId(mapIndex,difficulty,mode); var id = leaderboardId(mapIndex,difficulty,mode);
var metadata = {map:mapIndex,difficulty:difficulty,mode:mode,wave:wave,clientVersion:String(run.client_version||"unknown"),submittedAt:Date.now()}; var publicName = "PILOT";
var record = nk.leaderboardRecordWrite(id,ctx.userId,ctx.username||"Pilot",score,wave,metadata,null); try {
var account = nk.accountGetId(ctx.userId);
var storedName = account && account.user ? normalizeDisplayName(account.user.displayName) : "";
if (!validateDisplayName(storedName)) publicName = storedName;
} catch (error) {
logger.warn("Could not resolve display name for leaderboard user %s: %s", ctx.userId, error);
}
var metadata = {map:mapIndex,difficulty:difficulty,mode:mode,wave:wave,displayName:publicName,clientVersion:String(run.client_version||"unknown"),submittedAt:Date.now()};
var record = nk.leaderboardRecordWrite(id,ctx.userId,publicName,score,wave,metadata,null);
return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave}); return JSON.stringify({accepted:true,leaderboard_id:id,rank:record.rank,score:Number(record.score),wave:wave});
} }
+18
View File
@@ -38,6 +38,24 @@ assert.strictEqual(JSON.parse(context.rpcUpdateDisplayName({ userId: "pilot-a" }
assert.deepStrictEqual(JSON.parse(context.rpcDeleteAccount({ userId: "pilot-a" }, logger, accountNk, "{}")), { accepted: true }); assert.deepStrictEqual(JSON.parse(context.rpcDeleteAccount({ userId: "pilot-a" }, logger, accountNk, "{}")), { accepted: true });
assert.deepStrictEqual(deletedAccount, ["pilot-a", false]); assert.deepStrictEqual(deletedAccount, ["pilot-a", false]);
let leaderboardWrite = null;
const leaderboardNk = {
accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; },
leaderboardRecordWrite(...args) {
leaderboardWrite = args;
return { rank: 1, score: args[3] };
}
};
const submittedRun = JSON.parse(context.rpcSubmitRun(
{ userId: "pilot-a", username: "technical_guest_id" },
{ warn() {} },
leaderboardNk,
JSON.stringify({ map: 0, difficulty: 1, mode: "mission", wave: 4, score: 4700, client_version: "test" })
));
assert.strictEqual(submittedRun.accepted, true);
assert.strictEqual(leaderboardWrite[2], "Kommandant Ägir");
assert.strictEqual(leaderboardWrite[5].displayName, "Kommandant Ägir");
let state = context.matchInit({}, logger, nk, { difficulty: 1 }).state; 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, 1, state, [presenceA]).state;
state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state; state = context.matchJoin({}, logger, nk, dispatcher, 2, state, [presenceB]).state;
+11 -2
View File
@@ -32,7 +32,7 @@ func _run() -> void:
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.") assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
var info_data = JSON.parse_string(info.payload) 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 is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
assert(info_data.get("version") == "0.22", "Der Server muss die aktuelle Runtime 0.22 geladen haben: %s" % info_data) assert(info_data.get("version") == "0.23", "Der Server muss die aktuelle Runtime 0.23 geladen haben: %s" % info_data)
var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) var deletion_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) var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
@@ -48,11 +48,20 @@ func _run() -> void:
var deleted = await deletion_client.rpc_async(deletion_session, "delete_account", "{}") 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.") 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 public_leaderboard_name := "Rangpilot %s" % suffix.right(6)
var renamed_a = await client_a.rpc_async(session_a, "update_display_name", JSON.stringify({"display_name": public_leaderboard_name}))
assert(not renamed_a.is_exception() and bool(JSON.parse_string(renamed_a.payload).get("accepted", false)), "Der Ranglistenpilot muss einen öffentlichen Namen setzen können.")
var run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.23-integration"})
var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload) 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.") 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) var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10)
assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.") assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
var own_record = null
for record in records.records:
if record.owner_id == session_a.user_id:
own_record = record
break
assert(own_record != null and own_record.username == public_leaderboard_name, "Die Rangliste muss den öffentlichen Spielernamen statt der technischen Gast-ID zeigen.")
var socket_a: NakamaSocket = nakama.create_socket_from(client_a) var socket_a: NakamaSocket = nakama.create_socket_from(client_a)
var socket_b: NakamaSocket = nakama.create_socket_from(client_b) var socket_b: NakamaSocket = nakama.create_socket_from(client_b)