Compare commits

..
3 Commits
23 changed files with 1826 additions and 268 deletions

No files matched your search

+12 -3
View File
@@ -38,12 +38,21 @@ Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgea
- [ ] **M** Kurzen Netzabbruch während einer Onlinepartie und die Wiederverbindung prüfen.
- [ ] **M** Lokaler Koop mit KI: getrennte Ressourcen, zwei Gegnerwege, Seitenwechsel und gemeinsames Reaktorleben prüfen.
## Sprache und Freundesliste
- [x] **A** Deutsch/Englisch-Umschaltung und Freundes-/Einladungszustände bestehen den Godot-Test.
- [x] **A** Serverprüfung lehnt Einladungen ohne gegenseitige Freundschaft oder Raumhoheit ab und begrenzt Wiederholungen.
- [ ] **M** Nach Client- und Server-Update beide Sprachen vollständig durchspielen; ältere Status- und Fehlermeldungen nachübersetzen.
- [ ] **M** Auf zwei Konten Freundschaftsanfrage senden, annehmen und wieder entfernen; Status „online“ und „im Spiel“ samt Wiederverbindung prüfen.
- [ ] **M** Aus einem privaten Raum eine Einladung senden und auf dem zweiten Gerät direkt beitreten; nach Raumende darf kein alter Code mehr nutzbar sein.
- [x] **A** Datenschutzhinweise für Freundschaftsbeziehungen, Status und Systemeinladungen aktualisiert und auf der öffentlichen Seite geprüft.
## Veröffentlichung
- [ ] **M** Setup-Datei und SHA-256-Prüfsumme stimmen mit der öffentlichen Release-Datei überein.
- [ ] **M** Download-Schaltfläche auf `https://void-bastion.gergames.de/` lädt die aktuelle Setup-Datei.
- [x] **A** Alpha-10-Setup-Datei und SHA-256-Prüfsumme stimmen mit dem erneut heruntergeladenen Gitea-Release-Asset überein.
- [x] **A** Der Homepage-Release-Endpunkt zeigt auf Alpha 10 und die aktuelle Setup-Datei; Download-Schaltfläche im Browser noch manuell anklicken.
- [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar.
- [ ] **M** Änderungen, bekannte Fehler und Testhinweise stehen in den Release-Notizen.
- [x] **A** Änderungen, bekannte Fehler und Testhinweise stehen in den Alpha-10-Release-Notizen.
## Neue offene Punkte (Stand 06.10.2026)
+63
View File
@@ -0,0 +1,63 @@
# Void Bastion: Git- und Serververöffentlichung
Stand: 10. Oktober 2026. Diese Anleitung enthält bewusst keine Passwörter, privaten Schlüssel, Webhook-Adressen oder API-Tokens.
## Zuständigkeiten
| Teil | Quelle | Ziel | Transport |
|---|---|---|---|
| Homepage und geschützte Statistikseite | `outputs/void-bastion-website-git` | Gitea `Emporex/Void-Bastion-Website` → Netcup `gergames.de/void-bastion` | Git über repositorygebundenen SSH-Deploy-Key; Netcup-Plesk-Git-Anbindung |
| Online-Spielserver/Nakama | `outputs/void-bastion/server/nakama/runtime/build/index.js` | Unraid `/mnt/user/appdata/void-bastion-online/nakama/modules/build/index.js` | bestehender SSH-Alias `unraid-codex`, `tools/deploy-nakama.ps1` |
| Windows-Spiel | Godot-Projekt `outputs/void-bastion` | Gitea-Release mit Setup-EXE; Homepage verlinkt neuestes Release | separater Windows-Build-/Release-Prozess |
Die Website und der Nakama-Server sind unterschiedliche Systeme. Ein Git-Push zur Website aktualisiert nicht automatisch den Heimserver. Ein Nakama-Upload aktualisiert nicht automatisch Netcup.
## Website: direkt per Git arbeiten
Der aktuelle Arbeitsklon ist `outputs/void-bastion-website-git`. Sein `origin` zeigt per SSH auf Port 2202 des lokalen Unraid-Gitea. In der **lokalen** `.git/config` steht `core.sshCommand` mit dem repositorygebundenen Schlüssel `C:/Users/Domin/.ssh/id_ed25519_void_bastion_site` und der separat geprüften Host-Key-Datei `known_hosts_void_bastion_gitea`. Der private Schlüssel ist nicht im Repository. Der Gitea-Schlüssel hat Schreibrechte **nur** für `Emporex/Void-Bastion-Website`. Er ist auf diesem PC ohne Passphrase gespeichert und durch die Windows-Dateirechte auf Benutzer, Administratoren und System begrenzt. Ein laufender SSH-Agent ist hierfür nicht nötig. Bei Verlust des PCs den Deploy-Key in Gitea widerrufen.
```powershell
cd 'C:\Users\Domin\Documents\Codex\2026-10-02\ich\outputs\void-bastion-website-git'
git pull --ff-only
git status
# Nur beabsichtigte Dateien bearbeiten und gezielt stagen, z. B.:
git add intern/statistik/index.php
git commit -m "Beschreibung der Änderung"
git push origin main
```
Vor jedem Push `git status` und `git diff --cached` prüfen. Keine Zugangsdaten committen. `intern/statistik/stats-config.local.php` bleibt ausschließlich auf dem Webhosting und ist in `.gitignore` ausgeschlossen.
Netcup-Plesk ist mit dem Gitea-Repository `Emporex/Void-Bastion-Website`, Branch `main`, und dem Ziel `/gergames.de/void-bastion` verbunden. Seit dem 10. Oktober steht der Plesk-Bereitstellungsmodus auf **Automatisch**. Im Gitea-Repository ist der aktive Webhook „Plesk – Void Bastion Website“ eingerichtet: nur Push-Events, Branch-Filter `main`, POST mit `application/json`. Seine Plesk-Zieladresse enthält einen Zugriffswert und darf weder in Git noch in diese Doku kopiert werden. Ein Test-Event und der echte Push von Commit `3502e04` erhielten jeweils HTTP 204. Der harmlose HTML-Kommentar dieses Commits war danach auf der öffentlichen Homepage sichtbar, ohne manuelle Plesk-Aktion. Damit reicht künftig für Website-Änderungen der geprüfte `git push origin main`; anschließend die betroffene Live-Seite prüfen. Die Windows-Versionsanzeige kommt separat über `latest-release.php` aus der Releases-API und beweist keine PHP-Bereitstellung.
Falls eine Änderung nicht live erscheint: In **diesem** Gitea-Repository unter Einstellungen → Webhooks die jüngste Zustellung und Antwort prüfen. In Plesk nur bei `void-bastion.gergames.de` den Git-Stand und das Bereitstellungsziel prüfen; notfalls dort „Jetzt Pull ausführen“ und „Jetzt bereitstellen“ manuell nutzen. Andere Domains oder das webhostingweite FTP/SSH-Konto nicht für automatische Uploads benutzen. Ein unerwünschtes Website-Update durch einen gezielten Git-Revert und erneuten Push zurücknehmen, nicht durch Änderungen an fremden Domains.
Ein künftiger Wechsel des PCs benötigt den lokalen privaten Schlüssel aus einem sicheren Backup **oder** einen neu ausgestellten Deploy-Key auf Gitea. Vor der ersten Verbindung den Gitea-SSH-Host-Key erneut mit dem am Server veröffentlichten Schlüssel vergleichen (derzeit ED25519-Fingerabdruck `SHA256:WFM5G2yjTwTlJqF8OmBqx4N2O4/8L35vJ4Qu4zrC/J0` für `[192.168.178.20]:2202`). Die vollständige Verzeichnisstruktur und lokale `.git/config` lassen sich nicht allein aus einem Quellcode-ZIP wiederherstellen.
## Nakama: prüfen und gezielt veröffentlichen
Im Godot-Projektordner:
```powershell
& '.\tools\deploy-nakama.ps1'
& '.\tools\deploy-nakama.ps1' -Apply
```
Ohne `-Apply` läuft zuerst der Server-Simulationstest und dann ein SHA-256-Vergleich; es wird nichts hochgeladen. Mit `-Apply` lädt das Skript nur bei einer Änderung hoch, prüft den Hash, sichert die bisherige Serverdatei mit Zeitstempel, ersetzt sie, startet **nur** den Nakama-Container neu und wartet auf dessen `healthy`-Status. Bei einem Fehler nach der Aktivierung nicht blind erneut starten; zuerst Logs prüfen und bei Bedarf die gemeldete Sicherungsdatei wiederherstellen. Der Unraid-SSH-Zugang ist serverweit mächtig: Pfade und Containername im Skript nicht ohne Prüfung ändern.
Am 10. Oktober 2026 wurde für Alpha 10 das Server-Bundle mit SHA-256 `1b0a0922a1a289d7bb1ccf12269732ea57853eab9f60353757656a7c0fdd0917` aktiviert; Nakama meldete `healthy` und der Zwei-Konten-Test für Freundschaft, Status und Einladung bestand. Die RPCs `create_unlock_code`, `list_unlock_codes` und `invite_friend` sind registriert. Die PHP-Codeverwaltung aus Gitea-Commit `3e6132e` wurde am 9. Oktober manuell auf Netcup bereitgestellt; danach wurde die automatische Website-Bereitstellung eingerichtet und getestet.
## Windows-Release und Homepage
Den Windows-Export mit `packaging/windows/build.ps1` erstellen und die exportierte EXE vor dem Upload mit `--headless --quit` starten. Das Skript benötigt den Godot-Konsolenpfad und optional `-MakeNsisExe` mit der **echten** NSIS-Binärdatei (auf diesem PC `C:\Users\Domin\AppData\Local\Programs\NSIS-3.13\Bin\makensis.exe`; die gleichnamige Datei eine Ebene höher ist nur ein Starter). Produkt-, Installer- und Projektversion gemeinsam erhöhen. Das Skript gibt Größe und SHA-256 des Setups aus.
Im öffentlichen Gitea-Repository `Emporex/Void-Bastion-Releases` zuerst einen **Entwurf** für den neuen Tag erstellen, dann `Void-Bastion-Setup-x64.exe` anhängen. Den hochgeladenen Entwurf mit den über Git Credential Manager gespeicherten Zugangsdaten erneut herunterladen und SHA-256 vergleichen. Erst danach den Entwurf veröffentlichen. Keine Zugangsdaten oder Token in Skripte, Dateien oder Chat kopieren. Der vorhandene Gitea-Token hat `write:repository`, aber kein `read:user`; ein `GET /api/v1/user` kann deshalb trotz funktionierendem Release-Zugriff 403 liefern.
Die Website liest den neuesten **veröffentlichten** Release mit passendem EXE-Asset automatisch über `https://void-bastion.gergames.de/latest-release.php`; der Ingame-Updater liest die Release-Liste. `.../releases/latest` ist zur Alpha-Prüfung ungeeignet, da Gitea Vorabversionen dort nicht berücksichtigt. Nach jeder Veröffentlichung beide tatsächlichen Endpunkte und den öffentlichen Download testen. Alpha 10: `v0.1.0-alpha.10`, Setup SHA-256 `8B86ABC1EBF95252BD5C3C08156B9DD92BE6585A47E9DA323043D6483FED7287`.
## Absicherung und Wiederherstellung
- Keine Zugangsschlüssel in Git, Doku, Chat oder Build-Artefakte kopieren.
- Git-Push über den Website-Deploy-Key und Server-Upload über `unraid-codex` sind bewusst getrennt.
- Für die Website bleiben die geschützte lokale PHP-Konfiguration und die Netcup-Git-Zuordnung auf Netcup; ein Git-Klon allein ist kein vollständiges Hosting-Backup.
- Für Nakama gehören PostgreSQL-Backups und die Server-Konfiguration in das bestehende Unraid-Backup. Das Deploy-Skript sichert nur die bisherige JavaScript-Datei, nicht die Datenbank.
+17
View File
@@ -28,6 +28,10 @@ Nach jeder erfolgreichen Anmeldung meldet der Client Plattform und App-Version a
Die Serverfunktion `private_stats` ist ausschließlich über den geheimen Nakama-HTTP-Schlüssel für die geschützte, nicht verlinkte Statistikseite der Homepage erreichbar. Angemeldete Spielclients können diese Werte nicht abrufen. Der geheime Schlüssel bleibt auf Unraid und im privaten Webhosting-Verzeichnis; er wird weder exportiert noch im Repository gespeichert.
Die geschützte Statistikseite kann außerdem Freischaltcodes für alle Türme, alle Karten oder beides erstellen. `create_unlock_code` ist nur Server-zu-Server mit dem Nakama-HTTP-Schlüssel verfügbar. Codes werden über ihren SHA-256-Schlüssel nachgeschlagen; zusätzlich liegt der Klartextcode für die geschützte Admin-Übersicht in einem privaten Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0`. Das Spiel kann diese Objekte nicht lesen. `list_unlock_codes` liefert Code, Freischaltumfang, optionale Bemerkung, Erstellungszeit und Einlösungszähler ausschließlich für Server-zu-Server-Aufrufe zurück. Die Statistikseite zeigt daraus „Offen“, „Teilweise eingelöst“ oder „Eingelöst“; die Bemerkung ist optional und auf 160 Zeichen begrenzt. Hinweise können z. B. Empfänger beschreiben und sollten keine unnötigen privaten Daten enthalten. Bereits vor dieser Erweiterung erzeugte Codes behalten ihren Status, aber ihr Klartext kann nicht rekonstruiert werden.
Pro Konto gelten höchstens zehn Codeversuche in fünf Minuten. Die Spieler lösen Codes im Menü über `redeem_unlock_code` ein; die Freischaltungen liegen am Onlinekonto und werden nach einer Anmeldung über `get_promo_unlocks` wiederhergestellt. Für die Codeverwaltung müssen sowohl das Nakama-Runtime-Bundle als auch die PHP-Statistikseite aktualisiert werden. Der Nakama-HTTP-Schlüssel bleibt ausschließlich auf Unraid und im privaten Webhosting-Verzeichnis.
Im Hauptmenü ist eine Ranglistenansicht für die aktuell gewählte Karte und Schwierigkeit eingebaut. Sie zeigt Mission und Endlosmodus getrennt, löst aktuelle öffentliche Profilnamen auf und hebt den eigenen Eintrag hervor.
## Multiplayer
@@ -38,6 +42,12 @@ Vor dem Start zeigt eine gemeinsame Lobby beide Pilotennamen und ihre zugewiesen
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.
## Sprache und Freunde (in Entwicklung)
Unter **Einstellungen** kann zwischen Deutsch und Englisch gewechselt werden. Die Wahl liegt in der lokalen Datei `user://void_bastion_settings.cfg` und bleibt nach einem Neustart erhalten. Hauptmenü, Kartenwahl, Koop- und Freundesbereich sowie die wichtigsten Spiel-, Shop- und Ergebnisansichten sind übersetzt. Einzelne ältere Status- und Fehlermeldungen können noch auf Deutsch erscheinen; vor einer internationalen Veröffentlichung ist ein vollständiger Sprachdurchlauf nötig.
Die Freundesliste verwendet die Nakama-Freundschaftsfunktionen: Eine Anfrage wird über die eindeutige Spielerkennung gesendet und muss von der anderen Seite angenommen werden. Nur bestätigte Freunde zeigen Online- bzw. Im-Spiel-Status. Der Status ist grob (`menu` oder `playing`), ohne Karten-, Wellen- oder Standortdaten. Ein privater Raum kann nur vom Raumersteller an bestätigte Freunde eingeladen werden. Die Serverfunktion `invite_friend` prüft Raum und Freundschaft, begrenzt wiederholte Einladungen und sendet eine flüchtige Systembenachrichtigung mit Raumcode. Es gibt keinen Chat und keinen frei formulierten Nachrichtentext. Für diese Funktion müssen Client und Nakama-Runtime gemeinsam auf den neuen Stand gebracht werden; sie ist mit dem lokalen Test allein noch nicht live.
## Adressen
- Produktion: `https://api.void-bastion.gergames.de` und `wss://api.void-bastion.gergames.de`
@@ -50,7 +60,14 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `
## Tests
- Offline-/Gameplaytest: `godot --headless --path . --script res://tests/smoke_test.gd`
- Online-Zustands-/Interpolationstest ohne Server: `godot --headless --path . res://tests/online_snapshot_test.tscn`
- 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, Ä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.
Bei einem stockenden Onlinespiel schreibt der Client alle zehn Sekunden eine Zeile `ONLINE DIAG` ins Godot-Protokoll: Anzahl und maximale Lücke der Zustandspakete, Lücken über 500 ms, größte Paketgröße, maximale JSON-Lese- und Zustandsanwendungszeit. Zusätzliche Zeilen melden Bildaussetzer über 200 ms und WebSocket-Abbruch/Wiederverbindung. Eine große Paketlücke bei normaler Bildrate deutet auf Übertragung oder Server hin; eine lange Bildlücke ohne Paketlücke eher auf den Client. Für eine Fehlermeldung möglichst die Zeilen direkt vor und nach dem Hänger sichern; Spielerkennungen und Zugangsdaten werden darin nicht ausgegeben.
Der Client fasst bei Rückstau maximal fünf eingetroffene Spielstände zusammen: Nur der neueste Zustand wird angewendet, die Schusseffekte dieser wenigen Pakete bleiben erhalten. Ältere, verworfene Zustände sind nur noch historische Zwischenbilder. `coalesced` in `ONLINE DIAG` zählt die dabei übersprungenen Zustände. Weg- und Baufeldgeometrie wird nur beim Karten- bzw. Arenenwechsel neu berechnet. Der lokale Snapshot-Test prüft außerdem, dass Geschosse nach einem langen Bildaussetzer ihr Ziel erreichen und verschwinden.
Der Server meldet mit `ONLINE DIAG tick_gap_ms` verspätete Match-Ticks über 250 ms und mit `ONLINE DIAG loop_ms` eine Verarbeitung über 70 ms. Die Meldungen enthalten nur Ticknummer und Objektanzahl. Wenn beim Client Paketlücken auftreten, aber zur gleichen Zeit keine Tick- oder Loop-Warnung auf dem Server, liegt die Suche als Nächstes bei WebSocket-Verbindung und Reverse Proxy.
+12 -1
View File
@@ -1,6 +1,6 @@
# Void Bastion – Projekt- und Fortsetzungs-Handbuch
Stand: 6. Oktober 2026 · Version: 0.1.0-alpha.4 · Engine: Godot 4.7.2
Stand: 9. Oktober 2026 · Version: 0.1.0-alpha.4 · Engine: Godot 4.7.2
Dieses Dokument ist die zentrale technische Übergabe für die Weiterarbeit an Void Bastion – auch nach einem PC-Wechsel. Es beschreibt den aktuellen Stand, die Projektstruktur und die verbindlichen Gestaltungs- und Datenregeln. Bei Änderungen immer zuerst diese Datei, `README.md`, `ART_ASSETS.md` und die Tests lesen.
@@ -165,6 +165,17 @@ iOS-Export erzeugt ein Xcode-Projekt, erfordert macOS, Xcode, Zertifikate/Provis
Homepage: `https://void-bastion.gergames.de/`. Öffentliche Releases/Setup liegen im separaten Gitea-Releases-Projekt; der Ingame-Updater fragt die Release-API ab und soll automatisch das neueste passende Setup/APK verlinken. Nach einer Veröffentlichung Release-Tag, Version in Godot, Windows Installer, Android versionCode und Release-Assetnamen synchron halten. Keine Websiteänderung ist erforderlich, wenn Release-API/Assets korrekt gepflegt sind.
### Netcup-Dateiübertragung
Der aktuelle, schnellere Git-/SSH-Ablauf für Website und Nakama steht in `DEPLOYMENT-SCHNELLWEG.md`. Diese Datei bei Veröffentlichungen zuerst lesen. Der Website-Quellcode wird lokal per repositorygebundenem SSH-Deploy-Key nach Gitea gepusht; Nakama wird unabhängig davon per SSH gezielt auf Unraid aktualisiert.
- Die Website `void-bastion.gergames.de` liegt im Netcup-Webspace unter `gergames.de/void-bastion`. Änderungen nur in diesem Domain-/Ordnerbereich vornehmen; andere gehostete Seiten sind ausdrücklich außerhalb des Arbeitsumfangs.
- Beim Prüfen des Netcup-Panels am 9. Oktober 2026 zeigte der FTP-Bereich nur das Hauptkonto `hosting143098` mit Basisverzeichnis `/`. Das ist ein webhostingweiter Zugang und darf nicht für automatisierte Void-Bastion-Uploads verwendet werden, weil er auch andere Websites erreichen kann.
- Laut Netcup-Dokumentation lässt sich kein zusätzlicher FTP-Benutzer anlegen; zur Verfügung steht der SSH-Zugang. Ein separater, auf Void Bastion beschränkter FTP-Benutzer existiert daher nicht.
- Für automatisierte Übertragungen zuerst prüfen, ob SFTP über den vorhandenen SSH-Zugang aktiviert ist und ob dessen Startverzeichnis sicher auf den benötigten Webspace begrenzt ist. Bis Host, Port, Authentifizierung und zulässiger Zielpfad praktisch bestätigt sind, nichts automatisiert hochladen.
- Zugangsdaten oder private SSH-Schlüssel niemals in dieses Repository oder diese Anleitung schreiben. Für die Einrichtung nach Möglichkeit einen eigenen SSH-Schlüssel verwenden und nur lokal/geschützt speichern. Das bestehende Git-Deployment bzw. der Netcup-Dateimanager bleiben sichere Alternativen, bis SFTP getestet wurde.
- Passwörter, private Schlüssel und API-Tokens niemals in diese Anleitung, Git oder Projektdateien schreiben. Geheimnisse separat im Passwortmanager bzw. geschützten lokalen Credential Store des Betreibers ablegen. In der Anleitung dürfen nur Host, Protokoll, Port, Benutzername und begrenzter Zielpfad stehen, sofern diese Werte bestätigt wurden.
## Weiterarbeit nach einem PC-Wechsel
1. Backupordner auf neuen PC kopieren und vorzugsweise in einen stabilen Arbeitsordner legen.
+2 -2
View File
@@ -93,7 +93,7 @@ Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path
Für reproduzierbare Windows-Builds steht `packaging/windows/build.ps1` bereit. Godot und NSIS werden als Parameter übergeben; das Skript exportiert das Spiel, erstellt den Installer und gibt anschließend Größe und SHA-256-Prüfsumme aus. Die manuellen Freigabeprüfungen stehen in `ALPHA_TEST_CHECKLIST.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.8.md`.
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.10.md`.
## Werbung und Datenschutz
@@ -157,7 +157,7 @@ Die Schaltfläche **Bestenliste** im Hauptmenü zeigt die zwölf besten Piloten.
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 und direkte Einladungen ergänzen
7. Freundesliste und direkte Einladungen auf zwei echten Clients mit der neuen Server-Runtime prüfen
8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion
## Godot Agent Tools MCP
+16
View File
@@ -0,0 +1,16 @@
# Void Bastion 0.1.0-alpha.10
## Neu in dieser Testversion
- In **Einstellungen** kann zwischen Deutsch und Englisch gewechselt werden. Die Auswahl bleibt nach einem Neustart erhalten. Einzelne ältere Status- und Fehlermeldungen sind noch nicht übersetzt.
- Der Multiplayer-Bereich enthält eine **Freundesliste**. Spieler können ihre eindeutige Kennung kopieren, andere Spieler darüber hinzufügen und Anfragen bestätigen oder zurückziehen.
- Bestätigte Freunde werden als **online**, **im Spiel** oder **offline** angezeigt. Der Status wird nach einer unterbrochenen Echtzeitverbindung wiederhergestellt.
- Ersteller eines privaten Raums können bestätigten Freunden eine direkte Raumeinladung senden. Die Einladung enthält nur einen Raumcode und einen Absendernamen; es gibt keinen Chat und keine frei formulierbaren Nachrichten.
- Einladungen werden vom Server auf Raumhoheit und gegenseitige Freundschaft geprüft und gegen wiederholtes Senden begrenzt.
## Testhinweise
- Die Freundes- und Einladungsfunktionen benötigen die zu dieser Version passende Nakama-Serverlogik. Beide Spieler sollten Alpha 10 verwenden.
- Bitte mit zwei Konten Freundschaftsanfrage, Statuswechsel, Einladung und Beitritt testen. Eine Einladung ist flüchtig und wird nicht als Offline-Nachricht gespeichert.
- Die englischen Texte bitte in Hauptmenü, Shop, Mission und Koop prüfen; noch deutsche Meldungen können gemeldet werden.
- Dies ist weiterhin eine unsignierte Alpha-Testversion. Windows SmartScreen kann vor dem ersten Start warnen.
+15
View File
@@ -0,0 +1,15 @@
# Void Bastion 0.1.0-alpha.9
## Online-Koop-Test
- Partneranzeige und Partner-Umschalter sind in der oberen Leiste neu angeordnet; die Partnerenergie wird rechts angezeigt.
- ESC öffnet auch online das Pausenmenü. Die lokale Partie pausiert; online läuft das gemeinsame Spiel während des Menüs weiter.
- Der Server sendet Spielzustände jetzt mit 10 statt 5 Aktualisierungen pro Sekunde.
- Schüsse werden im Serverzustand mitgesendet und auf beiden Clients als passende Projektil- oder Strahleffekte dargestellt.
- Der Flakturm ist als Luftabwehr in der Serverlogik ergänzt und greift keine Bodeneinheiten an.
- Serverseitige Freischaltcodes sind für Türme, Karten oder beides enthalten; Einlösen ist angemeldeten Spielern vorbehalten und gegen Rateversuche geschützt.
## Testhinweise
- Godot-Headless-Start und die Nakama-Runtime-Simulation müssen vor Veröffentlichung erfolgreich durchlaufen.
- Der Online-Test benötigt zusätzlich das passende Nakama-Runtime-Bundle auf dem Server.
+2 -2
View File
@@ -24,8 +24,8 @@ application/modify_resources=true
application/icon="res://assets/app_icon.png"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0.8"
application/product_version="0.1.0.8"
application/file_version="0.1.0.10"
application/product_version="0.1.0.10"
application/company_name="GerGames"
application/product_name="Void Bastion"
application/file_description="Void Bastion – Alpha-Testversion"
+851 -226
View File
File diff suppressed because it is too large. Load diff
+234 -5
View File
@@ -10,6 +10,8 @@ signal match_found(match_data)
signal match_snapshot_received(snapshot: Dictionary)
signal match_event_received(event: Dictionary)
signal private_room_created(room_code: String)
signal friends_changed()
signal match_invite_received(invite: Dictionary)
const SERVER_KEY := "voidbastion-public-v1"
const PRODUCTION_HOST := "api.void-bastion.gergames.de"
@@ -22,6 +24,7 @@ const OP_UPGRADE := 11
const OP_SELL := 12
const OP_START_WAVE := 13
const OP_READY := 14
const MAX_PENDING_SNAPSHOTS := 5
var client: NakamaClient
var session: NakamaSession
@@ -37,6 +40,18 @@ var intentional_disconnect := false
var reconnect_in_progress := false
var account_manager: AccountManager
var auto_connect_enabled := true
var snapshot_previous_ms := 0
var snapshot_report_ms := 0
var snapshot_count := 0
var snapshot_gap_max_ms := 0
var snapshot_bytes_max := 0
var snapshot_parse_max_us := 0
var snapshot_large_gap_count := 0
var snapshot_coalesced_count := 0
var pending_snapshot_payloads: Array[String] = []
var friends: Array[Dictionary] = []
var friend_statuses: Dictionary = {}
var presence_status := ""
func _ready() -> void:
@@ -46,6 +61,35 @@ func _ready() -> void:
call_deferred("connect_and_authenticate")
func _process(_delta: float) -> void:
_flush_pending_snapshots()
func _flush_pending_snapshots() -> void:
if pending_snapshot_payloads.is_empty():
return
var payloads := pending_snapshot_payloads.duplicate()
pending_snapshot_payloads.clear()
var latest: Dictionary = {}
var recent_shots: Array = []
for payload in payloads:
var parse_start_us := Time.get_ticks_usec()
var parsed = JSON.parse_string(payload)
snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us)
if not parsed is Dictionary:
continue
latest = parsed
var shots = parsed.get("shots", [])
if shots is Array:
recent_shots.append_array(shots)
if latest.is_empty():
return
if payloads.size() > 1:
latest["shots"] = recent_shots
snapshot_coalesced_count += payloads.size() - 1
match_snapshot_received.emit(latest)
func _is_headless_or_editor() -> bool:
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
@@ -96,6 +140,8 @@ func _after_authentication() -> void:
else:
intentional_disconnect = false
_set_connection_state("online")
var parent_game = get_parent()
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu")
authenticated.emit(session.user_id, session.username)
await flush_pending_scores()
@@ -113,16 +159,22 @@ func _record_activity() -> void:
func _bind_socket_signals() -> void:
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
socket.received_match_state.connect(_on_match_state)
socket.received_status_presence.connect(_on_status_presence)
socket.received_notification.connect(_on_notification)
socket.closed.connect(_on_socket_closed)
func disconnect_from_server() -> void:
intentional_disconnect = true
pending_snapshot_payloads.clear()
if socket:
socket.close()
socket = null
session = null
current_match = null
friends.clear()
friend_statuses.clear()
presence_status = ""
matchmaker_ticket = ""
private_room_code = ""
lobby_ready = false
@@ -190,6 +242,124 @@ func delete_online_account() -> Dictionary:
return {"accepted": true}
func redeem_unlock_code(code: String) -> Dictionary:
if not is_authenticated():
return {"accepted": false, "error": "offline"}
var result = await client.rpc_async(session, "redeem_unlock_code", JSON.stringify({"code": code}))
var parsed := _parse_rpc_result(result)
if parsed.is_empty():
return {"accepted": false, "error": "invalid"}
return parsed
func get_promo_unlocks() -> Dictionary:
if not is_authenticated():
return {}
var result = await client.rpc_async(session, "get_promo_unlocks", "{}")
var parsed := _parse_rpc_result(result)
return parsed if bool(parsed.get("accepted", false)) else {}
func set_presence_status(value: String) -> void:
if not is_online() or value == presence_status:
return
var safe_value := "playing" if value == "playing" else "menu"
var result = await socket.update_status_async(safe_value)
if result != null and not result.is_exception():
presence_status = safe_value
func refresh_friends() -> bool:
if not is_authenticated():
return false
var result = await client.list_friends_async(session, null, 100)
if result == null or result.is_exception():
return false
var rows: Array[Dictionary] = []
var follow_ids := PackedStringArray()
for item in result.friends:
if item.user == null:
continue
var user_id: String = item.user.id
var state: int = item.state
rows.append({"id": user_id, "username": item.user.username,
"name": item.user.display_name if not item.user.display_name.is_empty() else item.user.username,
"state": state, "online": item.user.online})
if state == 0:
follow_ids.append(user_id)
friends = rows
friend_statuses.clear()
if is_online() and not follow_ids.is_empty():
var statuses = await socket.follow_users_async(follow_ids, PackedStringArray())
if statuses != null and not statuses.is_exception():
for presence in statuses.presences:
friend_statuses[presence.user_id] = presence.status
friends_changed.emit()
return true
func add_friend_by_username(username: String) -> bool:
var normalized := username.strip_edges()
if not is_authenticated() or normalized.is_empty() or normalized == session.username:
return false
var result = await client.add_friends_async(session, null, PackedStringArray([normalized]))
if result == null or result.is_exception():
return false
await refresh_friends()
return true
func accept_friend(user_id: String) -> bool:
if not is_authenticated() or user_id.is_empty():
return false
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
if result == null or result.is_exception():
return false
await refresh_friends()
return true
func remove_friend(user_id: String) -> bool:
if not is_authenticated() or user_id.is_empty():
return false
var result = await client.delete_friends_async(session, PackedStringArray([user_id]))
if result == null or result.is_exception():
return false
friend_statuses.erase(user_id)
await refresh_friends()
return true
func invite_friend(user_id: String, room_code: String) -> bool:
if not is_authenticated() or user_id.is_empty() or room_code.length() != 6:
return false
var result = await client.rpc_async(session, "invite_friend", JSON.stringify({"friend_id": user_id, "room_code": room_code}))
return bool(_parse_rpc_result(result).get("accepted", false))
func _on_status_presence(event) -> void:
for presence in event.joins:
friend_statuses[presence.user_id] = presence.status
for presence in event.leaves:
friend_statuses.erase(presence.user_id)
friends_changed.emit()
func _on_notification(notification) -> void:
if notification.code in [-2, -3]:
refresh_friends()
return
if notification.code != 101:
return
var content = JSON.parse_string(notification.content)
if not content is Dictionary:
return
var code := String(content.get("room_code", ""))
if code.length() != 6:
return
match_invite_received.emit({"room_code": code, "from": String(content.get("from", "Mitspieler"))})
func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array:
if not is_authenticated():
return []
@@ -245,6 +415,7 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
func cancel_matchmaking() -> void:
intentional_disconnect = true
pending_snapshot_payloads.clear()
if socket and not matchmaker_ticket.is_empty():
await socket.remove_matchmaker_async(matchmaker_ticket)
matchmaker_ticket = ""
@@ -257,6 +428,7 @@ func cancel_matchmaking() -> void:
func create_private_room(difficulty := 1) -> bool:
intentional_disconnect = false
pending_snapshot_payloads.clear()
if not is_online() or socket == null:
matchmaking_state_changed.emit("offline")
return false
@@ -284,6 +456,7 @@ func create_private_room(difficulty := 1) -> bool:
func join_private_room(room_code: String) -> bool:
intentional_disconnect = false
pending_snapshot_payloads.clear()
if not is_online() or socket == null:
matchmaking_state_changed.emit("offline")
return false
@@ -310,6 +483,7 @@ func join_private_room(room_code: String) -> bool:
func _on_matchmaker_matched(matched) -> void:
intentional_disconnect = false
pending_snapshot_payloads.clear()
matchmaker_ticket = ""
current_match = await socket.join_matched_async(matched)
if current_match == null or current_match.is_exception():
@@ -351,6 +525,7 @@ func request_wave_start() -> bool:
func leave_current_match() -> void:
pending_snapshot_payloads.clear()
if socket and current_match != null:
await socket.leave_match_async(current_match.match_id)
current_match = null
@@ -361,16 +536,44 @@ func leave_current_match() -> void:
func _on_match_state(data) -> void:
if current_match == null or data.match_id != current_match.match_id:
return
var parsed = JSON.parse_string(data.data)
if not parsed is Dictionary:
return
if data.op_code == OP_SNAPSHOT:
match_snapshot_received.emit(parsed)
var now_ms := Time.get_ticks_msec()
if snapshot_previous_ms > 0:
var gap_ms := now_ms - snapshot_previous_ms
snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms)
if gap_ms > 500:
snapshot_large_gap_count += 1
snapshot_previous_ms = now_ms
snapshot_count += 1
snapshot_bytes_max = maxi(snapshot_bytes_max, String(data.data).to_utf8_buffer().size())
pending_snapshot_payloads.append(String(data.data))
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
pending_snapshot_payloads.pop_front()
snapshot_coalesced_count += 1
if snapshot_report_ms == 0:
snapshot_report_ms = now_ms
elif now_ms - snapshot_report_ms >= 10000:
print("ONLINE DIAG snapshots=%d coalesced=%d max_gap_ms=%d gaps_over_500ms=%d max_bytes=%d max_parse_us=%d" % [snapshot_count, snapshot_coalesced_count, snapshot_gap_max_ms, snapshot_large_gap_count, snapshot_bytes_max, snapshot_parse_max_us])
snapshot_report_ms = now_ms
snapshot_count = 0
snapshot_coalesced_count = 0
snapshot_gap_max_ms = 0
snapshot_large_gap_count = 0
snapshot_bytes_max = 0
snapshot_parse_max_us = 0
elif data.op_code == OP_EVENT:
match_event_received.emit(parsed)
var parsed = JSON.parse_string(data.data)
if parsed is Dictionary:
match_event_received.emit(parsed)
func _on_socket_closed() -> void:
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
pending_snapshot_payloads.clear()
presence_status = ""
friend_statuses.clear()
friends_changed.emit()
snapshot_previous_ms = 0
var reconnect_match_id := ""
if not intentional_disconnect and current_match != null:
reconnect_match_id = String(current_match.match_id)
@@ -381,6 +584,28 @@ func _on_socket_closed() -> void:
reconnect_in_progress = true
matchmaking_state_changed.emit("reconnecting")
_recover_match.call_deferred(reconnect_match_id)
elif not intentional_disconnect and reconnect_match_id.is_empty() and not reconnect_in_progress and is_authenticated():
reconnect_in_progress = true
_recover_presence.call_deferred()
func _recover_presence() -> 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
reconnect_in_progress = false
_set_connection_state("online")
var parent_game = get_parent()
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu")
await refresh_friends()
return
reconnect_in_progress = false
func _recover_match(match_id: String) -> void:
@@ -395,13 +620,17 @@ func _recover_match(match_id: String) -> void:
continue
var rejoined = await socket.join_match_async(match_id)
if rejoined != null and not rejoined.is_exception():
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
current_match = rejoined
intentional_disconnect = false
reconnect_in_progress = false
_set_connection_state("online")
var parent_game = get_parent()
await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over"] else "menu")
matchmaking_state_changed.emit("reconnected")
return
reconnect_in_progress = false
print("ONLINE DIAG websocket_reconnect_failed")
matchmaking_state_changed.emit("connection_lost")
+1
View File
@@ -15,6 +15,7 @@ if (-not [string]::IsNullOrWhiteSpace($MakeNsisExe)) {
$nsisCandidates += $pathNsis.Source
}
$nsisCandidates += @(
(Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\Bin\makensis.exe"),
(Join-Path $env:LOCALAPPDATA "Programs\NSIS-3.13\makensis.exe"),
"C:\Program Files (x86)\NSIS\makensis.exe",
"C:\Program Files\NSIS\makensis.exe"
+2 -2
View File
@@ -6,7 +6,7 @@ RequestExecutionLevel user
!include "LogicLib.nsh"
!define PRODUCT_NAME "Void Bastion"
!define PRODUCT_VERSION "0.1.0-alpha.8"
!define PRODUCT_VERSION "0.1.0-alpha.10"
!define PRODUCT_PUBLISHER "GerGames"
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
!define PRODUCT_EXE "Void-Bastion.exe"
@@ -21,7 +21,7 @@ BrandingText "GerGames"
ShowInstDetails show
ShowUninstDetails show
VIProductVersion "0.1.0.8"
VIProductVersion "0.1.0.10"
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
+1 -1
View File
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Void Bastion"
config/version="0.1.0-alpha.8"
config/version="0.1.0-alpha.10"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7")
config/icon="res://assets/app_icon.png"
+6 -1
View File
@@ -28,9 +28,14 @@ Für eine öffentliche Testversion wird Port 7350 später über Nginx Proxy Mana
Der Stack verwendet fest versioniertes Nakama 3.37.0 und PostgreSQL 16 Alpine. Vor einem Versionswechsel werden Datenbank und Nakama-Appdata gesichert und die offiziellen Migrationshinweise geprüft.
## Freunde und Einladungen
Freundschaftsanfragen, bestätigte Freundschaften und Online-Präsenz verwenden die eingebauten Nakama-Funktionen. Die app-eigene RPC `invite_friend` nimmt ausschließlich die Freundes-ID und einen sechsstelligen privaten Raumcode entgegen. Sie prüft, dass der Absender den Raum erstellt hat und eine gegenseitige Freundschaft besteht. Je Empfänger gilt ein Mindestabstand von 15 Sekunden. Die Benachrichtigung ist nicht persistent und enthält nur Absendername und Raumcode; ein Chat oder frei eingebbarer Einladungstext existiert nicht. Die Funktion muss zusammen mit dem aktualisierten Godot-Client ausgerollt werden.
## Private Statistik-RPCs
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
- `create_unlock_code`, `list_unlock_codes` und `delete_unlock_code`: nur Server-zu-Server mit `NAKAMA_HTTP_KEY`; verwalten private Freischaltcodes inklusive Einlösungsstatus und optionaler Admin-Bemerkung. Löschen ist nur bei null Einlösungen möglich; eine versionsgebundene Löschung schützt vor einer gleichzeitigen Einlösung.
Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
Der Code-Lookup verwendet einen SHA-256-Schlüssel. Für die private Admin-Übersicht wird der Klartextcode zusätzlich in einem Nakama-Speicherobjekt mit `permissionRead:0` und `permissionWrite:0` gehalten; Spielclients können dieses Objekt nicht lesen. Bemerkungen sind auf 160 Zeichen beschränkt. Die echte Webhosting-Konfiguration mit dem HTTP-Schlüssel darf nicht in Git abgelegt werden.
+198 -7
View File
@@ -1,4 +1,4 @@
var GAME_VERSION = "0.23";
var GAME_VERSION = "0.25";
var MAP_COUNT = 7;
var DIFFICULTY_COUNT = 3;
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
@@ -8,10 +8,14 @@ var ACTIVITY_COLLECTION = "player_activity";
var ACTIVITY_KEY = "summary";
var STATS_COLLECTION = "internal_stats";
var STATS_KEY = "global";
var PROMO_CODE_COLLECTION = "unlock_codes";
var PROMO_UNLOCK_COLLECTION = "player_unlocks";
var PROMO_UNLOCK_KEY = "website_codes";
var PROMO_ATTEMPT_COLLECTION = "unlock_attempts";
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
var OP_SNAPSHOT = 1, OP_EVENT = 2, OP_BUILD = 10, OP_UPGRADE = 11, OP_SELL = 12, OP_START_WAVE = 13, OP_READY = 14;
var TICK_RATE = 10, SNAPSHOT_TICKS = 2, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758;
var TICK_RATE = 10, SNAPSHOT_TICKS = 1, GRID = 80, WORLD_WIDTH = 2960, MIRROR_X = 1480;
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 84, PLAYFIELD_BOTTOM = 758;
var LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
var RIGHT_PATH = [];
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
@@ -22,7 +26,8 @@ var TOWERS = [
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70}
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70},
{cost:210,damage:20,range:230,cooldown:0.42,targetClass:"air"}
];
var ENEMIES = [
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
@@ -140,6 +145,137 @@ function rpcPrivateStats(ctx, logger, nk, payload) {
var stats = record && record.value ? record.value : emptyStats(Date.now());
return JSON.stringify(stats);
}
function rpcCreateUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var reward = String(request.reward || "");
if (["all_towers", "all_maps", "all"].indexOf(reward) < 0) throw new Error("invalid unlock reward");
var maxUses = clampInteger(request.max_uses === undefined ? 1 : request.max_uses, 1, 50000);
var note = String(request.note || "").trim();
if (note.length > 160) throw new Error("note exceeds 160 characters");
for (var attempt = 0; attempt < 5; attempt++) {
var code = nk.uuidv4().replace(/-/g, "").substring(0, 24).toUpperCase();
var codeKey = nk.sha256Hash(code);
if (readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID)) continue;
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:{code:code,reward:reward,note:note,max_uses:maxUses,uses:0,redeemed_users:[],created_at:Date.now()},permissionRead:0,permissionWrite:0,version:"*"}]);
return JSON.stringify({accepted:true,code:code,reward:reward,note:note,max_uses:maxUses,uses:0,status:"open"});
}
throw new Error("could not allocate unique unlock code");
}
function rpcListUnlockCodes(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var codes = [], cursor = "", pages = 0;
do {
var result = nk.storageList(SYSTEM_USER_ID, PROMO_CODE_COLLECTION, 100, cursor);
var objects = Array.isArray(result) ? result : (result && Array.isArray(result.objects) ? result.objects : []);
for (var i = 0; i < objects.length; i++) {
var object = objects[i], value = object && object.value ? object.value : {};
var uses = Math.max(0, Math.floor(Number(value.uses || 0)));
var maxUses = Math.max(1, Math.floor(Number(value.max_uses || 1)));
codes.push({
code:String(value.code || ""),
code_key:String(object && object.key ? object.key : ""),
code_id:String(object && object.key ? object.key : "").slice(-8),
reward:String(value.reward || ""),
note:String(value.note || ""),
uses:uses,
max_uses:maxUses,
created_at:Number(value.created_at || 0),
status:uses >= maxUses ? "redeemed" : (uses > 0 ? "partial" : "open")
});
}
cursor = result && !Array.isArray(result) ? String(result.cursor || "") : "";
pages++;
} while (cursor && pages < 10);
codes.sort(function(a, b) { return b.created_at - a.created_at; });
return JSON.stringify({codes:codes,truncated:!!cursor});
}
function rpcDeleteUnlockCode(ctx, logger, nk, payload) {
if (ctx.userId) throw new Error("server-to-server access required");
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { throw new Error("invalid JSON payload"); }
var codeKey = String(request.code_key || "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(codeKey)) return JSON.stringify({accepted:false,error:"invalid"});
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"not_found"});
var value = record.value;
if (Number(value.uses || 0) !== 0 || (Array.isArray(value.redeemed_users) && value.redeemed_users.length > 0)) {
return JSON.stringify({accepted:false,error:"already_used"});
}
if (!record.version) throw new Error("unlock code has no storage version");
try {
nk.storageDelete([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,version:record.version}]);
return JSON.stringify({accepted:true});
} catch (error) {
if (attempt === 2) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function allowPromoAttempt(ctx, nk) {
var now = Date.now(), key = "user_" + ctx.userId;
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_ATTEMPT_COLLECTION, key, SYSTEM_USER_ID);
var value = record && record.value ? record.value : {window_started:now,count:0};
if (now - Number(value.window_started || 0) >= 5 * 60 * 1000) value = {window_started:now,count:0};
if (Number(value.count || 0) >= 10) return false;
value.count = Number(value.count || 0) + 1;
var write = {collection:PROMO_ATTEMPT_COLLECTION,key:key,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return true; }
catch (error) { if (attempt === 2) return false; }
}
return false;
}
function writePromoUnlocks(nk, userId, reward) {
for (var attempt = 0; attempt < 3; attempt++) {
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
if (reward === "all_towers" || reward === "all") value.all_towers = true;
if (reward === "all_maps" || reward === "all") value.all_maps = true;
var write = {collection:PROMO_UNLOCK_COLLECTION,key:PROMO_UNLOCK_KEY,userId:userId,value:value,permissionRead:0,permissionWrite:0,version:record && record.version ? record.version : "*"};
try { nk.storageWrite([write]); return value; }
catch (error) { if (attempt === 2) throw error; }
}
throw new Error("could not save player unlocks");
}
function rpcRedeemUnlockCode(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
if (!allowPromoAttempt(ctx, nk)) return JSON.stringify({accepted:false,error:"rate_limited"});
var request;
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({accepted:false,error:"invalid"}); }
var code = String(request.code || "").toUpperCase().replace(/[\s-]/g, "");
if (!/^[A-F0-9]{24}$/.test(code)) return JSON.stringify({accepted:false,error:"invalid"});
var codeKey = nk.sha256Hash(code);
for (var attempt = 0; attempt < 4; attempt++) {
var record = readStorageRecord(nk, PROMO_CODE_COLLECTION, codeKey, SYSTEM_USER_ID);
if (!record || !record.value) return JSON.stringify({accepted:false,error:"invalid"});
var value = record.value;
var redeemed = Array.isArray(value.redeemed_users) ? value.redeemed_users : [];
if (redeemed.indexOf(ctx.userId) >= 0) {
var existingUnlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,already_redeemed:true,reward:value.reward,all_towers:!!existingUnlocks.all_towers,all_maps:!!existingUnlocks.all_maps});
}
if (Number(value.uses || 0) >= Number(value.max_uses || 1)) return JSON.stringify({accepted:false,error:"exhausted"});
value.redeemed_users = redeemed.concat([ctx.userId]);
value.uses = Number(value.uses || 0) + 1;
try {
nk.storageWrite([{collection:PROMO_CODE_COLLECTION,key:codeKey,userId:SYSTEM_USER_ID,value:value,permissionRead:0,permissionWrite:0,version:record.version}]);
var unlocks = writePromoUnlocks(nk, ctx.userId, String(value.reward || ""));
return JSON.stringify({accepted:true,reward:value.reward,all_towers:!!unlocks.all_towers,all_maps:!!unlocks.all_maps});
} catch (error) {
if (attempt === 3) throw error;
}
}
return JSON.stringify({accepted:false,error:"temporary"});
}
function rpcGetPromoUnlocks(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var record = readStorageRecord(nk, PROMO_UNLOCK_COLLECTION, PROMO_UNLOCK_KEY, ctx.userId);
var value = record && record.value ? record.value : {all_towers:false,all_maps:false};
return JSON.stringify({accepted:true,all_towers:!!value.all_towers,all_maps:!!value.all_maps});
}
function rpcSubmitRun(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("authentication required");
var run;
@@ -233,6 +369,40 @@ function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
}
function rpcInviteFriend(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 friendId = String(request.friend_id || ""), code = normalizeRoomCode(request.room_code);
if (!/^[0-9a-f-]{36}$/i.test(friendId) || code.length !== 6 || friendId === ctx.userId)
return JSON.stringify({accepted:false,error:"invalid_invite"});
var room = privateRoomRecord(nk, code);
if (!room || !room.value || room.value.host_user_id !== ctx.userId)
return JSON.stringify({accepted:false,error:"not_room_host"});
var mutual = false, cursor = "";
for (var page = 0; page < 10 && !mutual; page++) {
var result = nk.friendsList(ctx.userId, 100, 0, cursor);
var list = Array.isArray(result) ? result : (result && Array.isArray(result.friends) ? result.friends : []);
for (var i = 0; i < list.length; i++) {
if (list[i].state === 0 && list[i].user && String(list[i].user.userId || list[i].user.id || "") === friendId) { mutual = true; break; }
}
cursor = result && result.cursor ? result.cursor : "";
if (!cursor) break;
}
if (!mutual) return JSON.stringify({accepted:false,error:"not_mutual_friends"});
var rateKey = "invite_" + friendId;
var previous = readStorageRecord(nk, "friend_invite_limits", rateKey, ctx.userId);
var now = Date.now();
if (previous && previous.value && now - Number(previous.value.sent_at || 0) < 15000)
return JSON.stringify({accepted:false,error:"invite_cooldown"});
var senderName = String(ctx.username || "Pilot");
var senders = nk.usersGetId([ctx.userId]);
if (senders.length && senders[0].displayName) senderName = String(senders[0].displayName);
nk.storageWrite([{collection:"friend_invite_limits",key:rateKey,userId:ctx.userId,value:{sent_at:now},permissionRead:0,permissionWrite:0}]);
nk.notificationsSend([{userId:friendId,subject:"Void Bastion Invite",content:{room_code:code,from:senderName.slice(0,32)},code:101,senderId:ctx.userId,persistent:false}]);
return JSON.stringify({accepted:true});
}
function matchmakerMatched(ctx, logger, nk, matches) {
var difficulty = 1;
if (matches.length && matches[0].properties && matches[0].properties.difficulty !== undefined) difficulty = Math.max(0,Math.min(2,Math.floor(Number(matches[0].properties.difficulty))));
@@ -241,7 +411,7 @@ 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:{},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,waveSchedule:[],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,waveSchedule:[],spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],shots:[],lastSnapshotTick:0,lastLoopStartMs: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)}; }
@@ -257,9 +427,15 @@ function snapshot(state,tick) {
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
lobby[slot]={user_id:id,slot:slot,name:String(player.name||"PILOT"),ready:!!state.ready[id],connected:state.presences[id]!==undefined};
}
return {version:GAME_VERSION, tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:state.towers,enemies:state.enemies};
var towers=state.towers.map(function(tower) {
return {id:tower.id,x:tower.x,y:tower.y,type:tower.type,level:tower.level,owner:tower.owner,angle:Math.round(tower.angle*1000)/1000};
});
var enemies=state.enemies.map(function(enemy) {
return {id:enemy.id,type:enemy.type,x:Math.round(enemy.x*10)/10,y:Math.round(enemy.y*10)/10,path_index:enemy.path_index,route_id:enemy.route_id,hp:Math.round(enemy.hp*10)/10,max_hp:Math.round(enemy.max_hp*10)/10,angle:Math.round(enemy.angle*1000)/1000,boss:enemy.boss,shield:enemy.shield,shield_hp:Math.round(enemy.shield_hp*10)/10,max_shield_hp:Math.round(enemy.max_shield_hp*10)/10};
});
return {version:GAME_VERSION,tick:tick,started:state.started,connection_interrupted:state.pausedForReconnect,reconnect_seconds:state.pausedForReconnect?Math.max(0,Math.ceil((state.reconnectDeadlineTick-tick)/TICK_RATE)):0,game_over:state.gameOver,difficulty:state.difficulty,slots:state.slots,players:lobby,credits:state.credits,reactor_lives:state.reactorLives,wave:state.wave,wave_active:state.waveActive,enemies_to_spawn:state.spawnRemaining,wave_types_remaining:state.waveSchedule.map(function(entry){return entry.type;}),towers:towers,enemies:enemies,shots:state.shots||[]};
}
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.lastSnapshotTick=tick; }
function broadcastSnapshot(dispatcher,state,tick) { dispatcher.broadcastMessage(OP_SNAPSHOT,JSON.stringify(snapshot(state,tick)),null,null,true); state.shots=[]; state.lastSnapshotTick=tick; }
function broadcastEvent(dispatcher,type,data) { var payload=data||{}; payload.type=type; dispatcher.broadcastMessage(OP_EVENT,JSON.stringify(payload),null,null,true); }
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
var rejoined=false;
@@ -386,6 +562,7 @@ function updateEnemies(state,delta) {
}
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
function updateTowers(state,delta) {
state.shots=state.shots||[];
for (var i=0;i<state.towers.length;i++) {
var t=state.towers[i]; t.cooldown-=delta; var range=towerRange(t),targetIndex=-1,best=-1,bestShield=false;
for (var j=0;j<state.enemies.length;j++) {
@@ -398,6 +575,7 @@ function updateTowers(state,delta) {
if (targetIndex<0) continue;
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5].indexOf(t.type)>=0?Math.PI:0);
if (t.cooldown>0) continue;
state.shots.push({tower_id:t.id,type:t.type,from_x:t.x,from_y:t.y,to_x:target.x,to_y:target.y});
var damage=towerDamage(t)*(TOWERS[t.type].targetClass==="both"?TOWERS[t.type].bothScale:1);
if (t.type===5) {
var aimLength=Math.sqrt(distanceSquared(t.x,t.y,target.x,target.y)),dx=(target.x-t.x)/aimLength,dy=(target.y-t.y)/aimLength,rangeSquared=range*range;
@@ -444,6 +622,11 @@ function handleCommand(nk,dispatcher,state,message) {
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
}
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
var loopStartMs=Date.now();
if (state.lastLoopStartMs && loopStartMs-state.lastLoopStartMs>250 && logger.warn)
logger.warn("ONLINE DIAG tick_gap_ms=%d tick=%d",loopStartMs-state.lastLoopStartMs,tick);
state.lastLoopStartMs=loopStartMs;
state.shots=[];
if (!Object.keys(state.presences).length) {
state.emptyTicks += 1;
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
@@ -452,6 +635,8 @@ function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
if (state.pausedForReconnect&&tick>=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);
var loopMs=Date.now()-loopStartMs;
if (loopMs>70 && logger.warn) logger.warn("ONLINE DIAG loop_ms=%d tick=%d enemies=%d towers=%d",loopMs,tick,state.enemies.length,state.towers.length);
return {state:state};
}
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
@@ -462,11 +647,17 @@ function InitModule(ctx,logger,nk,initializer) {
initializer.registerRpc("server_info",rpcServerInfo);
initializer.registerRpc("record_activity",rpcRecordActivity);
initializer.registerRpc("private_stats",rpcPrivateStats);
initializer.registerRpc("create_unlock_code",rpcCreateUnlockCode);
initializer.registerRpc("list_unlock_codes",rpcListUnlockCodes);
initializer.registerRpc("delete_unlock_code",rpcDeleteUnlockCode);
initializer.registerRpc("redeem_unlock_code",rpcRedeemUnlockCode);
initializer.registerRpc("get_promo_unlocks",rpcGetPromoUnlocks);
initializer.registerRpc("submit_run",rpcSubmitRun);
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
initializer.registerRpc("delete_account",rpcDeleteAccount);
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
initializer.registerRpc("invite_friend",rpcInviteFriend);
initializer.registerMatchmakerMatched(matchmakerMatched);
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
+134
View File
@@ -2,6 +2,7 @@ const assert = require("assert");
const fs = require("fs");
const vm = require("vm");
const path = require("path");
const crypto = require("crypto");
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
const context = { console, Math, Date, JSON, isFinite };
@@ -9,8 +10,10 @@ vm.createContext(context);
vm.runInContext(source, context);
assert.strictEqual(context.ENEMIES.length, 9);
assert.strictEqual(context.SNAPSHOT_TICKS, 1, "Online snapshots should be sent at the full 10 Hz simulation rate.");
assert.strictEqual(context.ENEMIES[0].category, "air");
assert.strictEqual(context.ENEMIES[6].category, "ground");
assert.strictEqual(context.TOWERS.length, 7);
assert.strictEqual(context.TOWERS[0].targetClass, "ground");
assert.strictEqual(context.TOWERS[1].targetClass, "both");
assert.ok(context.TOWERS[1].bothScale < 1, "The all-purpose laser should trade damage for shield-breaking ground coverage.");
@@ -18,6 +21,48 @@ assert.strictEqual(context.TOWERS[3].targetClass, "both");
assert.ok(context.TOWERS[3].bothScale < 1, "Universal towers should trade damage for flexibility.");
assert.strictEqual(context.TOWERS[4].damage, 20, "EMP damage should be tuned down to about 20.");
assert.strictEqual(context.TOWERS[4].targetClass, "both", "EMP should cover both air and ground units.");
assert.strictEqual(context.TOWERS[6].targetClass, "air", "The Flak tower must only target air units.");
const hostId = "11111111-1111-1111-1111-111111111111";
const friendId = "22222222-2222-2222-2222-222222222222";
const sentInvites = [];
let inviteLimitRecord = null;
const inviteNk = {
storageRead(requests) {
return requests[0].collection === "private_rooms"
? [{ value: { host_user_id: hostId, match_id: "private-match" } }]
: requests[0].collection === "friend_invite_limits" && inviteLimitRecord ? [inviteLimitRecord] : [];
},
storageWrite(items) {
if (items[0].collection === "friend_invite_limits") inviteLimitRecord = { value: items[0].value };
},
usersGetId() { return [{ displayName: "Commander" }]; },
friendsList() { return { friends: [{ state: 0, user: { userId: friendId } }], cursor: "" }; },
notificationsSend(items) { sentInvites.push(...items); }
};
const inviteCtx = { userId: hostId, username: "pilot_host" };
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, true);
assert.strictEqual(sentInvites.length, 1);
assert.strictEqual(sentInvites[0].content.room_code, "ABC123");
assert.strictEqual(sentInvites[0].content.from, "Commander");
assert.strictEqual(sentInvites[0].persistent, false, "Game invitations must not remain as stale offline messages.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, inviteNk, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).error, "invite_cooldown", "Repeated invitations must be rate limited.");
assert.strictEqual(sentInvites.length, 1, "A rate-limited invitation must not send another notification.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend({ userId: friendId }, {}, inviteNk, JSON.stringify({ friend_id: hostId, room_code: "ABC123" }))).accepted, false, "Only the private room host may invite.");
assert.strictEqual(JSON.parse(context.rpcInviteFriend(inviteCtx, {}, { ...inviteNk, friendsList: () => ({ friends: [] }) }, JSON.stringify({ friend_id: friendId, room_code: "ABC123" }))).accepted, false, "Only mutual friends may be invited.");
const flakState = {
towers: [{ id: 20, type: 6, x: 800, y: 200, owner: 0, level: 1, cooldown: 0 }],
enemies: [
{ id: 401, type: 0, x: 850, y: 200, route_id: 0, path_index: 1, hp: 100, shield: false },
{ id: 402, type: 6, x: 850, y: 210, route_id: 0, path_index: 1, hp: 100, shield: false }
],
credits: [0, 0]
};
context.updateTowers(flakState, 1);
assert.ok(flakState.enemies.find((enemy) => enemy.id === 401).hp < 100, "Flak should damage air units.");
assert.strictEqual(flakState.enemies.find((enemy) => enemy.id === 402).hp, 100, "Flak must ignore ground units.");
assert.strictEqual(flakState.shots.length, 1, "Firing towers should publish a visual shot event.");
assert.strictEqual(flakState.shots[0].tower_id, 20);
assert.strictEqual(flakState.shots[0].type, 6);
const shieldTest = { hp: 100, shield: true, shield_hp: 30, max_shield_hp: 30, shield_resistance: 0.28 };
context.applyDamageToEnemy(shieldTest, 20, 1);
assert.strictEqual(shieldTest.hp, 100, "Laser damage should drain shield before hull.");
@@ -123,6 +168,89 @@ assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
const promoStorage = new Map();
let promoVersion = 0;
let promoCodeSequence = 0;
let beforePromoDelete = null;
const promoNk = {
uuidv4() {
promoCodeSequence += 1;
return `${String(promoCodeSequence).padStart(8, "0")}-0000-4000-8000-000000000001`;
},
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
storageRead(ops) {
return ops.map(op => promoStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean);
},
storageWrite(ops) {
for (const op of ops) {
const key = `${op.collection}:${op.userId}:${op.key}`;
const previous = promoStorage.get(key);
if (op.version === "*" && previous) throw new Error("version conflict");
if (op.version && op.version !== "*" && (!previous || previous.version !== op.version)) throw new Error("version conflict");
promoVersion += 1;
promoStorage.set(key, { collection: op.collection, userId: op.userId, key: op.key, value: JSON.parse(JSON.stringify(op.value)), version: String(promoVersion) });
}
},
storageList(userId, collection, limit, cursor) {
const objects = Array.from(promoStorage.values())
.filter(record => record.userId === userId && record.collection === collection)
.slice(0, limit)
.map(record => ({ key: record.key, value: JSON.parse(JSON.stringify(record.value)) }));
return { objects, cursor: "" };
},
storageDelete(ops) {
for (const op of ops) {
const key = `${op.collection}:${op.userId}:${op.key}`;
if (beforePromoDelete) beforePromoDelete(key);
const previous = promoStorage.get(key);
if (!previous || op.version !== previous.version) throw new Error("version conflict");
promoStorage.delete(key);
}
}
};
assert.throws(() => context.rpcCreateUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 1 })), /server-to-server/);
assert.throws(() => context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", note: "x".repeat(161) })), /160 characters/);
const createdPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps", max_uses: 2, note: "Für den Multiplayer-Test" })));
assert.strictEqual(createdPromo.accepted, true);
assert.strictEqual(createdPromo.code.length, 24);
assert.strictEqual(createdPromo.note, "Für den Multiplayer-Test");
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "open");
assert.throws(() => context.rpcListUnlockCodes({ userId: "pilot-a" }, logger, promoNk, "{}"), /server-to-server/);
const redeemedPromo = JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code })));
assert.strictEqual(redeemedPromo.accepted, true);
assert.strictEqual(redeemedPromo.all_maps, true);
assert.strictEqual(redeemedPromo.all_towers, false);
const partiallyRedeemed = JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0];
assert.strictEqual(partiallyRedeemed.status, "partial");
assert.strictEqual(partiallyRedeemed.uses, 1);
assert.strictEqual(partiallyRedeemed.code, createdPromo.code);
assert.strictEqual(partiallyRedeemed.note, "Für den Multiplayer-Test");
assert.strictEqual(JSON.parse(context.rpcGetPromoUnlocks({ userId: "pilot-promo-a" }, logger, promoNk, "{}")).all_maps, true);
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-a" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).already_redeemed, true);
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-b" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).accepted, true);
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes[0].status, "redeemed");
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-c" }, logger, promoNk, JSON.stringify({ code: createdPromo.code }))).error, "exhausted");
const usedCodeKey = promoNk.sha256Hash(createdPromo.code);
assert.throws(() => context.rpcDeleteUnlockCode({ userId: "pilot-a" }, logger, promoNk, JSON.stringify({ code_key: usedCodeKey })), /server-to-server/);
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: usedCodeKey }))).error, "already_used");
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: "bad-key" }))).error, "invalid");
const unusedPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_towers" })));
const unusedCodeKey = promoNk.sha256Hash(unusedPromo.code);
assert.strictEqual(JSON.parse(context.rpcListUnlockCodes({}, logger, promoNk, "{}")).codes.find(entry => entry.code === unusedPromo.code).code_key, unusedCodeKey);
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).accepted, true);
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: unusedCodeKey }))).error, "not_found");
assert.strictEqual(JSON.parse(context.rpcRedeemUnlockCode({ userId: "pilot-promo-d" }, logger, promoNk, JSON.stringify({ code: unusedPromo.code }))).error, "invalid");
const racingPromo = JSON.parse(context.rpcCreateUnlockCode({}, logger, promoNk, JSON.stringify({ reward: "all_maps" })));
const racingCodeKey = promoNk.sha256Hash(racingPromo.code);
beforePromoDelete = key => {
beforePromoDelete = null;
const previous = promoStorage.get(key);
promoVersion += 1;
promoStorage.set(key, { ...previous, version: String(promoVersion), value: { ...previous.value, uses: 1, redeemed_users: ["pilot-race"] } });
};
assert.strictEqual(JSON.parse(context.rpcDeleteUnlockCode({}, logger, promoNk, JSON.stringify({ code_key: racingCodeKey }))).error, "already_used");
assert.ok(promoStorage.has(`unlock_codes:${context.SYSTEM_USER_ID}:${racingCodeKey}`));
let leaderboardWrite = null;
const leaderboardNk = {
accountGetId() { return { user: { displayName: "Kommandant Ägir" } }; },
@@ -194,6 +322,12 @@ assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category ===
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));
const compactSnapshot = context.snapshot(state, 29);
const untrimmedSnapshot = { ...compactSnapshot, towers: state.towers, enemies: state.enemies };
assert.ok(JSON.stringify(compactSnapshot).length < JSON.stringify(untrimmedSnapshot).length, "Frequent snapshots should omit simulation-only properties.");
assert.ok(compactSnapshot.towers.every(tower => !Object.hasOwn(tower, "cooldown")));
assert.ok(compactSnapshot.enemies.every(enemy => !Object.hasOwn(enemy, "shield_resistance") && !Object.hasOwn(enemy, "reward")));
assert.ok(state.towers.every(tower => Object.hasOwn(tower, "cooldown")), "Network serialization must not alter simulation state.");
const towerA = state.towers.find(tower => tower.owner === 0);
state = context.matchLoop({}, logger, nk, dispatcher, 30, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;
+24 -5
View File
@@ -32,7 +32,7 @@ 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.23", "Der Server muss die aktuelle Runtime 0.23 geladen haben: %s" % info_data)
assert(info_data.get("version") == "0.25", "Der Server muss die aktuelle Runtime 0.25 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_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
@@ -51,13 +51,13 @@ func _run() -> void:
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 run_payload := JSON.stringify({"map": 1, "difficulty": 1, "mode": "mission", "wave": 4, "score": 4700, "client_version": "0.25-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)
assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", [session_a.user_id], null, 10)
assert(not records.is_exception() and (records.records.size() >= 1 or records.owner_records.size() >= 1), "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
var own_record = null
for record in records.records:
for record in records.owner_records:
if record.owner_id == session_a.user_id:
own_record = record
break
@@ -67,6 +67,17 @@ func _run() -> void:
var socket_b: NakamaSocket = nakama.create_socket_from(client_b)
assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception(), "Spieler A benötigt eine Echtzeitverbindung.")
assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception(), "Spieler B benötigt eine Echtzeitverbindung.")
var friend_request = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username]))
assert(not friend_request.is_exception(), "Spieler A muss eine Freundschaftsanfrage senden können.")
var friend_accept = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id]))
assert(not friend_accept.is_exception(), "Spieler B muss die Freundschaftsanfrage bestätigen können.")
var friend_list = await client_a.list_friends_async(session_a, 0, 100)
assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id,
"Die bestätigte Freundschaft muss beim Gastgeber sichtbar sein.")
var received_invites: Array = []
socket_b.received_notification.connect(func(note):
if int(note.code) == 101:
received_invites.append(JSON.parse_string(note.content)))
socket_a.received_matchmaker_matched.connect(func(value): matched_a = value)
socket_b.received_matchmaker_matched.connect(func(value): matched_b = value)
socket_a.received_match_state.connect(func(value): _capture_snapshot(value, true))
@@ -124,6 +135,14 @@ func _run() -> void:
assert(room_code.length() == 6 and not private_match_id.is_empty(), "Der Server muss einen sechsstelligen Raumcode und eine Partie liefern.")
var private_match_a = await socket_a.join_match_async(private_match_id)
assert(not private_match_a.is_exception(), "Der Gastgeber muss seinem privaten Raum beitreten können.")
var invite = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": room_code}))
assert(not invite.is_exception() and bool(JSON.parse_string(invite.payload).get("accepted", false)), "Der Gastgeber muss einen bestätigten Freund einladen können.")
for attempt in 30:
if not received_invites.is_empty():
break
await create_timer(0.05).timeout
assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == room_code,
"Die Einladung muss als Echtzeitbenachrichtigung mit dem richtigen Raumcode ankommen.")
var found_room = await client_b.rpc_async(session_b, "join_private_room", JSON.stringify({"room_code": room_code.to_lower()}))
assert(not found_room.is_exception(), "Der Mitspieler muss den Raumcode unabhängig von Groß-/Kleinschreibung finden können.")
var found_data = JSON.parse_string(found_room.payload)
+83
View File
@@ -0,0 +1,83 @@
extends Node
func _ready() -> void:
call_deferred("_run")
func _run() -> void:
var game = load("res://main.tscn").instantiate()
get_tree().root.add_child(game)
await get_tree().process_frame
game.ui_language = "en"
assert(game._ui("FREUNDESLISTE") == "FRIENDS")
assert(game._ui("WELLE") == "WAVE")
game.ui_language = "de"
assert(game._ui("FREUNDESLISTE") == "FREUNDESLISTE")
game._on_match_invite_received({"room_code": "ABC123", "from": "Testpilot"})
assert(game.pending_match_invite.room_code == "ABC123")
game.pending_match_invite.clear()
game.friend_return_mode = "matchmaking"
game.multiplayer_manager.matchmaking_mode = "private_host"
game.multiplayer_manager.matchmaking_state = "hosting"
game.multiplayer_manager.private_room_code = "ABC123"
assert(game._can_invite_friends())
game.multiplayer_manager.matchmaking_state = "offline"
assert(not game._can_invite_friends(), "Ein alter Raumcode darf keine Einladung mehr ermöglichen.")
game.coop_online_mode = true
var base_snapshot := {
"difficulty": 1, "reactor_lives": 30, "wave": 1, "wave_active": true,
"enemies_to_spawn": 0, "wave_types_remaining": [], "towers": [],
"enemies": [{"id": 4, "type": 0, "x": 100.0, "y": 200.0, "hp": 40.0,
"max_hp": 40.0, "shield": false, "boss": false}], "shots": []
}
game._apply_online_snapshot(base_snapshot)
assert(game.enemies.size() == 1)
assert(game.enemies[0].pos == Vector2(100, 200))
var first_enemy: Dictionary = game.enemies[0]
var next_snapshot: Dictionary = base_snapshot.duplicate(true)
next_snapshot["enemies"][0]["x"] = 200.0
next_snapshot["enemies"][0]["hp"] = 25.0
game._apply_online_snapshot(next_snapshot)
assert(is_same(first_enemy, game.enemies[0]), "Gegnerdaten sollen zwischen Paketen wiederverwendet werden.")
game._update_online_interpolation(0.05)
assert(game.enemies[0].pos.x > 100.0 and game.enemies[0].pos.x < 200.0,
"Die Position soll zwischen zwei Serverständen gleichmäßig übergehen.")
game._update_online_interpolation(0.1)
assert(is_equal_approx(game.enemies[0].pos.x, 200.0))
game.online_projectiles.append({"pos": Vector2.ZERO, "target": Vector2(30, 0), "speed": 920.0, "color": Color.WHITE})
game._update_online_projectiles(0.1)
assert(game.online_projectiles.is_empty(), "Geschosse dürfen bei einem langen Frame nicht am Ziel vorbeipendeln.")
var online_manager: OnlineManager = game.get_node("OnlineManager")
var received: Array = []
online_manager.match_snapshot_received.connect(func(snapshot: Dictionary) -> void: received.append(snapshot))
online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 1, "shots": [{"id": 1}]}))
online_manager.pending_snapshot_payloads.append(JSON.stringify({"tick": 2, "shots": [{"id": 2}]}))
online_manager._flush_pending_snapshots()
assert(received.size() == 1 and received[0].tick == 2, "Nur der neueste Spielstand darf angewendet werden.")
assert(received[0].shots.size() == 2, "Schusseffekte aus zusammengefassten Spielständen müssen erhalten bleiben.")
received.clear()
online_manager.current_match = {"match_id": "test-match"}
for tick in 8:
online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT,
"data": JSON.stringify({"tick": tick, "shots": [{"id": tick}]})})
assert(online_manager.pending_snapshot_payloads.size() == online_manager.MAX_PENDING_SNAPSHOTS,
"Die Warteschlange muss auch nach einem längeren Rückstau begrenzt bleiben.")
online_manager._flush_pending_snapshots()
assert(received.size() == 1 and received[0].tick == 7)
assert(received[0].shots.size() == online_manager.MAX_PENDING_SNAPSHOTS)
game._select_map(0)
game._prepare_draw_geometry()
assert(not game.draw_geometry_dirty and not game.road_tile_cache.is_empty())
assert(not game.build_cell_cache.is_empty())
game._select_map(1)
assert(game.draw_geometry_dirty, "Ein Kartenwechsel muss die Weggeometrie erneuern.")
game._prepare_draw_geometry()
assert(not game.draw_geometry_dirty)
game.coop_mode = true
game._configure_coop_arena()
game._prepare_draw_geometry()
var partner_cells: Array = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
assert(not partner_cells.is_empty(), "Die gespiegelte Koop-Seite braucht eigene Baufelder.")
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://cg5nghnbri5bo
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/online_snapshot_test.gd" id="1_test"]
[node name="OnlineSnapshotTest" type="Node"]
script = ExtResource("1_test")
+16 -13
View File
@@ -39,8 +39,8 @@ func _run() -> void:
assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.")
assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
game.set("unlocked_maps", [0, 1, 2])
assert(game.get("TOWER_DATA").size() == 6, "Das Arsenal muss sechs unterschiedliche Turmtypen enthalten.")
assert(game.get("tower_buttons").size() == 6, "Alle sechs Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.")
assert(game.get("TOWER_DATA").size() == 7, "Das Arsenal muss sieben unterschiedliche Turmtypen enthalten.")
assert(game.get("tower_buttons").size() == 7, "Alle sieben Türme müssen gleichzeitig in der unteren Baumenüleiste sichtbar sein.")
game.call("_handle_pointer", Vector2(760, 815), false)
assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.")
await process_frame
@@ -75,7 +75,8 @@ func _run() -> void:
assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.")
game.set("unlocked_towers", [0, 1, 2])
game.set("game_mode", "playing")
game.call("_handle_pointer", game.get("tower_buttons")[3].get_center(), false)
var locked_tower_slot: int = game.call("_tower_display_order").find(3)
game.call("_handle_pointer", game.get("tower_buttons")[locked_tower_slot].get_center(), false)
assert(game.get("game_mode") == "store", "Ein gesperrter Turm muss den Shop auch aus einer laufenden Mission öffnen.")
game.call("_handle_pointer", game.get("shop_back_button").get_center(), false)
assert(game.get("game_mode") == "playing", "Der Shop-Zurück-Button muss in die laufende Mission zurückführen.")
@@ -141,10 +142,11 @@ func _run() -> void:
assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.")
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.")
assert(game.call("_app_version") == "0.1.0-alpha.8", "Profil, Installer und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var current_version: String = str(ProjectSettings.get_setting("application/config/version", ""))
assert(game.call("_app_version") == current_version and not current_version.is_empty(), "Profil und Online-Übertragung müssen dieselbe Alpha-Version verwenden.")
var support_url: String = game.call("_support_email_url")
assert(support_url.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.")
assert(support_url.contains("0.1.0-alpha.8") and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
assert(support_url.contains(current_version) and support_url.contains(account_manager.short_id()), "Die Support-Mail muss Version und gekürzte Spieler-ID zur Fehlerzuordnung enthalten.")
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.")
@@ -163,9 +165,9 @@ func _run() -> void:
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.6"), "Die installierte Version darf nicht als Update angeboten werden.")
var announced_update := {}
update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true))
var fake_release_body := JSON.stringify([{"tag_name": "v0.1.0-alpha.9", "name": "Alpha.9", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
var fake_release_body := JSON.stringify([{"tag_name": "v9.0.0", "name": "Future test release", "draft": false, "assets": [{"name": "Void-Bastion-Setup-x64.exe", "browser_download_url": "https://git.dominicspringer.de/public-test.exe"}]}]).to_utf8_buffer()
update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body)
assert(announced_update.get("version") == "0.1.0-alpha.9", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(announced_update.get("version") == "9.0.0", "Die Update-Prüfung muss eine neuere öffentliche Version ankündigen.")
assert(str(announced_update.get("url", "")).begins_with("https://git.dominicspringer.de/"), "Der Update-Hinweis darf nur einen öffentlichen Download-Link öffnen.")
assert(game.get("path_points").size() == 9, "Bastion Prime sollte mit einer eigenen Einfahrt im Reaktor enden.")
_assert_grid_aligned_path(game.get("path_points"), "Bastion Prime")
@@ -228,16 +230,17 @@ func _run() -> void:
game.set("selected_difficulty", 0)
game.call("_start_game")
assert(game.get("credits") == 450 and game.get("reactor_lives") == 20, "Leicht muss dem bisherigen Balancing entsprechen.")
game.call("_handle_pointer", Vector2(1180, 45), false)
game.call("_handle_pointer", game.get("speed_button").get_center(), false)
assert(game.get("speed_dropdown_open"), "Die Spielgeschwindigkeit muss über eine Auswahl bedienbar sein.")
game.call("_handle_pointer", Vector2(1190, 198), false)
game.call("_handle_pointer", game.call("_speed_option_rect", 4).get_center(), false)
assert(is_equal_approx(game.get("game_speed"), 5.0), "Die Geschwindigkeitsauswahl muss bis 5× reichen.")
game.set("game_speed", 1.0)
game.call("_handle_pointer", Vector2(1330, 82), false)
game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false)
assert(game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox aktivierbar sein.")
game.call("_handle_pointer", Vector2(1330, 82), false)
game.call("_handle_pointer", game.get("auto_wave_button").get_center(), false)
assert(not game.get("auto_start_waves"), "Der automatische Wellenstart muss per Checkbox abschaltbar sein.")
game.call("_handle_pointer", Vector2(1110, 45), false)
game.call("_handle_pointer", game.get("pause_button").get_center(), false)
game.call("_handle_pointer", game.get("pause_exit_button").get_center(), false)
assert(game.get("game_mode") == "menu", "Der Zurück-Button muss eine laufende Runde abbrechen und zum Kartenmenü führen.")
assert(game.get("towers").is_empty() and game.get("enemies").is_empty(), "Beim Abbruch müssen alle Einheiten der laufenden Runde entfernt werden.")
game.call("_start_game")
@@ -279,7 +282,7 @@ func _run() -> void:
assert(game.get("TOWER_DATA")[0].target_class == "ground" and game.get("TOWER_DATA")[1].target_class == "both", "Der Laser muss neben Luft- auch Bodenziele angreifen können.")
assert(game.get("TOWER_DATA")[1].both_damage_scale < 1.0 and game.get("TOWER_DATA")[3].both_damage_scale < 1.0 and game.get("TOWER_DATA")[5].both_damage_scale < 1.0, "Laser, Plasma und Railgun müssen für ihre Mehrzweckfähigkeit einen Schadensmultiplikator erhalten.")
assert(game.get("TOWER_DATA")[4].damage == 20.0 and game.get("TOWER_DATA")[4].target_class == "both", "EMP muss Luft- und Bodenziele mit rund 20 Schaden erfassen.")
assert(game.call("_tower_display_order") == [0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_display_order") == [6, 0, 2, 1, 3, 4, 5], "Die Turmauswahl muss zuerst Luft-, dann Boden- und anschließend Mehrzwecktürme gruppieren.")
assert(game.call("_tower_class_color", 0).g > game.call("_tower_class_color", 0).r and game.call("_tower_class_color", 1).b > game.call("_tower_class_color", 1).r, "Bodenfelder müssen grün und Mehrzweckfelder blau-cyan eingefärbt sein.")
game.get("enemies").clear()
var rail_front := {"id": 96001, "type": 0, "pos": Vector2(400, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
+68
View File
@@ -0,0 +1,68 @@
extends SceneTree
var received_invites: Array = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var nakama = root.get_node("Nakama")
var host := OS.get_environment("VOID_BASTION_TEST_HOST")
var scheme := OS.get_environment("VOID_BASTION_TEST_SCHEME")
var port_text := OS.get_environment("VOID_BASTION_TEST_PORT")
if host.is_empty():
host = "192.168.178.20"
if scheme.is_empty():
scheme = "http"
var port := int(port_text) if not port_text.is_empty() else 7350
var client_a: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
var client_b: NakamaClient = nakama.create_client("voidbastion-public-v1", host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
var suffix := str(Time.get_ticks_usec())
var session_a: NakamaSession = await client_a.authenticate_device_async("social_test_a_" + suffix, "social_a_" + suffix, true)
var session_b: NakamaSession = await client_b.authenticate_device_async("social_test_b_" + suffix, "social_b_" + suffix, true)
assert(not session_a.is_exception() and not session_b.is_exception(), "Beide Testkonten müssen sich anmelden können.")
var socket_a: NakamaSocket = nakama.create_socket_from(client_a)
var socket_b: NakamaSocket = nakama.create_socket_from(client_b)
assert(not (await socket_a.connect_async(session_a, true, 5)).is_exception())
assert(not (await socket_b.connect_async(session_b, true, 5)).is_exception())
socket_b.received_notification.connect(func(note):
if int(note.code) == 101:
received_invites.append(JSON.parse_string(note.content)))
var requested = await client_a.add_friends_async(session_a, null, PackedStringArray([session_b.username]))
assert(not requested.is_exception(), "Eine Freundschaftsanfrage muss gesendet werden können.")
var accepted = await client_b.add_friends_async(session_b, PackedStringArray([session_a.user_id]))
assert(not accepted.is_exception(), "Die Anfrage muss angenommen werden können.")
var friend_list = await client_a.list_friends_async(session_a, 0, 100)
assert(not friend_list.is_exception() and friend_list.friends.size() == 1 and friend_list.friends[0].user.id == session_b.user_id,
"Die gegenseitige Freundschaft muss auf dem Server gespeichert sein.")
var followed = await socket_a.follow_users_async(PackedStringArray([session_b.user_id]), PackedStringArray())
assert(not followed.is_exception(), "Der Freundesstatus muss abonnierbar sein.")
assert(not (await socket_b.update_status_async("playing")).is_exception(), "Der Im-Spiel-Status muss gesetzt werden können.")
var created = await client_a.rpc_async(session_a, "create_private_room", JSON.stringify({"difficulty": 1}))
assert(not created.is_exception(), "Ein privater Raum muss erstellbar sein.")
var room: Dictionary = JSON.parse_string(created.payload)
var code := String(room.get("room_code", ""))
assert(code.length() == 6)
var joined = await socket_a.join_match_async(String(room.get("match_id", "")))
assert(not joined.is_exception(), "Der Gastgeber muss den privaten Raum betreten können.")
var invited = await client_a.rpc_async(session_a, "invite_friend", JSON.stringify({"friend_id": session_b.user_id, "room_code": code}))
assert(not invited.is_exception() and bool(JSON.parse_string(invited.payload).get("accepted", false)),
"Der Gastgeber muss einen bestätigten Freund einladen können.")
for attempt in 40:
if not received_invites.is_empty():
break
await create_timer(0.05).timeout
assert(not received_invites.is_empty() and received_invites[0] is Dictionary and String(received_invites[0].get("room_code", "")) == code,
"Die Echtzeit-Einladung muss den Raumcode enthalten.")
var unauthorized = await client_b.rpc_async(session_b, "invite_friend", JSON.stringify({"friend_id": session_a.user_id, "room_code": code}))
assert(not unauthorized.is_exception() and not bool(JSON.parse_string(unauthorized.payload).get("accepted", true)),
"Ein Mitspieler ohne Gastgeberrolle darf keine Einladung versenden.")
socket_a.close()
socket_b.close()
var deleted_a = await client_a.rpc_async(session_a, "delete_account", "{}")
var deleted_b = await client_b.rpc_async(session_b, "delete_account", "{}")
assert(not deleted_a.is_exception() and not deleted_b.is_exception(), "Die Testkonten müssen entfernt werden können.")
print("VOID_BASTION_SOCIAL_INTEGRATION_OK")
quit()
+62
View File
@@ -0,0 +1,62 @@
param(
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$bundle = (Resolve-Path (Join-Path $PSScriptRoot '..\server\nakama\runtime\build\index.js')).Path
$test = (Resolve-Path (Join-Path $PSScriptRoot '..\server\nakama\tests\runtime_sim_test.js')).Path
$remoteFile = '/mnt/user/appdata/void-bastion-online/nakama/modules/build/index.js'
$remoteHost = 'unraid-codex'
function Assert-LastExit([string]$step) {
if ($LASTEXITCODE -ne 0) { throw "$step fehlgeschlagen (Exitcode $LASTEXITCODE)." }
}
& node $test
Assert-LastExit 'Nakama-Simulationstest'
$localHash = (Get-FileHash -LiteralPath $bundle -Algorithm SHA256).Hash.ToLowerInvariant()
$remoteHashLine = & ssh -o BatchMode=yes $remoteHost "sha256sum $remoteFile"
Assert-LastExit 'Server-Hashprüfung'
$remoteHash = ($remoteHashLine -split '\s+')[0].ToLowerInvariant()
Write-Output "Lokal: $localHash"
Write-Output "Unraid: $remoteHash"
if ($localHash -eq $remoteHash) {
Write-Output 'Nakama ist bereits auf dem aktuellen Stand.'
exit 0
}
if (-not $Apply) {
Write-Output 'Nur geprüft. Zum Veröffentlichen erneut mit -Apply ausführen.'
exit 0
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$upload = "$remoteFile.upload-$stamp"
$backup = "$remoteFile.pre-$stamp"
& scp -o BatchMode=yes $bundle "${remoteHost}:$upload"
Assert-LastExit 'Upload'
$uploadedHashLine = & ssh -o BatchMode=yes $remoteHost "sha256sum $upload"
Assert-LastExit 'Upload-Hashprüfung'
if (($uploadedHashLine -split '\s+')[0].ToLowerInvariant() -ne $localHash) {
throw 'Upload-Hash stimmt nicht überein. Aktive Serverdatei wurde nicht verändert.'
}
& ssh -o BatchMode=yes $remoteHost "cp -p $remoteFile $backup && mv $upload $remoteFile && docker restart void-bastion-nakama"
Assert-LastExit 'Sicherung, Aktivierung oder Neustart'
$healthy = $false
for ($attempt = 1; $attempt -le 15; $attempt++) {
Start-Sleep -Seconds 2
$status = & ssh -o BatchMode=yes $remoteHost 'docker inspect -f "{{.State.Health.Status}}" void-bastion-nakama'
if ($LASTEXITCODE -eq 0 -and $status.Trim() -eq 'healthy') {
$healthy = $true
break
}
}
if (-not $healthy) {
throw "Nakama ist nach dem Neustart nicht healthy. Sicherung auf Unraid: $backup. Nicht automatisch zurückgerollt."
}
Write-Output "Nakama läuft healthy. Wiederherstellbare Sicherung: $backup"