Show public names in leaderboards
This commit is contained in:
1 parent
1b5b8dc0a4
commit
31ddba0d45
8 files changed
+98
-12
No files matched your search
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user