Compare commits
14
Commits
118061b8d7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1294000348 | ||
|
|
8777f28595 | ||
|
|
a536b698f7 | ||
|
|
026d0bad58 | ||
|
|
fc4fcd40a4 | ||
|
|
39ed4c3bf7 | ||
|
|
67abdf13b0 | ||
|
|
4d9e6d9185 | ||
|
|
d53467baf2 | ||
|
|
7d5cad7277 | ||
|
|
acb7301262 | ||
|
|
9b0eab4273 | ||
|
|
e63e08ddde | ||
|
|
56077e88a5 |
No files matched your search
+64
-5
@@ -1,6 +1,6 @@
|
|||||||
# Void Bastion – Alpha-Testcheckliste
|
# Void Bastion – Alpha-Testcheckliste
|
||||||
|
|
||||||
Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet. Automatische Prüfungen sind mit **A**, manuelle Sicht- und Bedienprüfungen mit **M** gekennzeichnet.
|
Diese Liste wird fortlaufend erweitert und vor jeder öffentlichen Version abgearbeitet. Automatische Prüfungen sind mit **A**, manuelle Sicht- und Bedienprüfungen mit **M** gekennzeichnet. Neue Wünsche werden hier ergänzt; Punkte werden erst nach erfolgreicher Prüfung abgehakt.
|
||||||
|
|
||||||
## Build und Installation
|
## Build und Installation
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet
|
|||||||
## Darstellung und Bedienung
|
## Darstellung und Bedienung
|
||||||
|
|
||||||
- [ ] **M** 1920 × 1080, 2560 × 1440 und eine kleinere 16:9-Auflösung prüfen: Karte und UI bleiben scharf und vollständig sichtbar.
|
- [ ] **M** 1920 × 1080, 2560 × 1440 und eine kleinere 16:9-Auflösung prüfen: Karte und UI bleiben scharf und vollständig sichtbar.
|
||||||
- [ ] **M** Alle drei Karten prüfen: Straße, Gegnerlaufweg, Raster und Bausperren stimmen exakt überein.
|
- [ ] **M** Alle sieben Karten prüfen: Straße, Gegnerlaufweg, Raster und Bausperren stimmen exakt überein.
|
||||||
- [ ] **M** Reaktoren und freie Weltraumbereiche sind auf allen Karten unbebaubar.
|
- [ ] **M** Reaktoren und freie Weltraumbereiche sind auf allen Karten unbebaubar.
|
||||||
- [ ] **M** Reichweitenvorschau erscheint vor dem Bau; Turmwerte und Upgrade-Mehrwert sind lesbar.
|
- [ ] **M** Reichweitenvorschau erscheint vor dem Bau; Turmwerte und Upgrade-Mehrwert sind lesbar.
|
||||||
- [ ] **M** Zurück-Schaltfläche bricht eine laufende Runde verständlich ab.
|
- [ ] **M** Zurück-Schaltfläche bricht eine laufende Runde verständlich ab.
|
||||||
@@ -38,9 +38,68 @@ Diese Liste wird vor jeder öffentlichen Windows-Alpha vollständig abgearbeitet
|
|||||||
- [ ] **M** Kurzen Netzabbruch während einer Onlinepartie und die Wiederverbindung prüfen.
|
- [ ] **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.
|
- [ ] **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
|
## Veröffentlichung
|
||||||
|
|
||||||
- [ ] **M** Setup-Datei und SHA-256-Prüfsumme stimmen mit der öffentlichen Release-Datei überein.
|
- [x] **A** Alpha-11-Setup-Datei und SHA-256-Prüfsumme stimmen mit dem erneut heruntergeladenen Gitea-Release-Asset überein.
|
||||||
- [ ] **M** Download-Schaltfläche auf `https://void-bastion.gergames.de/` lädt die aktuelle Setup-Datei.
|
- [x] **A** Der Homepage-Release-Endpunkt zeigt automatisch auf Alpha 11; öffentlicher Download ist erreichbar.
|
||||||
- [ ] **M** Datenschutz, Impressum, Supportadresse und Kontolöschseite sind öffentlich erreichbar.
|
- [ ] **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-11-Release-Notizen.
|
||||||
|
|
||||||
|
## Neue offene Punkte (Stand 06.10.2026)
|
||||||
|
|
||||||
|
- [ ] **M** Android-APK auf einem echten Android-Handy installieren und Start, Touch-Steuerung, verschiedene Bildschirmformate, App-Wechsel/Fortsetzen und Performance prüfen. Bisheriger erfolgreicher Start war nur im Emulator.
|
||||||
|
- [ ] **A/M** Android-Audiofehler beheben: Laufzeit-generierte Audioeffekte sind auf Android derzeit deaktiviert; importierte Audioassets einsetzen und auf echtem Gerät prüfen.
|
||||||
|
- [ ] **A/M** Android-AdMob-Einbindung prüfen: Beim Test wurde das native AdMob-Modul nicht gefunden; Testanzeigen erst nach erfolgreicher Integration und Einwilligungsprüfung aktivieren.
|
||||||
|
- [ ] **A** Android-Release-Pipeline erstellen: signierter Release-Build/AAB, sichere Ablage des Signierschlüssels und fortlaufender `versionCode`. Export-Presets stehen jetzt auf Code 2; das vorhandene APK ist weiterhin der alte Debug-Testbuild mit Code 1. Export des neuen APK wartet auf den Java-SDK-Pfad in Godot.
|
||||||
|
- [ ] **M** Schwierigkeit neu balancieren: aktuell schafft der Entwickler alle Karten auf allen Schwierigkeitsstufen problemlos. Schwerpunkt ist **Schwer**, ohne Leicht/Mittel unfair zu machen.
|
||||||
|
- [ ] **A/M** Weitere Gegnerschiffe mit klar unterscheidbaren Rollen, visueller Silhouette und passenden Kontern entwerfen, implementieren und gegen alle sechs Türme testen.
|
||||||
|
- [ ] **M** Online-Koop mit zwei echten Geräten vollständig spielen; Emulator-/Integrationstests ersetzen diesen Test nicht.
|
||||||
|
- [ ] **M** Datenschutzhinweise, Store-Datenerklärungen und Kontolöschung vor einer öffentlichen Store-Veröffentlichung final prüfen.
|
||||||
|
|
||||||
|
## Abarbeitung am 06.10.2026
|
||||||
|
|
||||||
|
- [x] Drei zusätzliche Gegnertypen mit eigener Grafik und Daten angelegt: schneller Abfangjäger, dauerhafter Schildkreuzer und schwerer Belagerer.
|
||||||
|
- [x] Gegnertypen kommen wellenabhängig ins Spiel; auf Schwer erscheinen sie früher (Abfangjäger ab Welle 3, Schildkreuzer ab 5, Belagerer ab 8). Bosswellen verwenden den Belagerer.
|
||||||
|
- [x] Schwer startet jetzt mit 340 Energie und 12 Reaktorleben; die Belohnung beträgt 12 Kernkristalle. Gegnerzahl und Gegnerhülle ziehen ab der Missionsmitte zusätzlich an.
|
||||||
|
- [x] Automatisierter Godot-Smoke-Test besteht (`VOID_BASTION_SMOKE_TEST_OK`); Windows-Testexport wurde erstellt.
|
||||||
|
- [x] Nakama-Laufzeitsimulation besteht (`VOID_BASTION_SERVER_SIM_OK`).
|
||||||
|
- [ ] **A** Windows-Testexport war erfolgreich, meldete aber `app_icon.png` als ungültige Icon-Datei; vor dem nächsten Installer-/Store-Build das Iconformat prüfen und reparieren.
|
||||||
|
- [ ] Manuelle Spieltests für Schwierigkeitsbalance und alle neuen Gegner bleiben offen; die automatisierten Tests beweisen noch nicht, dass Schwer in echten Läufen fair und fordernd ist.
|
||||||
|
- [ ] Android-Testbuild mit den neuen Änderungen noch nicht exportiert: Godot meldet, dass `export/android/java_sdk_path` leer ist. Das JDK ist unter `C:\Program Files\Android\Android Studio\jbr` vorhanden; in Godot unter Editor-Einstellungen > Export > Android als Java-SDK-Pfad setzen, dann Export erneut ausführen. Das vorhandene Emulator-APK ist noch älter; ein echter Android-Gerätetest steht ebenfalls aus.
|
||||||
|
|
||||||
|
## Gegnervielfalt und Balance
|
||||||
|
|
||||||
|
- [x] **A** Drei Bodeneinheiten ergänzt: schneller Rover, Sturmpanzer und Belagerungsläufer; Werte enthalten Lebenspunkte, Tempo, Reaktorschaden und Belohnung.
|
||||||
|
- [x] **A** Türme besitzen Luft-/Boden-Zielklassen; Plasma und Railgun treffen beide Kategorien mit reduziertem Schaden.
|
||||||
|
- [x] **A** Aktuelle und nächste Welle werden im HUD mit Luft-/Bodensymbolen, kleinen Einheitengrafiken und Anzahlen angezeigt.
|
||||||
|
- [x] **A** Die Vorschau zeigt die Gesamtzahl der Schiffe und lesbare Stückzahlen neben den Gegnerminiaturen.
|
||||||
|
- [x] **A** Lasertreffer bauen ein separates Schild-Lebenskonto ab; der blaue Schutzring zeigt den verbleibenden Anteil gegen den Uhrzeigersinn an und Überschussschaden trifft die Hülle.
|
||||||
|
- [x] **A** Lokaler Entwicklungsmultiplikator für Gegner-Lebenspunkte (0,5×–3,0×) ist über F9 und die +/- Schaltflächen einstellbar; Online-Koop bleibt davon unabhängig.
|
||||||
|
- [x] **A** Entwicklermodus kann den Kristallstand in 25er-Schritten erhöhen oder senken und speichert den Teststand.
|
||||||
|
- [ ] **M** Neue Luft- und Bodentypen im laufenden Spiel visuell prüfen: Silhouetten, Trefferreaktionen, Flug-/Bodenbewegung und Zielauswahl der Türme.
|
||||||
|
- [ ] **M** Die neuen leuchtend grünen Bodenfahrzeuge im Spiel und in der Wellenvorschau auf Lesbarkeit bei kleiner Darstellung prüfen.
|
||||||
|
- [ ] **M** Turmprioritäten erste/letzte/MEISTE HP/SCHILD testen; Laser muss bei mehreren Zielen immer ein erreichbares Schildziel vor ungeschützten Zielen wählen.
|
||||||
|
- [ ] **M** Bei einem Schildgegner den Laser einsetzen und prüfen, dass der blaue Ring sichtbar gegen den Uhrzeigersinn schrumpft und erst danach der überschüssige Schaden die Hülle trifft.
|
||||||
|
- [ ] **M** Pro Gegnertyp Konter und Werte im Spiel prüfen: Lebenspunkte, Tempo, Schild/Resistenz, Reaktor-Schaden, Belohnung und passende Türme.
|
||||||
|
- [ ] **M** Alle sieben Karten auf Leicht, Mittel und Schwer vollständig testen und Schwierigkeit, Ressourcen, Gegnerzusammensetzung sowie Sieg-/Niederlagenquote protokollieren.
|
||||||
|
- [ ] **M** Schwer muss spürbar fordernd sein, aber mit sinnvoller Strategie schaffbar; Leicht und Mittel bleiben fair abgestuft.
|
||||||
|
- [ ] **A/M** Wellenmischung je Schwierigkeit testen; Schwierigkeit soll nicht allein durch mehr Lebenspunkte entstehen.
|
||||||
|
- [ ] **M** Testläufe mit Siegquote, Reaktorleben und verwendeten Türmen festhalten; Balance iterativ nach den Ergebnissen anpassen.
|
||||||
|
- [ ] **M** Gegner-Stärkemultiplikator für Entwicklung verwenden, Testwert notieren und vor dem Release auf 1,0× zurücksetzen.
|
||||||
|
|
||||||
|
## Android-Test und Store-Vorbereitung
|
||||||
|
|
||||||
|
- [ ] **M** Auf echtem Gerät: Installation, erster Start, Touch-Bauen/Upgraden/Verkaufen, Wellen, Pause, Zurück, App-Wechsel und erneutes Öffnen prüfen.
|
||||||
|
- [ ] **M** Auf kleinem und großem Display prüfen, dass Spielfeld und Bedienelemente erreichbar und nicht abgeschnitten sind.
|
||||||
|
- [ ] **M** Android-Audio auf echtem Gerät prüfen; bis dahin bleibt die Android-spezifische Audiodeaktivierung bestehen.
|
||||||
|
- [ ] **M** Android-Testanzeigen erst freigeben, wenn das native Plugin geladen wird, Anzeigen sichtbar sind und Datenschutz-/Einwilligungsablauf geprüft wurde.
|
||||||
|
- [ ] **M** Google-Play-internen Test mit signiertem AAB durchführen; Release-Schlüssel geschützt und außerhalb des Repositories verwahren.
|
||||||
@@ -50,6 +50,28 @@ Color palette: dark titanium, worn steel, deep red markings, red-orange emissive
|
|||||||
Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no cropped parts, no perspective tilt
|
Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no cropped parts, no perspective tilt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Neue Gegnerschiffe (Balance-Iteration)
|
||||||
|
|
||||||
|
Die Rollenbilder liegen separat, damit die zusätzlichen Schiffe trotz unterschiedlicher Silhouette ohne Sprite-Sheet-Cropping angezeigt werden: `enemy_interceptor.png`, `enemy_shield_carrier.png` und `enemy_siege_destroyer.png`. Alle drei zeigen die Flugrichtung nach rechts; der Laufcode dreht das jeweilige Bild passend zur Route. Quelle: integrierter OpenAI-Bildgenerator, transparenter Hintergrund, Stilreferenz war `enemy_ships.png`.
|
||||||
|
|
||||||
|
**Abfangjäger** — schnell, schmal, geringe Hülle und höherer Reaktorschaden bei Durchbruch.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Einzelner transparenter feindlicher Abfangjäger für Void Bastion, scharf-winklige schmale Flügel, vollständig sichtbar und nach rechts ausgerichtet (Bug rechts, Triebwerke links), verwitterte gunmetal- und rote Panzerplatten, orange Triebwerke, realistischer 3D-gerenderter Top-down-3/4-Sci-Fi-Spielgrafikstil passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schildkreuzer** — mittelgroß, dauerhaft abgeschirmt und mit blau-cyanfarbenen Emittern deutlich erkennbar.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Einzelner transparenter feindlicher Schild-Unterstützer für Void Bastion, breite kompakte Silhouette, cyanfarbene Energie-Emitter-Ringe und leuchtender Kern direkt im Schiff, keine äußere Schutzblase, vollständig sichtbar und nach rechts ausgerichtet, verwitterte gunmetal- und rote Panzerplatten, realistische 3D-gerenderte Sci-Fi-Spielgrafik passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Belagerer** — schweres, langsames Schiff mit breiter Silhouette und hohem Reaktorschaden; wird außerdem für Bosswellen genutzt.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Einzelner transparenter schwerer feindlicher Belagerungszerstörer für Void Bastion, breite massive gepanzerte Silhouette mit mehreren sichtbaren Kanonen und drei starken orangefarbenen Triebwerken, vollständig sichtbar und nach rechts ausgerichtet, verwitterte gunmetal- und rote Panzerplatten, realistischer 3D-gerenderter Sci-Fi-Spielgrafikstil passend zum bestehenden Gegner-Sprite, keine Umgebung, kein Text, kein Rahmen, kein Wasserzeichen.
|
||||||
|
```
|
||||||
|
|
||||||
## app_icon.png
|
## app_icon.png
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Vollständiges Projekt-Backup
|
||||||
|
|
||||||
|
- Erstellt: 2026-10-06
|
||||||
|
- Quelle: `C:\Users\Domin\Documents\Codex\2026-10-02\ich\outputs\void-bastion`
|
||||||
|
- Enthält den gesamten Projektordner inklusive `.git`-Historie, Assets, Godot-Importdaten, Plattformprojektordner, lokalen Builds und Installerdateien.
|
||||||
|
- Startpunkt für die Wiederaufnahme: `PROJEKT-HANDBUCH-FORTSETZUNG.md` und danach `README.md`.
|
||||||
|
- Das Backup ist eine Sicherheitskopie. Änderungen darin nicht als Arbeitskopie verwenden; zuerst in einen normalen Projektordner zurückkopieren.
|
||||||
|
- Nicht enthalten sind Geheimnisse/Zugangsdaten, die ausschließlich auf Unraid, im Webhosting oder in einem Passwortmanager liegen.
|
||||||
|
|
||||||
|
## Prüfsumme prüfen
|
||||||
|
|
||||||
|
Nach dem Kopieren lassen sich wichtige Dokumente und Builds mit PowerShell vergleichen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-FileHash -Algorithm SHA256 .\PROJEKT-HANDBUCH-FORTSETZUNG.md
|
||||||
|
Get-FileHash -Algorithm SHA256 .\project.godot
|
||||||
|
Get-FileHash -Algorithm SHA256 .\build\android\Void-Bastion-Android-x64-alpha4.apk
|
||||||
|
Get-FileHash -Algorithm SHA256 .\dist\Void-Bastion-Setup-x64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Ausgabe dieser Befehle muss für Quelldatei und Sicherung identisch sein.
|
||||||
@@ -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 11 das Server-Bundle mit SHA-256 `b522531f8ae6c4ec12b2001db9fbee9fb47b3f48611c58aaab9a07439309d399` aktiviert. Nach dem gezielten Containerneustart meldete Nakama `healthy`. Das Bundle enthält die Freundesnamenssuche, automatische Online-Rundenberichte und die serverseitige Halbierung des ersten Welle-5-Boss-Schilds. Die wiederherstellbare Serverkopie liegt unter `/mnt/user/appdata/void-bastion-online/nakama/modules/build/index.js.pre-20261010-201010`.
|
||||||
|
|
||||||
|
## 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 `-MakeNsisExe` mit der **echten** NSIS-Binärdatei. Auf diesem PC funktionierten Godot 4.7.2 unter `C:\Users\Domin\Downloads\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64_console.exe` und NSIS unter `build\nsis-portable\nsis-3.13\Bin\makensis.exe`. In der eingeschränkten Ausführungsumgebung schlug NSIS mit einem Windows-System-DLL-Ladefehler fehl; der Installer ließ sich mit derselben Build-Anweisung außerhalb der Sandbox erstellen. 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. Das Release-Repository ist unabhängig vom Spiel-Quellcode-Repository: `target_commitish` muss daher auf dessen existierenden Branch (derzeit `main`) zeigen, nicht auf den Commit-SHA des Spiels. 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 12: `v0.1.0-alpha.12`, Spiel-Commit `39ed4c3`, Website-Commit `417b2d8`, Nakama-Bundle SHA-256 `f99bdbc674556bfd59197d6cc1665a6db171e6ccde9c9103c19d4e9dfe99b4f7`, Setup SHA-256 `3309856C37B91B316ACF510911FE76F7F75C817D2BC515A43544566AFA50BA93`. Nakama meldete nach dem gezielten Neustart `healthy`; `latest-release.php` zeigte Alpha 12 und der öffentliche Download stimmte bytegenau mit dem lokalen Setup überein.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Online-Diagnose für Zwei-PC-Tests
|
||||||
|
|
||||||
|
Ab dem nächsten gemeinsamen Client-/Nakama-Update schreibt das Spiel während jeder Online-Partie Messwerte lokal mit. Beim Sieg (Welle 15), bei einer Niederlage oder beim Verlassen sendet jeder PC seinen Bericht automatisch an Nakama. Beide Berichte bekommen dieselbe 16-stellige Rundenkennung und die Seitenzahl `0` oder `1`. Die Spieler müssen dafür keinen Diagnose-Button bedienen. Bei vorübergehendem Übertragungsfehler versucht der Client es dreimal; die lokale `.jsonl`-Datei bleibt als Rückfallebene erhalten.
|
||||||
|
|
||||||
|
Die private Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine Übersicht der letzten Berichte. Mit `{"round_id":"<16-stellige Kennung>"}` liefert sie die zwei vollständigen Seitenberichte. Sie ist wie `private_stats` nur mit dem privaten Nakama-HTTP-Schlüssel erreichbar; Spielclients können sie nicht aufrufen. Für den Vergleich einer Partie erst die Rundenkennung in der Übersicht auswählen und anschließend beide Seiten laden. Die Kennung steht außerdem nach erfolgreichem Upload in der Spielausgabe als `ONLINE DIAG hochgeladen: Runde ...`.
|
||||||
|
|
||||||
|
Online gespeichert werden nur technische Messwerte. Nachrichten, IP-Adressen, Spielernamen, Raumcodes und der rohe Match-Identifier werden nicht als Bericht gespeichert. Der Server nimmt nur Berichte von Konten an, die tatsächlich Teilnehmer der Runde waren. Berichte und Teilnehmerzuordnung werden nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
|
||||||
|
|
||||||
|
Jede `window`-Zeile fasst etwa zehn Sekunden zusammen:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `snapshots_per_second`, `gap_p95_ms`, `gap_max_ms` | Wie regelmäßig Zustände beim PC eintreffen; erwartet sind ungefähr 10/s bzw. 100 ms Abstand. |
|
||||||
|
| `tick_jump_max`, `tick_jumps_over_1` | Ob Server-Schritte zwischen verarbeiteten Zuständen fehlen. Zusammen mit den Empfangsabständen und Warteschlangenwerten zeigt das, ob Zustände ausblieben oder erst verspätet gebündelt ankamen; allein beweist es noch keine Ursache. |
|
||||||
|
| `bytes_avg`, `bytes_max` | Größe der vollständigen Zustandsnachrichten. Wachsende Werte bei vielen Gegnern/Türmen weisen auf Datenmenge als möglichen Faktor hin. |
|
||||||
|
| `coalesced`, `queue_dropped`, `queue_max` | Mehrere Zustände kamen vor einem Bild zusammen oder die Warteschlange lief voll. Das deutet auf einen Client-Rückstau hin. |
|
||||||
|
| `parse_max_us`, `apply_max_us` | Höchste Zeit für JSON-Verarbeitung bzw. Anwenden des Zustands in Godot. |
|
||||||
|
| `frame_max_ms`, `frame_stalls_over_200_ms` | Lokale Bildaussetzer, auch wenn das Netz regelmäßig liefert. |
|
||||||
|
| `enemies`, `towers` | Last im Spiel zum Zeitpunkt der Messung. |
|
||||||
|
|
||||||
|
Zusätzliche `snapshot_gap`, `tick_jump`, `frame_stall`, `socket_closed` und `socket_reconnected`-Zeilen markieren einzelne Ereignisse. Die Uhrzeit ist UTC, damit beide PCs direkt verglichen werden können. Ein Empfangsabstand allein beweist keine Paketverluste: Er kann auch durch Serverlast, Netzwerk-Jitter oder einen blockierten Client entstehen. Für eine eindeutige Zuordnung die gleichzeitigen Berichte **beider PCs** vergleichen; serverseitige `ONLINE DIAG tick_gap_ms`- und `loop_ms`-Warnungen helfen zusätzlich.
|
||||||
|
|
||||||
|
Wichtig: Alte Spielversionen laden noch nichts hoch. Vor einem echten Zwei-PC-Test müssen sowohl der neue Windows-Build als auch der aktualisierte Nakama-Runtime-Code veröffentlicht sein.
|
||||||
@@ -28,6 +28,14 @@ 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 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.
|
||||||
|
|
||||||
|
Das Einheiten-Balancing liegt einheitlich im privaten Nakama-Speicher (`game_balance/global`). Die geschützte Statistikseite liest und ändert es über die nur mit `NAKAMA_HTTP_KEY` aufrufbaren RPCs `get_game_balance` und `set_game_balance`; der HTTP-Schlüssel wird nie an Clients ausgeliefert. Angemeldete Spielclients rufen `get_game_balance` beim Start auf, cachen die letzte gültige Konfiguration lokal und verwenden sie auch offline. Jede neue Online-Partie lädt dieselbe Konfiguration serverseitig bei `matchInit`; bereits laufende Partien bleiben unverändert. Falls der Server unerreichbar ist, gelten der letzte lokale Cache bzw. die eingebauten Ausgangswerte. Änderung des Balancings erfordert dadurch keine neue Spiel-EXE, aber das Runtime-Bundle auf dem Nakama-Server und die interne PHP-Seite müssen zunächst aktualisiert werden.
|
||||||
|
|
||||||
|
`server/nakama/tests/balance_duel_test.js` ist ein reproduzierbarer Referenztest, der die echte Nakama-Kampf-Simulation nutzt und alle zulässigen Turm-/Gegner-Kombinationen sowie den ersten Boss bewertet. Er ist ein gezielter 1-gegen-1-Wegtest (ein Level-1-Turm, keine Upgrades/Unterstützung), kein Ersatz für komplette Wellen- und Spielertests. Ergebnisse daher als Warnsignal und Vergleich zwischen Versionen verwenden, nicht als alleinige automatische Balancing-Entscheidung.
|
||||||
|
|
||||||
|
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.
|
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
|
## Multiplayer
|
||||||
@@ -38,6 +46,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.
|
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
|
## Adressen
|
||||||
|
|
||||||
- Produktion: `https://api.void-bastion.gergames.de` und `wss://api.void-bastion.gergames.de`
|
- Produktion: `https://api.void-bastion.gergames.de` und `wss://api.void-bastion.gergames.de`
|
||||||
@@ -50,7 +64,14 @@ Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- Offline-/Gameplaytest: `godot --headless --path . --script res://tests/smoke_test.gd`
|
- 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`
|
- 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`
|
- 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.
|
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 jetzt einen fortlaufenden Diagnosebericht pro Partie. Im Online-Pausemenü kann der Bericht mit **DIAGNOSE KOPIEREN** vollständig in die Zwischenablage kopiert werden. Er enthält Zehn-Sekunden-Werte und einzelne Hänger-/Verbindungsereignisse, aber keine Spielernamen, Raumcodes oder Kontenkennungen. Für Ablauf und Auswertung auf zwei PCs siehe [ONLINE_DIAGNOSE.md](ONLINE_DIAGNOSE.md).
|
||||||
|
|
||||||
|
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` und `queue_dropped` im Diagnosebericht trennen zusammengefasste von wegen voller Warteschlange verworfenen Zuständen. 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.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Void Bastion – Projekt- und Fortsetzungs-Handbuch
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Projektziele und aktueller Stand
|
||||||
|
|
||||||
|
Void Bastion ist ein 2D-Tower-Defense-Spiel im realistischen Science-Fiction-Stil für Windows, Android und später iOS. Der aktuelle Spielstand ist Alpha 4. Einzelspieler mit sieben Karten, sechs Türmen, drei Schwierigkeitsstufen, 15 Wellen plus optionalem Endlosmodus, Shop, lokalem Profil und Bestenlisten ist vorhanden. Ein lokaler Koop-Modus gegen KI sowie eine Nakama-Onlineanbindung sind vorbereitet/implementiert.
|
||||||
|
|
||||||
|
Wichtige Einschränkungen des aktuellen Android-Teststands:
|
||||||
|
|
||||||
|
- Der Android-Emulator (Android 17, x86_64, SwiftShader) startet Alpha 4 und bleibt mindestens 45 Sekunden aktiv. Der Bildschirm zeigt das Hauptmenü; im Log gab es während dieses Laufs keinen Absturz.
|
||||||
|
- Android verwendet den Mobile/Vulkan-Renderer. Die GLES-Compatibility-Variante überschritt auf dem Emulator die verfügbare Shader-Uniform-Grenze.
|
||||||
|
- Wegen eines reproduzierbaren nativen SIGSEGV in Androids AudioTrack/OpenSL-Ausgabepfad sind Musik und UI-Klicktöne derzeit nur auf Android deaktiviert. Windows-Audio bleibt aktiv. Vor dem Wiederaktivieren erst mit regulären, eingebundenen Audioassets und auf echten Geräten erneut testen.
|
||||||
|
- Im Android-Log meldet das AdMob-Plugin, dass sein natives Singleton nicht initialisiert ist. Android-Werbung ist deshalb nicht als funktionsfähig getestet anzusehen.
|
||||||
|
- Die geprüfte Emulator-Datei ist `build/android/Void-Bastion-Android-x64-alpha4.apk`. Sie hat die Paketkennung `de.gergames.voidbastion`, Versionsnamen `0.1.0-alpha.4` und ist mit dem lokalen Debug-Schlüssel signiert. Sie ist nur zum Sideload-/Emulator-Test bestimmt, nicht zur Store-Veröffentlichung.
|
||||||
|
- Es wurde kein echtes Android-Handy in diesem Testlauf geprüft. Touch-Bedienung, Geräteperformance, verschiedene Seitenverhältnisse, Anzeigen und Audio brauchen noch echte Gerätetests.
|
||||||
|
|
||||||
|
## Projektdateien und Zuständigkeiten
|
||||||
|
|
||||||
|
- `project.godot`: Godot-Projekt, Fenster-/Viewport-Einstellungen, Autoloads, Editor-Plugins und Renderer. Desktop bleibt auf `gl_compatibility`; Mobilgeräte nutzen derzeit `mobile`/Vulkan.
|
||||||
|
- `main.gd`: Spielzustand, Karten-/Weg-/Bau-/Turmdaten, Wellen, KI, Kampf, UI, Zeichnen und lokale Navigation. Die zentralen Datenkonstanten stehen am Dateianfang.
|
||||||
|
- `main.tscn`: Hauptszene und Manager-Nodes.
|
||||||
|
- `multiplayer_manager.gd`: lokale Koop-/KI-Regeln und Multiplayer-Helfer.
|
||||||
|
- `online_manager.gd`, `account_manager.gd`: Nakama-Verbindung, Profil, Anmeldung und Ranglisten.
|
||||||
|
- `ads_manager.gd`: Werbeintegration. Testmodus beibehalten, bis Konten, Einwilligung und Store-Konfiguration rechtlich/technisch geprüft sind.
|
||||||
|
- `update_manager.gd`: Prüft öffentliche Releases und bietet einen Update-Link an; installiert nichts ungefragt.
|
||||||
|
- `assets/`: Kartenbasen, Turm-/Gegnersprites, Straßenmaterial und UI-Grafiken.
|
||||||
|
- `tests/smoke_test.gd`: Offline-/Logikregressionstest. Nach Karten-, Weg-, Turm-, Wellen- oder Balancingänderungen immer ausführen.
|
||||||
|
- `tests/online_integration_test.gd`: Nakama-/Onlineintegrationstest; nur gegen ausdrücklich gewählte Testumgebung ausführen.
|
||||||
|
- `packaging/windows/build.ps1` und `packaging/windows/installer.nsi`: Windows-Export und Setup-Installer.
|
||||||
|
- `export_presets.cfg`: Windows-, Android- und iOS-Exportprofile.
|
||||||
|
- `server/nakama/`: Serverkonfiguration/Module. Keine Servergeheimnisse hier ablegen.
|
||||||
|
- `README.md`, `ART_ASSETS.md`, `ONLINE_SETUP.md`, `LEGAL_RELEASE_CHECKLIST.md`, `PLATFORM_ACCOUNT_SETUP.md`, `THIRD_PARTY_NOTICES.md`: ergänzende Projektunterlagen.
|
||||||
|
- `.godot/`, `build/`, `dist/`, `android/build/`: generierte Imports, Builds, Caches und Artefakte. Sie gehören zum vollständigen PC-Backup, aber normalerweise nicht in Git-Commits.
|
||||||
|
|
||||||
|
## Karten: verbindliche Regeln
|
||||||
|
|
||||||
|
### Koordinatensystem und Raster
|
||||||
|
|
||||||
|
- Die logische Spielfläche ist `SCREEN = Vector2(1600, 900)` in `main.gd`.
|
||||||
|
- `GRID_SIZE = 80` Pixel; Karten, Wege, Bauzellen und Turm-Snapping müssen mit demselben Raster arbeiten.
|
||||||
|
- Das Rasterzentrum eines Feldes liegt bei `(40 + 80·x, 40 + 80·y)`. Wegpunkte sollen auf diesen Feldmitten liegen. Der auslaufende Start darf links bei `x = -40` liegen.
|
||||||
|
- Wegsegmente verlaufen nur waagerecht oder senkrecht. Keine diagonalen Segmente. Kurven entstehen durch aufeinanderfolgende rechtwinklige Segmente.
|
||||||
|
- Gegnerrouten und Straßenzeichnung beziehen ihre Daten gemeinsam aus `MAP_PATHS`/`path_cells`. Niemals zusätzlich einen unabhängigen Weg in die Hintergrundgrafik malen oder eine zweite separate Laufroute definieren.
|
||||||
|
|
||||||
|
### Drei parallele Kartendatenlisten
|
||||||
|
|
||||||
|
Jeder Karteneintrag muss denselben Index in ALLEN folgenden Arrays besitzen (in `main.gd`):
|
||||||
|
|
||||||
|
1. `MAP_TEXTURES`: Hintergrundtextur.
|
||||||
|
2. `MAP_NAMES`, `MAP_DESCRIPTIONS`, `MAP_UNLOCK_COSTS`: Name, Kurzbeschreibung und Shoppreis.
|
||||||
|
3. `MAP_PATHS`: geordnete Mittellinienpunkte für Gegner und Straße.
|
||||||
|
4. `MAP_BUILD_ZONES`: erlaubte Deckbereiche als ein oder mehrere `Rect2`-Rechtecke.
|
||||||
|
5. `MAP_REACTOR_ZONES`: Reaktormittelpunkt(e) und Sperrradius/-radien.
|
||||||
|
|
||||||
|
Alle Listen müssen dieselbe Kartenanzahl und identische Reihenfolge haben. Nach Hinzufügen oder Umsortieren einer Karte die Anzahl und Reihenfolge prüfen und Smoke-Test ergänzen.
|
||||||
|
|
||||||
|
### Routenende, Reaktor und Bauverbote
|
||||||
|
|
||||||
|
- Letzter Punkt in `MAP_PATHS[index]` muss exakt im Mittelpunkt des Hauptreaktors landen (Toleranz in `_validate_route_ends_at_reactor()` ist 1 Pixel). `_select_map()` validiert die Route; der Smoke-Test validiert alle Karten.
|
||||||
|
- Die Reaktorgrafik im Hintergrund, `MAP_REACTOR_ZONES[index][0].center` und letzter Wegpunkt müssen optisch/technisch deckungsgleich sein.
|
||||||
|
- Auf jedem Reaktor ist Bauen verboten. Alle Reaktoren/offenen Reaktorkammern derselben Karte in `MAP_REACTOR_ZONES` eintragen. Pyrax hat zusätzlich die offene Reaktorkammer als zweite Sperrzone.
|
||||||
|
- Erlaubte Deckzellen werden über `MAP_BUILD_ZONES` definiert. Das Spiel sperrt zusätzlich Wegzellen, Reaktorzonen, Randzellen und bereits belegte Zellen.
|
||||||
|
- Türme werden auf Feldmitten eingerastet. Weder Türme noch Wegkacheln sollen nur über Bildkoordinaten korrigiert werden: zuerst Raster-/Routendaten korrigieren.
|
||||||
|
|
||||||
|
### Kartengrafik
|
||||||
|
|
||||||
|
- Karte ist eine saubere, hochauflösende Draufsicht im Querformat (bestehende HD-Basen etwa 3344×1882 Pixel).
|
||||||
|
- Hintergrundgrafik ohne eingebauten Weg, Fahrbahn, Raster, Bauplätze, Türme, Gegner, UI, Text oder Logos erstellen. Straße/Raster/Route werden im Spiel gezeichnet; so können sie nicht gegeneinander verrutschen.
|
||||||
|
- Der Reaktor soll gut erkennbar am rechten Rand ungefähr auf Höhe des Spielfeldzentrums liegen, außer die Route/Zone definiert absichtlich eine andere Lage.
|
||||||
|
- Behalte realistische aktive Stationsbeleuchtung, Metallpaneele, Cyan-Energieelemente und kleine warme Positionslichter. Keine dunkle/ausgewaschene Karte und keine starke gelbe Umrandung der kompletten Straße.
|
||||||
|
- Die Straße ist eine Rasterzelle breit (80 px) und nutzt `road_straight.svg`, `road_corner.svg`, `road_surface_material_v1.png`, `road_corner_surface_v1.png`.
|
||||||
|
- Der letzte Abschnitt zum Reaktor wird in `_draw_reactor_approach()` mit leisem Lichtimpuls und langsamem Transparenzverlauf gestaltet. Den Schluss nicht hart abschneiden und keine groben Doppelbögen ans Reaktorzentrum setzen.
|
||||||
|
- Asset-Prompts und Asset-Herkunft in `ART_ASSETS.md` dokumentieren.
|
||||||
|
|
||||||
|
### Kartenprüfung
|
||||||
|
|
||||||
|
1. Für jeden Wegpunkt Rastermitte prüfen, horizontale/vertikale Segmente sicherstellen.
|
||||||
|
2. Ende exakt am Reaktormittelpunkt kontrollieren.
|
||||||
|
3. Reaktor im Hintergrund mit Sperrzone vergleichen; keine Bauzellen auf dem Reaktor.
|
||||||
|
4. Das Projekt starten: Weg muss sichtbar mit Gegnerlaufspur, Raster und Hintergrund zusammenpassen.
|
||||||
|
5. Zwischen Wegen müssen Bauzellen frei bleiben, soweit sie innerhalb einer Build-Zone und außerhalb aller Route-/Reaktor-Sperren liegen.
|
||||||
|
6. `tests/smoke_test.gd` für jede neue Karte um Routenende, Reactor-Sperre, baubare Zellen und Shop-Kosten erweitern und ausführen.
|
||||||
|
|
||||||
|
## Türme und Gegner
|
||||||
|
|
||||||
|
### Turmdaten und Darstellung
|
||||||
|
|
||||||
|
Die Liste `TOWER_DATA` in `main.gd` ist die zentrale Quelle für Turmname, Baukosten, Basisschaden, Reichweite, Schussintervall (`rate`, Sekunden pro Schuss), Projektilgeschwindigkeit, Schadensart, Farbe, Freischaltpreis und Beschreibung. Schüsse/Sekunde wird aus dem Intervall berechnet. Beim Hinzufügen eines Turms müssen UI-Karte, Shop, Platzierung, Reichweitenvorschau, Angriff/Schadenslogik, Upgrade, Speichern/Freischaltung und Tests konsistent ergänzt werden.
|
||||||
|
|
||||||
|
Sprites:
|
||||||
|
|
||||||
|
- Kinetik, Laser, Rakete: drei horizontale Frames aus `assets/defense_towers.png`.
|
||||||
|
- Plasma, EMP-Puls, Railgun: separate Texturen (`tower_plasma.png`, `tower_emp.png`, `tower_railgun.png`).
|
||||||
|
- Ergänzende Sprites im bestehenden maßstäblichen Top-Down-Stil und mit transparenter Fläche erzeugen. In `_draw_tower()` Textur/Fall ergänzen. Bei der Kinetik-Ausrichtung beachten, dass ein falscher Basiswinkel den Lauf um 180° verdreht erscheinen lässt.
|
||||||
|
|
||||||
|
| Turm | Kosten | Schaden | Reichweite | Intervall | Besonderheit |
|
||||||
|
|---|---:|---:|---:|---:|---|
|
||||||
|
| Kinetik | 120 EN | 22 | 190 | 0,55 s | Einzelziel |
|
||||||
|
| Laser | 180 EN | 12 | 165 | 0,18 s | Schnell, effektiv gegen Schilde |
|
||||||
|
| Rakete | 250 EN | 65 | 260 | 1,40 s | Flächenschaden 76 |
|
||||||
|
| Plasma | 230 EN | 48 | 240 | 1,05 s | Flächenschaden 92; 18 Kernkristalle Freischaltung |
|
||||||
|
| EMP-Puls | 210 EN | 34 | 215 | 0,92 s | Flächeneffekt 118; 24 Kernkristalle |
|
||||||
|
| Railgun | 300 EN | 110 | 330 | 1,85 s | Durchschlägt Schild; 30 Kernkristalle |
|
||||||
|
|
||||||
|
Alle Türme unten in der Leiste darstellen. Reichweite schon vor dem Bauen zeigen; Vorschaukasten kompakt/transluzent halten. Turminfo soll Schaden, Schüsse pro Sekunde, Reichweite und Einzel-/Flächentyp zeigen. Upgrade-Vorschau muss den echten Zuwachs darstellen und darf keine verdeckten Felder erzeugen. Bei Balancingänderungen Shoptext und Tests mit anpassen.
|
||||||
|
|
||||||
|
### Gegner
|
||||||
|
|
||||||
|
Gegnerwerte liegen in `ENEMY_DATA`: Jäger (70 HP, Tempo 105, Belohnung 18), Fregatte (185 HP, Tempo 72, Belohnung 34), Kreuzer (520 HP, Tempo 43, Belohnung 78). Skalierung und Wellenlogik werden zusätzlich durch `DIFFICULTY_DATA`/Wellenfunktionen beeinflusst. Schildgegner sollen Laserschaden bevorzugt wirksam bekommen; andere Türme dürfen reduziert, aber nicht grundsätzlich null Schaden verursachen. Railgun ignoriert den Schildschutz.
|
||||||
|
|
||||||
|
## Wellen, Wirtschaft, Highscore
|
||||||
|
|
||||||
|
- `MAX_WAVES = 15`; Bossgegner alle fünf Wellen.
|
||||||
|
- Leicht entspricht dem früheren Balancing, Mittel/Schwer steigern Lebenspunkte, Tempo und Gegneranzahl und reduzieren Startressourcen. Belohnung aktuell 5/7/10 Kernkristalle nach Schwierigkeit.
|
||||||
|
- Nach Welle 15 gibt es die Wahl: Mission beenden oder freiwillig Endlosmodus. Endlos-Verlust gilt nicht als gescheiterte Mission.
|
||||||
|
- Punkte sollen neben erreichter Welle auch Restenergie und effiziente Turmverwendung abbilden. Verkauf aller Türme in der Schlusswelle darf keinen Multiplikator-Trick ermöglichen; deshalb Turmbau-/Verkaufshistorie berücksichtigen, nicht nur die am Ende noch stehenden Türme.
|
||||||
|
- Spielwährung heißt konsistent **Kernkristalle** (nicht abwechselnd „Kern“ oder „Kristalle“). Energie/EN ist die missionsinterne Bauressource.
|
||||||
|
- Für neue Türme/Karten Shopfreischaltungen und Belohnung konsistent speichern; kein echter Geldeinkauf ist für diese Testfassung erforderlich.
|
||||||
|
|
||||||
|
## Multiplayer
|
||||||
|
|
||||||
|
- Lokale KI-Arena verwendet `COOP_LEFT_PATH` und eine Spiegelung exakt um die Achse durch den gemeinsamen Reaktor (`COOP_MIRROR_X = 1480`). Weltbreite ist doppelt; beide Seiten sollen voll groß bleiben, nicht zusammengeschrumpft.
|
||||||
|
- Reaktorleben sind gemeinsam, Credits getrennt; jeder baut auf seiner Seite. KI-Platzierung in `_find_ai_build_position()` anhand echter Gegnerroute und effektiver Turmreichweite bewerten, niemals zufällige/off-route Plätze wählen.
|
||||||
|
- Onlinekonten, Räume, Ranglisten und Ergebnisprüfung laufen über Nakama. Niemals Admin-/Server-/HTTP-Schlüssel in Godot-Projekt, `.env`-Beispiel, Git, Website oder diese Anleitung kopieren.
|
||||||
|
- Server-/Unraid-Konfiguration und öffentliche Adresse sind in `ONLINE_SETUP.md` bzw. `server/nakama/README.md` beschrieben; echte Geheimnisse liegen nur auf dem Server/Webhosting.
|
||||||
|
|
||||||
|
## Builds, Checks und Release
|
||||||
|
|
||||||
|
### Logiktest
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& 'E:\Program Files\Godot\Godot_v4.7.2-stable_win64_console.exe' --headless --path . --script tests/smoke_test.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Erfolg endet mit `VOID_BASTION_SMOKE_TEST_OK`. Bekannte Umgebungswarnungen zum CA-Zertifikat oder user://-Log sind nicht automatisch ein Spielfehler; entscheidend ist Rückgabecode und Ergebnis.
|
||||||
|
|
||||||
|
### Windows-Installer
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& '.\packaging\windows\build.ps1' `
|
||||||
|
-GodotExe 'E:\Program Files\Godot\Godot_v4.7.2-stable_win64_console.exe' `
|
||||||
|
-MakeNsisExe '.\build\nsis-portable\nsis-3.13\makensis.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
Artefakt: `dist/Void-Bastion-Setup-x64.exe`. Das Skript blendet das Entwicklungs-MCP aus dem Release aus, exportiert und stellt anschließend `project.godot` wieder her.
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
- Godot Exportprofil heißt `Android Studio Emulator x86_64` (emulator) bzw. `Android Test APK` (ARM64/ARMv7-Geräte).
|
||||||
|
- Erfordert Android SDK, Java 17, Gradle und Godot-Android-Templates. Android-Paketkennung ist `de.gergames.voidbastion`.
|
||||||
|
- Das Editor-MCP-Autoload/Plugin darf nicht ins Release gelangen: temporär vor dem Export `_MCPGameBridge` und `res://addons/agent_tools/plugin.cfg` aus der Exportkopie entfernen und Quelldatei danach wiederherstellen (Windows-Skript zeigt das sichere try/finally-Muster). Nie eine Änderung versehentlich dauerhaft aus der Entwicklerdatei löschen.
|
||||||
|
- Emulator Alpha 4 wurde mit Gradle 8.11.1, AGP 8.6.1, Java 17 und SDK 36 gebaut. Bei einem Gradle `AccessDeniedException` in einer Transform-Cache-Datei neues separates `GRADLE_USER_HOME` nutzen; vorhandene Cache-Datei nicht blind löschen.
|
||||||
|
- Für einen Testbuild prüfen: `aapt dump badging` zeigt `de.gergames.voidbastion` und `0.1.0-alpha.4`; `apksigner verify --verbose` muss `Verifies` melden; danach auf Emulator installieren und mindestens 45 Sekunden starten, Logcat auf SIGSEGV/FATAL prüfen und Screenshot kontrollieren.
|
||||||
|
- Erfolgreiche Testdatei liegt in `build/android/Void-Bastion-Android-x64-alpha4.apk` (Debugsignatur; nur intern). APK ist im Git-Verlauf nicht garantiert; im Vollbackup enthalten.
|
||||||
|
- Für neue Google-Play-/Sideload-Versionen Android `versionCode` erhöhen. Signaturschlüssel für Produktion getrennt und sicher aufbewahren, nicht Debugschlüssel als Store-Schlüssel verwenden.
|
||||||
|
|
||||||
|
### iOS und Stores
|
||||||
|
|
||||||
|
iOS-Export erzeugt ein Xcode-Projekt, erfordert macOS, Xcode, Zertifikate/Provisioning und ein Apple-Developer-Konto. Ein Windows-Rechner kann die iOS-App nicht final signieren oder in den App Store hochladen. Google Play-/Apple Store-/Steam-Accounts, Datenschutz- und Storefreigaben bleiben manuelle Betreiberaufgaben. Vor öffentlichem Release Checklisten/Third-party-Lizenzen und echte Geräteprüfung abschließen.
|
||||||
|
|
||||||
|
### Webseite und Releases
|
||||||
|
|
||||||
|
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. Git installieren; `git status` und `git log -1` prüfen. Den mitgesicherten `.git`-Ordner nicht entfernen, damit die Historie erhalten bleibt.
|
||||||
|
3. Godot 4.7.2 installieren und `project.godot` importieren; ersten Assetimport abwarten.
|
||||||
|
4. `PROJEKT-HANDBUCH-FORTSETZUNG.md`, `README.md`, `ART_ASSETS.md`, `ALPHA_TEST_CHECKLIST.md`, `ONLINE_SETUP.md` lesen.
|
||||||
|
5. Android-Buildtools/JDK 17 nur einrichten, wenn Android gebraucht wird. Emulator neu anlegen; lokale Gradle-Caches und lokale Godot-Einstellungen sind maschinengebunden und können auf dem neuen PC neu erzeugt werden.
|
||||||
|
6. `tests/smoke_test.gd` ausführen, dann Spiel im Editor starten und zuletzt Plattformbuilds prüfen.
|
||||||
|
7. Serverzugänge, Signierzertifikate, echte AdMob-/Nakama-Schlüssel und Hostingpasswörter separat aus einem Passwortmanager/Server sichern. Diese sind absichtlich nicht Teil des Repositories oder dieser Anleitung.
|
||||||
|
|
||||||
|
## Grundsatz bei jeder Karten-/Turmänderung
|
||||||
|
|
||||||
|
Eine visuelle Änderung ist erst fertig, wenn Datenquelle, Darstellung, Kollisions-/Bauprüfung, Kauf-/Freischaltstatus, UI-Text und automatisierter Test zueinander passen. Bei Karten besonders Route, Straßenkacheln, Raster, Bauflächen, Sperrzonen und Reaktorhintergrund gemeinsam prüfen. Keine unabhängige „optische Reparatur“ über die Route zeichnen.
|
||||||
@@ -16,9 +16,16 @@ Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuell
|
|||||||
- jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt
|
- jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt
|
||||||
- neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung
|
- neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung
|
||||||
- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun
|
- sechs Türme: Kinetik, Laser, Rakete, Plasma, EMP-Puls und Railgun
|
||||||
- drei Gegnerklassen mit unterschiedlichen Werten
|
- neun Gegnerklassen mit Luft- und Bodeneinheiten: Jäger, Fregatte, Kreuzer, Abfangjäger, Schildkreuzer, Luft-Belagerer, schneller Boden-Rover, Panzer und schwerer Läufer
|
||||||
|
- neue Bodeneinheiten-Grafiken mit leuchtend grünen Warnmarkierungen und Energielichtern
|
||||||
|
- Zielprioritäten für Türme: erste, letzte, höchste verbleibende Hülle oder Schildziele zuerst; Laser priorisiert Schilde immer automatisch
|
||||||
|
- Luft-/Bodenvorschau wird durch gleich gestaltete grüne Pfeile unterschieden: Luft nach oben, Boden nach unten
|
||||||
|
- Schildgegner haben einen blauen, gegen den Uhrzeigersinn abnehmenden Schildring; Lasertreffer bauen zuerst Schildpunkte ab und übertragen Überschuss auf die Hülle
|
||||||
|
- sechs Türme mit klar ausgewiesenen Zielen: Boden, Luft oder beide Kategorien; universelle Türme verursachen reduziert Schaden
|
||||||
|
- HUD-Vorschau mit kleinen Einheitensymbolen und Stückzahl für aktuelle und nächste Welle
|
||||||
|
- Entwickler-Testregler für Gegner-Lebenspunkte (F9 ein-/ausblenden, +/− verändern; Faktor 0,5×–3,0×, im Online-Koop deaktiviert)
|
||||||
- 15 automatisch skalierende Missionswellen
|
- 15 automatisch skalierende Missionswellen
|
||||||
- drei wählbare Schwierigkeitsgrade: Leicht entspricht dem bisherigen Balancing, Mittel ist der neue Standard und Schwer erhöht Gegnerhülle, Tempo und Anzahl deutlich
|
- drei wählbare Schwierigkeitsgrade: Leicht behält die bisherige Kurve; Schwer startet knapper und erhöht ab Missionsmitte Gegnerhülle, Gegnerzahl und fortgeschrittene Gegnertypen stärker
|
||||||
- höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen
|
- höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen
|
||||||
- freiwilliger Endlosmodus nach Welle 15 mit unbegrenzt weiter skalierenden Gegnern
|
- freiwilliger Endlosmodus nach Welle 15 mit unbegrenzt weiter skalierenden Gegnern
|
||||||
- das Ende eines Endloslaufs zählt ausdrücklich nicht als gescheiterte Mission
|
- das Ende eines Endloslaufs zählt ausdrücklich nicht als gescheiterte Mission
|
||||||
@@ -86,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`.
|
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 fertigen Hinweise für die erste öffentliche Testversion stehen in `RELEASE_NOTES_0.1.0-alpha.md`.
|
Die Hinweise zum aktuellen Windows-Testbuild stehen in `RELEASE_NOTES_0.1.0-alpha.13.md`.
|
||||||
|
|
||||||
## Werbung und Datenschutz
|
## Werbung und Datenschutz
|
||||||
|
|
||||||
@@ -150,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
|
4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen
|
||||||
5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden
|
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
|
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
|
8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion
|
||||||
|
|
||||||
## Godot Agent Tools MCP
|
## Godot Agent Tools MCP
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.11
|
||||||
|
|
||||||
|
## Neu in dieser Testversion
|
||||||
|
|
||||||
|
- Private Koop-Räume können jetzt auch nach dem Wechsel in die Lobby Freunde einladen.
|
||||||
|
- Freunde lassen sich über den sichtbaren Pilotennamen suchen, statt lange technische Kennungen manuell zu kopieren.
|
||||||
|
- Die Schilde des Mutterschiff-Bosses in Welle 5 wurden halbiert.
|
||||||
|
- Online-Partieberichte werden beim Ende oder Verlassen automatisch pseudonymisiert an Nakama übertragen und mit einer gemeinsamen Rundenkennung für beide PCs abgelegt.
|
||||||
|
- Die geschützte Statistikseite enthält nun eine Übersicht und Detailansicht der Online-Diagnoseberichte.
|
||||||
|
|
||||||
|
## Testhinweise
|
||||||
|
|
||||||
|
- Für automatische Diagnoseberichte müssen beide Spieler Alpha 11 und der aktualisierte Nakama-Server verwenden.
|
||||||
|
- Bitte mit zwei Konten Namenssuche, Freundeseinladung aus der Lobby und einen Online-Spielverlauf einschließlich Verlassen testen.
|
||||||
|
- Die Diagnoseberichte enthalten technische Messwerte, keine Namen, Chatnachrichten, IP-Adressen oder Raumcodes. Sie werden serverseitig nach 14 Tagen entfernt.
|
||||||
|
- Dies ist eine unsignierte Alpha-Testversion. Windows SmartScreen kann vor dem ersten Start warnen.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.12
|
||||||
|
|
||||||
|
## Balance-Konfiguration und Online-Korrekturen
|
||||||
|
|
||||||
|
- Türme und Gegner verwenden für Offline-Partien dieselbe zentrale Balance-Konfiguration wie für neue Online-Partien.
|
||||||
|
- Angemeldete Spieler laden freigegebene Werte automatisch; der letzte gültige Stand wird lokal zwischengespeichert und bei fehlender Verbindung weiterverwendet.
|
||||||
|
- Laufende Online-Partien behalten ihren beim Start geladenen Balance-Schnappschuss.
|
||||||
|
- Der erste Boss hat weniger Hüllen- und Schildpunkte.
|
||||||
|
- Die interne Statistikseite kann Balance-Werte für Türme und Gegner bearbeiten.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Das Duellwerkzeug ist eine gezielte Einzelkonfrontation und kein vollständiger automatisierter Durchspieltest. Das Spielbalancing sollte zusätzlich mit echten Missionsläufen geprüft werden.
|
||||||
|
- Balance-Änderungen auf dem Server wirken auf neue Online-Partien sofort und auf Offline-Partien nach dem nächsten Start mit erreichbarem Server; bei Offline-Nutzung ohne Verbindung gelten Cache oder eingebaute Werte.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.13
|
||||||
|
|
||||||
|
## Balance- und Onlinekorrekturen
|
||||||
|
|
||||||
|
- Der Online-Server übernimmt den ausgewählten Schwierigkeitsgrad „Leicht“ jetzt korrekt, statt ihn auf „Mittel“ zurückzusetzen.
|
||||||
|
- Flak-Schaden pro Treffer: 20 → 22.
|
||||||
|
- Belagerer-Grundhülle: 740 → 620. Das wirkt sich auch auf die daraus berechneten Schilde aus.
|
||||||
|
- Gespeicherte Balancewerte aus Schema 1 werden beim ersten Lesen auf Schema 2 migriert. Nur Felder, die noch exakt den alten Flak-/Belagerer-Standardwerten entsprechen, werden angepasst; andere eigene Admin-Balancewerte bleiben erhalten.
|
||||||
|
- Ein reproduzierbarer 15-Wellen-Missionstest ergänzt die vorhandenen Runtime- und Turm-Gegner-Duelle.
|
||||||
|
|
||||||
|
## Prüfumfang
|
||||||
|
|
||||||
|
- `node server/nakama/tests/runtime_sim_test.js`
|
||||||
|
- `node server/nakama/tests/balance_mission_test.js`
|
||||||
|
- `node server/nakama/tests/balance_duel_test.js`
|
||||||
|
- PHP-Syntaxprüfung der internen Statistikseite
|
||||||
|
|
||||||
|
Der Missionslauf ist ein einfacher Level-1-Referenz-Bot ohne Upgrades und kein Ersatz für Spieltests mit echten Spielern.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.5
|
||||||
|
|
||||||
|
Diese Windows-Testversion erweitert die Gegnerflotte und zieht die Balance der schwierigeren Einsätze an.
|
||||||
|
|
||||||
|
## Neu
|
||||||
|
|
||||||
|
- drei zusätzliche Gegnertypen: Abfangjäger, Schildträger und Belagerungszerstörer
|
||||||
|
- neue Flugzeug- und Schiffsgrafiken für die zusätzlichen Gegnertypen
|
||||||
|
|
||||||
|
## Verbessert
|
||||||
|
|
||||||
|
- höhere Gegnerzahl und stärkere Hüllenwerte in mittleren und späten Wellen auf den Schwierigkeitsgraden Mittel und Schwer
|
||||||
|
- fortgeschrittene Gegner erscheinen auf höheren Schwierigkeitsgraden früher und häufiger
|
||||||
|
- Wellen nach dem Missionssieg skalieren im Endlosmodus weiter
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Void Bastion befindet sich weiterhin in einer frühen Alpha-Phase. Unter Windows erscheint keine Werbung. Updates werden im Spiel angekündigt, aber niemals ungefragt installiert. Ruckmeldungen und Fehlerberichte bitte an support@gergames.de senden.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.6
|
||||||
|
|
||||||
|
## Luft- und Bodeneinheiten
|
||||||
|
|
||||||
|
- Drei neue Bodengegner: schneller Rover, schwerer Panzer und Belagerungsläufer.
|
||||||
|
- Alle drei Bodeneinheiten haben jetzt deutlich sichtbare leuchtend grüne Akzente.
|
||||||
|
- Türme zeigen ihre Zielklasse an und greifen nur passende Einheiten an. Laser, Plasma und Railgun können beide Klassen treffen, verursachen dabei aber weniger Schaden.
|
||||||
|
- Für ausgewählte Türme lässt sich die Zielpriorität zwischen erster, letzter, höchster verbleibender Hülle und Schildzielen wechseln. Lasertürme priorisieren Schildgegner immer automatisch, sowohl lokal als auch im Online-Koop.
|
||||||
|
- Schildgegner haben jetzt ein eigenes Schild-Lebenskonto. Lasertreffer bauen es zuerst ab; überschüssiger Schaden geht danach auf die Hülle. Das Schild wird als blauer Ring dargestellt, dessen verbleibender Anteil gegen den Uhrzeigersinn schrumpft.
|
||||||
|
- In der Wellenvorschau zeigen gleich gestaltete grüne Richtungspfeile nach oben für Luft und nach unten für Boden.
|
||||||
|
- Die Vorschau nennt jetzt zusätzlich die Gesamtzahl aller Schiffe der Welle und zeigt die Stückzahl pro sichtbarem Gegnertyp gut lesbar neben dessen Miniatur.
|
||||||
|
- Gegnerwellen werden vorab deterministisch geplant. Die HUD-Leiste zeigt kompakt die aktuelle und nächste Welle mit Kategorie, Einheitensymbol und Anzahl.
|
||||||
|
- Im lokalen Testmodus lässt sich die Gegner-Lebenspunkte-Stärke mit **F9** einblenden und über **− / +** zwischen **0,5× und 3,0×** einstellen. Der Faktor gilt auch für bereits aktive lokale Gegner und ist im Online-Koop gesperrt.
|
||||||
|
- Dasselbe Testfeld kann mit **−25 / +25** den lokalen Kernkristallstand für Shop- und Freischalttests verändern; der Teststand wird gespeichert und fällt nie unter null.
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Diese Änderung ist ein Entwicklungsstand und wurde noch nicht als öffentliches Setup veröffentlicht. Online-Koop-Wellen verwenden dieselben Einheitentypen, Klassenregeln und Wellenplanung; der lokale Testmultiplikator verändert keine Online-Partie.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.7
|
||||||
|
|
||||||
|
## Neue Einheiten und Türme
|
||||||
|
|
||||||
|
- Drei Bodengegner – Rover, Panzer und Belagerungsläufer – sind mit grünen Leuchtelementen besser von Lufteinheiten unterscheidbar.
|
||||||
|
- Die Turmauswahl ist nach Zielklasse sortiert: zuerst reine Luftabwehr, dann Bodenabwehr, danach Mehrzwecktürme. Aktuell gibt es noch keinen reinen Luftabwehrturm.
|
||||||
|
- Die Felder der Turmauswahl sind leicht eingefärbt: Rot für Luft, Grün für Boden und Blau-Cyan für Luft/Boden.
|
||||||
|
- Der Railgun-Strahl ist lila und durchschlägt alle Gegner auf seiner Schusslinie, auch Ziele hinter dem zuerst anvisierten Gegner. Schilde werden weiterhin durchschlagen.
|
||||||
|
- Der EMP-Puls trifft Luft- und Bodeneinheiten im Explosionsradius mit je 20 Schaden.
|
||||||
|
- Schildgegner, Zielprioritäten und die Wellenübersicht zeigen jetzt die Luft-/Bodenklassen und Stückzahlen.
|
||||||
|
- Im lokalen Entwicklermodus lassen sich Gegnerstärke und Kernkristalle zum Testen anpassen.
|
||||||
|
|
||||||
|
## Testhinweise
|
||||||
|
|
||||||
|
- Spiel- und Servertests wurden im Headless-Modus ausgeführt.
|
||||||
|
- Windows-Setup und Online-Koop müssen vor einer öffentlichen Freigabe zusätzlich praktisch auf dem Zielsystem geprüft werden.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Void Bastion 0.1.0-alpha.8
|
||||||
|
|
||||||
|
## Fehlerbehebung für Windows-Updates
|
||||||
|
|
||||||
|
- Der Windows-Export wurde frisch erzeugt und anschließend vor dem Verpacken headless gestartet, um beschädigte eingebettete Projektdaten zu erkennen.
|
||||||
|
- Das Buildskript bricht künftig ab, wenn `project.binary` nicht geladen werden kann; ein fehlerhafter NSIS-Installer wird so nicht mehr gebaut.
|
||||||
|
- Enthält die Spiel- und Balance-Änderungen aus Alpha 7.
|
||||||
|
|
||||||
|
## Testhinweise
|
||||||
|
|
||||||
|
- Der exportierte Windows-Build wurde headless gestartet und konnte die eingebetteten Godot-Projektdaten laden.
|
||||||
|
- Smoke-Test und Nakama-Simulation wurden ausgeführt.
|
||||||
@@ -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.
|
||||||
@@ -14,6 +14,8 @@ var primary_provider := "guest"
|
|||||||
var linked_identities: Array = []
|
var linked_identities: Array = []
|
||||||
var highscores: Dictionary = {}
|
var highscores: Dictionary = {}
|
||||||
var pending_submissions: Array = []
|
var pending_submissions: Array = []
|
||||||
|
var pending_player_events: Array = []
|
||||||
|
var next_player_event_sequence := 0
|
||||||
var created_at := 0
|
var created_at := 0
|
||||||
var storage_enabled := true
|
var storage_enabled := true
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ func _load_or_create_profile() -> void:
|
|||||||
linked_identities = data.get("linked_identities", [])
|
linked_identities = data.get("linked_identities", [])
|
||||||
highscores = data.get("highscores", {})
|
highscores = data.get("highscores", {})
|
||||||
pending_submissions = data.get("pending_submissions", [])
|
pending_submissions = data.get("pending_submissions", [])
|
||||||
|
pending_player_events = data.get("pending_player_events", [])
|
||||||
|
next_player_event_sequence = int(data.get("next_player_event_sequence", 0))
|
||||||
created_at = int(data.get("created_at", 0))
|
created_at = int(data.get("created_at", 0))
|
||||||
if player_id.is_empty():
|
if player_id.is_empty():
|
||||||
_create_guest_profile()
|
_create_guest_profile()
|
||||||
@@ -50,6 +54,8 @@ func _create_guest_profile() -> void:
|
|||||||
linked_identities = []
|
linked_identities = []
|
||||||
highscores = {}
|
highscores = {}
|
||||||
pending_submissions = []
|
pending_submissions = []
|
||||||
|
pending_player_events = []
|
||||||
|
next_player_event_sequence = 0
|
||||||
created_at = int(Time.get_unix_time_from_system())
|
created_at = int(Time.get_unix_time_from_system())
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +72,8 @@ func _save_profile() -> void:
|
|||||||
"linked_identities": linked_identities,
|
"linked_identities": linked_identities,
|
||||||
"highscores": highscores,
|
"highscores": highscores,
|
||||||
"pending_submissions": pending_submissions,
|
"pending_submissions": pending_submissions,
|
||||||
|
"pending_player_events": pending_player_events,
|
||||||
|
"next_player_event_sequence": next_player_event_sequence,
|
||||||
"created_at": created_at
|
"created_at": created_at
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -91,6 +99,29 @@ func record_run(map_index: int, difficulty_index: int, endless: bool, reached_wa
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func queue_player_event(event_name: String, tower_type := -1) -> void:
|
||||||
|
if event_name != "play_start" and event_name != "tower_build":
|
||||||
|
return
|
||||||
|
next_player_event_sequence += 1
|
||||||
|
pending_player_events.append({
|
||||||
|
"event": event_name,
|
||||||
|
"tower_type": tower_type,
|
||||||
|
"source": player_id,
|
||||||
|
"sequence": next_player_event_sequence,
|
||||||
|
"at": int(Time.get_unix_time_from_system() * 1000.0)
|
||||||
|
})
|
||||||
|
_save_profile()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_player_event_complete(sequence: int) -> void:
|
||||||
|
for index in range(pending_player_events.size() - 1, -1, -1):
|
||||||
|
var event = pending_player_events[index]
|
||||||
|
if event is Dictionary and int(event.get("sequence", -1)) == sequence:
|
||||||
|
pending_player_events.remove_at(index)
|
||||||
|
_save_profile()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
func attach_online_identity(online_user_id: String, online_username: String) -> void:
|
func attach_online_identity(online_user_id: String, online_username: String) -> void:
|
||||||
if online_user_id.is_empty():
|
if online_user_id.is_empty():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
4.7.2.stable
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bnkdjurvbhyxn"
|
||||||
|
path="res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_ground_rover.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_ground_rover.png-78b1629e1d50e53aefbd44cf187db541.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dw874rt1gjjq4"
|
||||||
|
path="res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_ground_tank.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_ground_tank.png-a69deadc2b1612bb3304c457aae904a7.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cpa443bn3712c"
|
||||||
|
path="res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_ground_walker.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_ground_walker.png-97fb77912dc8b92904da3219a9ad1771.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dq8ng1q3sipdj"
|
||||||
|
path="res://.godot/imported/enemy_interceptor.png-25fe15248366da5309a562c7fda1695b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_interceptor.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_interceptor.png-25fe15248366da5309a562c7fda1695b.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://d0c55da7vg2x8"
|
||||||
|
path="res://.godot/imported/enemy_shield_carrier.png-5797af82d40d1f3dfd002a92a5efe490.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_shield_carrier.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_shield_carrier.png-5797af82d40d1f3dfd002a92a5efe490.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://7vnc3kiipsqj"
|
||||||
|
path="res://.godot/imported/enemy_siege_destroyer.png-b87dfe2af9a477c9a1f1ee85d55de962.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/enemy_siege_destroyer.png"
|
||||||
|
dest_files=["res://.godot/imported/enemy_siege_destroyer.png-b87dfe2af9a477c9a1f1ee85d55de962.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
+4
-2
@@ -24,8 +24,8 @@ application/modify_resources=true
|
|||||||
application/icon="res://assets/app_icon.png"
|
application/icon="res://assets/app_icon.png"
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.1.0.4"
|
application/file_version="0.1.0.11"
|
||||||
application/product_version="0.1.0.4"
|
application/product_version="0.1.0.11"
|
||||||
application/company_name="GerGames"
|
application/company_name="GerGames"
|
||||||
application/product_name="Void Bastion"
|
application/product_name="Void Bastion"
|
||||||
application/file_description="Void Bastion – Alpha-Testversion"
|
application/file_description="Void Bastion – Alpha-Testversion"
|
||||||
@@ -62,6 +62,7 @@ architectures/x86=false
|
|||||||
architectures/x86_64=false
|
architectures/x86_64=false
|
||||||
package/name="Void Bastion"
|
package/name="Void Bastion"
|
||||||
package/unique_name="de.gergames.voidbastion"
|
package/unique_name="de.gergames.voidbastion"
|
||||||
|
version/code=2
|
||||||
package/signed=true
|
package/signed=true
|
||||||
screen/immersive_mode=true
|
screen/immersive_mode=true
|
||||||
permissions/internet=true
|
permissions/internet=true
|
||||||
@@ -118,6 +119,7 @@ architectures/x86=false
|
|||||||
architectures/x86_64=true
|
architectures/x86_64=true
|
||||||
package/name="Void Bastion"
|
package/name="Void Bastion"
|
||||||
package/unique_name="de.gergames.voidbastion"
|
package/unique_name="de.gergames.voidbastion"
|
||||||
|
version/code=2
|
||||||
package/signed=true
|
package/signed=true
|
||||||
screen/immersive_mode=true
|
screen/immersive_mode=true
|
||||||
permissions/internet=true
|
permissions/internet=true
|
||||||
+501
-9
@@ -10,6 +10,8 @@ signal match_found(match_data)
|
|||||||
signal match_snapshot_received(snapshot: Dictionary)
|
signal match_snapshot_received(snapshot: Dictionary)
|
||||||
signal match_event_received(event: Dictionary)
|
signal match_event_received(event: Dictionary)
|
||||||
signal private_room_created(room_code: String)
|
signal private_room_created(room_code: String)
|
||||||
|
signal friends_changed()
|
||||||
|
signal match_invite_received(invite: Dictionary)
|
||||||
|
|
||||||
const SERVER_KEY := "voidbastion-public-v1"
|
const SERVER_KEY := "voidbastion-public-v1"
|
||||||
const PRODUCTION_HOST := "api.void-bastion.gergames.de"
|
const PRODUCTION_HOST := "api.void-bastion.gergames.de"
|
||||||
@@ -22,6 +24,7 @@ const OP_UPGRADE := 11
|
|||||||
const OP_SELL := 12
|
const OP_SELL := 12
|
||||||
const OP_START_WAVE := 13
|
const OP_START_WAVE := 13
|
||||||
const OP_READY := 14
|
const OP_READY := 14
|
||||||
|
const MAX_PENDING_SNAPSHOTS := 5
|
||||||
|
|
||||||
var client: NakamaClient
|
var client: NakamaClient
|
||||||
var session: NakamaSession
|
var session: NakamaSession
|
||||||
@@ -37,6 +40,41 @@ var intentional_disconnect := false
|
|||||||
var reconnect_in_progress := false
|
var reconnect_in_progress := false
|
||||||
var account_manager: AccountManager
|
var account_manager: AccountManager
|
||||||
var auto_connect_enabled := true
|
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 snapshot_queue_dropped_count := 0
|
||||||
|
var snapshot_queue_max := 0
|
||||||
|
var snapshot_bytes_total := 0
|
||||||
|
var snapshot_tick_previous := -1
|
||||||
|
var snapshot_tick_jump_max := 0
|
||||||
|
var snapshot_tick_jumps := 0
|
||||||
|
var snapshot_gap_samples: Array[int] = []
|
||||||
|
var diagnostic_file: FileAccess
|
||||||
|
var diagnostic_path := ""
|
||||||
|
var diagnostic_match_key := ""
|
||||||
|
var diagnostic_match_id := ""
|
||||||
|
var diagnostic_finished_match_key := ""
|
||||||
|
var diagnostic_open_failed := false
|
||||||
|
var diagnostic_slot := -1
|
||||||
|
var diagnostic_frame_max_ms := 0
|
||||||
|
var diagnostic_frame_stalls := 0
|
||||||
|
var diagnostic_apply_max_us := 0
|
||||||
|
var diagnostic_enemy_count := 0
|
||||||
|
var diagnostic_tower_count := 0
|
||||||
|
var diagnostic_last_summary := ""
|
||||||
|
var diagnostic_records: Array[Dictionary] = []
|
||||||
|
var pending_snapshot_payloads: Array[String] = []
|
||||||
|
var friends: Array[Dictionary] = []
|
||||||
|
var friend_statuses: Dictionary = {}
|
||||||
|
var presence_status := ""
|
||||||
|
var player_event_flush_busy := false
|
||||||
|
var player_event_flush_again := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -46,6 +84,220 @@ func _ready() -> void:
|
|||||||
call_deferred("connect_and_authenticate")
|
call_deferred("connect_and_authenticate")
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_flush_pending_snapshots()
|
||||||
|
if diagnostic_file != null and Time.get_ticks_msec() - snapshot_report_ms >= 10000:
|
||||||
|
_report_diagnostic_window()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
var server_tick := int(parsed.get("tick", -1))
|
||||||
|
if server_tick >= 0:
|
||||||
|
if snapshot_tick_previous >= 0:
|
||||||
|
var tick_jump := server_tick - snapshot_tick_previous
|
||||||
|
snapshot_tick_jump_max = maxi(snapshot_tick_jump_max, tick_jump)
|
||||||
|
if tick_jump > 1:
|
||||||
|
snapshot_tick_jumps += 1
|
||||||
|
if tick_jump > 3:
|
||||||
|
_write_diagnostic_record("tick_jump", {"from_tick": snapshot_tick_previous, "to_tick": server_tick, "steps": tick_jump})
|
||||||
|
snapshot_tick_previous = server_tick
|
||||||
|
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 _begin_match_diagnostics() -> void:
|
||||||
|
if current_match == null:
|
||||||
|
return
|
||||||
|
var match_key := String(current_match.match_id).sha256_text().substr(0, 12)
|
||||||
|
if match_key == diagnostic_finished_match_key:
|
||||||
|
return
|
||||||
|
if diagnostic_match_key == match_key and (diagnostic_file != null or diagnostic_open_failed):
|
||||||
|
return
|
||||||
|
if diagnostic_file != null:
|
||||||
|
_finish_match_diagnostics("new_match")
|
||||||
|
diagnostic_match_key = match_key
|
||||||
|
diagnostic_match_id = String(current_match.match_id)
|
||||||
|
diagnostic_finished_match_key = ""
|
||||||
|
diagnostic_open_failed = false
|
||||||
|
diagnostic_slot = -1
|
||||||
|
diagnostic_records.clear()
|
||||||
|
snapshot_previous_ms = 0
|
||||||
|
snapshot_report_ms = Time.get_ticks_msec()
|
||||||
|
snapshot_tick_previous = -1
|
||||||
|
_reset_diagnostic_window()
|
||||||
|
var stamp := Time.get_datetime_string_from_system(true).replace(":", "-")
|
||||||
|
diagnostic_path = "user://online-diag-%s-%s-%d.jsonl" % [stamp, match_key, Time.get_ticks_usec()]
|
||||||
|
diagnostic_file = FileAccess.open(diagnostic_path, FileAccess.WRITE)
|
||||||
|
if diagnostic_file == null:
|
||||||
|
diagnostic_open_failed = true
|
||||||
|
push_warning("Online-Diagnose konnte nicht gespeichert werden: %s" % diagnostic_path)
|
||||||
|
return
|
||||||
|
_write_diagnostic_record("start", {
|
||||||
|
"match": match_key,
|
||||||
|
"host": current_host,
|
||||||
|
"version": str(ProjectSettings.get_setting("application/config/version", "")),
|
||||||
|
"transport": "nakama_websocket",
|
||||||
|
"expected_snapshots_per_second": 10
|
||||||
|
})
|
||||||
|
print("ONLINE DIAG file=%s" % ProjectSettings.globalize_path(diagnostic_path))
|
||||||
|
|
||||||
|
|
||||||
|
func set_diagnostic_slot(slot: int, viewing_right: bool) -> void:
|
||||||
|
if diagnostic_slot == slot:
|
||||||
|
return
|
||||||
|
diagnostic_slot = slot
|
||||||
|
_write_diagnostic_record("side", {"slot": slot, "viewing_right": viewing_right})
|
||||||
|
|
||||||
|
|
||||||
|
func record_online_frame(delta: float) -> void:
|
||||||
|
if diagnostic_file == null:
|
||||||
|
return
|
||||||
|
var frame_ms := roundi(delta * 1000.0)
|
||||||
|
diagnostic_frame_max_ms = maxi(diagnostic_frame_max_ms, frame_ms)
|
||||||
|
if frame_ms > 200:
|
||||||
|
diagnostic_frame_stalls += 1
|
||||||
|
if frame_ms > 1000:
|
||||||
|
_write_diagnostic_record("frame_stall", {"duration_ms": frame_ms, "last_server_tick": snapshot_tick_previous})
|
||||||
|
|
||||||
|
|
||||||
|
func record_snapshot_apply(duration_us: int, enemy_count: int, tower_count: int) -> void:
|
||||||
|
diagnostic_apply_max_us = maxi(diagnostic_apply_max_us, duration_us)
|
||||||
|
diagnostic_enemy_count = enemy_count
|
||||||
|
diagnostic_tower_count = tower_count
|
||||||
|
|
||||||
|
|
||||||
|
func _write_diagnostic_record(event_name: String, details: Dictionary) -> void:
|
||||||
|
var record := details.duplicate(true)
|
||||||
|
record["event"] = event_name
|
||||||
|
record["utc"] = Time.get_datetime_string_from_system(true)
|
||||||
|
record["slot"] = diagnostic_slot
|
||||||
|
if event_name != "start":
|
||||||
|
diagnostic_records.append(record)
|
||||||
|
if diagnostic_records.size() > 160:
|
||||||
|
diagnostic_records.pop_front()
|
||||||
|
if diagnostic_file == null:
|
||||||
|
return
|
||||||
|
diagnostic_file.store_line(JSON.stringify(record))
|
||||||
|
diagnostic_file.flush()
|
||||||
|
|
||||||
|
|
||||||
|
func _report_diagnostic_window() -> void:
|
||||||
|
var now_ms := Time.get_ticks_msec()
|
||||||
|
var sorted_gaps := snapshot_gap_samples.duplicate()
|
||||||
|
sorted_gaps.sort()
|
||||||
|
var p95_gap := 0
|
||||||
|
if not sorted_gaps.is_empty():
|
||||||
|
p95_gap = sorted_gaps[clampi(int(ceil(float(sorted_gaps.size()) * 0.95)) - 1, 0, sorted_gaps.size() - 1)]
|
||||||
|
var seconds := maxf(0.001, float(now_ms - snapshot_report_ms) / 1000.0)
|
||||||
|
var record := {
|
||||||
|
"seconds": snappedf(seconds, 0.001),
|
||||||
|
"snapshots": snapshot_count,
|
||||||
|
"snapshots_per_second": snappedf(float(snapshot_count) / seconds, 0.01),
|
||||||
|
"gap_p95_ms": p95_gap,
|
||||||
|
"gap_max_ms": snapshot_gap_max_ms,
|
||||||
|
"gaps_over_500_ms": snapshot_large_gap_count,
|
||||||
|
"last_server_tick": snapshot_tick_previous,
|
||||||
|
"tick_jump_max": snapshot_tick_jump_max,
|
||||||
|
"tick_jumps_over_1": snapshot_tick_jumps,
|
||||||
|
"bytes_avg": snapshot_bytes_total / maxi(1, snapshot_count),
|
||||||
|
"bytes_max": snapshot_bytes_max,
|
||||||
|
"parse_max_us": snapshot_parse_max_us,
|
||||||
|
"apply_max_us": diagnostic_apply_max_us,
|
||||||
|
"frame_max_ms": diagnostic_frame_max_ms,
|
||||||
|
"frame_stalls_over_200_ms": diagnostic_frame_stalls,
|
||||||
|
"coalesced": snapshot_coalesced_count,
|
||||||
|
"queue_dropped": snapshot_queue_dropped_count,
|
||||||
|
"queue_max": snapshot_queue_max,
|
||||||
|
"enemies": diagnostic_enemy_count,
|
||||||
|
"towers": diagnostic_tower_count
|
||||||
|
}
|
||||||
|
diagnostic_last_summary = JSON.stringify(record)
|
||||||
|
_write_diagnostic_record("window", record)
|
||||||
|
print("ONLINE DIAG %s" % diagnostic_last_summary)
|
||||||
|
snapshot_report_ms = now_ms
|
||||||
|
_reset_diagnostic_window()
|
||||||
|
|
||||||
|
|
||||||
|
func _reset_diagnostic_window() -> void:
|
||||||
|
snapshot_count = 0
|
||||||
|
snapshot_gap_max_ms = 0
|
||||||
|
snapshot_bytes_max = 0
|
||||||
|
snapshot_parse_max_us = 0
|
||||||
|
snapshot_large_gap_count = 0
|
||||||
|
snapshot_coalesced_count = 0
|
||||||
|
snapshot_queue_dropped_count = 0
|
||||||
|
snapshot_queue_max = 0
|
||||||
|
snapshot_bytes_total = 0
|
||||||
|
snapshot_tick_jump_max = 0
|
||||||
|
snapshot_tick_jumps = 0
|
||||||
|
snapshot_gap_samples.clear()
|
||||||
|
diagnostic_frame_max_ms = 0
|
||||||
|
diagnostic_frame_stalls = 0
|
||||||
|
diagnostic_apply_max_us = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_match_diagnostics(reason: String) -> void:
|
||||||
|
if diagnostic_match_key.is_empty():
|
||||||
|
return
|
||||||
|
if snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000:
|
||||||
|
_report_diagnostic_window()
|
||||||
|
_write_diagnostic_record("end", {"reason": reason})
|
||||||
|
var match_id := diagnostic_match_id
|
||||||
|
if not match_id.is_empty() and is_authenticated() and not diagnostic_records.is_empty():
|
||||||
|
var report := {"match_id": match_id, "version": str(ProjectSettings.get_setting("application/config/version", "development")),
|
||||||
|
"records": diagnostic_records.duplicate(true)}
|
||||||
|
while JSON.stringify(report).to_utf8_buffer().size() > 60000 and report["records"].size() > 1:
|
||||||
|
report["records"].pop_front()
|
||||||
|
_upload_match_diagnostics.call_deferred(report)
|
||||||
|
diagnostic_file = null
|
||||||
|
diagnostic_finished_match_key = diagnostic_match_key
|
||||||
|
diagnostic_match_key = ""
|
||||||
|
diagnostic_match_id = ""
|
||||||
|
diagnostic_open_failed = false
|
||||||
|
diagnostic_records.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _upload_match_diagnostics(report: Dictionary) -> void:
|
||||||
|
for attempt in 3:
|
||||||
|
if not is_authenticated():
|
||||||
|
break
|
||||||
|
var result = await client.rpc_async(session, "submit_online_diagnostics", JSON.stringify(report))
|
||||||
|
var parsed := _parse_rpc_result(result)
|
||||||
|
if bool(parsed.get("accepted", false)):
|
||||||
|
print("ONLINE DIAG hochgeladen: Runde %s, Seite %d" % [String(parsed.get("round_id", "")), int(parsed.get("slot", -1))])
|
||||||
|
return
|
||||||
|
await get_tree().create_timer(2.0 * (attempt + 1)).timeout
|
||||||
|
push_warning("Online-Diagnose konnte nicht hochgeladen werden; lokale Datei bleibt erhalten.")
|
||||||
|
|
||||||
|
|
||||||
|
func get_diagnostic_bundle() -> String:
|
||||||
|
if diagnostic_file != null and (snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000):
|
||||||
|
_report_diagnostic_window()
|
||||||
|
if diagnostic_path.is_empty() or not FileAccess.file_exists(diagnostic_path):
|
||||||
|
return "Noch keine Online-Diagnose vorhanden."
|
||||||
|
return "Void Bastion Online-Diagnose\nDatei: %s\n\n%s" % [ProjectSettings.globalize_path(diagnostic_path), FileAccess.get_file_as_string(diagnostic_path)]
|
||||||
|
|
||||||
|
|
||||||
func _is_headless_or_editor() -> bool:
|
func _is_headless_or_editor() -> bool:
|
||||||
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
|
return DisplayServer.get_name() == "headless" or Engine.is_editor_hint()
|
||||||
|
|
||||||
@@ -96,7 +348,10 @@ func _after_authentication() -> void:
|
|||||||
else:
|
else:
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
_set_connection_state("online")
|
_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)
|
authenticated.emit(session.user_id, session.username)
|
||||||
|
await flush_pending_player_events()
|
||||||
await flush_pending_scores()
|
await flush_pending_scores()
|
||||||
|
|
||||||
|
|
||||||
@@ -110,19 +365,63 @@ func _record_activity() -> void:
|
|||||||
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
await client.rpc_async(session, "record_activity", JSON.stringify(payload))
|
||||||
|
|
||||||
|
|
||||||
|
func record_local_play_start() -> void:
|
||||||
|
_queue_player_event("play_start")
|
||||||
|
|
||||||
|
|
||||||
|
func record_local_tower_build(tower_type: int) -> void:
|
||||||
|
if tower_type < 0 or tower_type >= 7:
|
||||||
|
return
|
||||||
|
_queue_player_event("tower_build", tower_type)
|
||||||
|
|
||||||
|
|
||||||
|
func _queue_player_event(event_name: String, tower_type := -1) -> void:
|
||||||
|
if not account_manager:
|
||||||
|
return
|
||||||
|
account_manager.queue_player_event(event_name, tower_type)
|
||||||
|
if is_authenticated():
|
||||||
|
flush_pending_player_events()
|
||||||
|
|
||||||
|
|
||||||
|
func flush_pending_player_events() -> void:
|
||||||
|
if player_event_flush_busy:
|
||||||
|
player_event_flush_again = true
|
||||||
|
return
|
||||||
|
if not account_manager or not is_authenticated():
|
||||||
|
return
|
||||||
|
player_event_flush_busy = true
|
||||||
|
while is_authenticated() and not account_manager.pending_player_events.is_empty():
|
||||||
|
var event: Dictionary = account_manager.pending_player_events[0]
|
||||||
|
var result = await client.rpc_async(session, "record_player_event", JSON.stringify(event))
|
||||||
|
var parsed := _parse_rpc_result(result)
|
||||||
|
if not bool(parsed.get("accepted", false)):
|
||||||
|
break
|
||||||
|
account_manager.mark_player_event_complete(int(event.get("sequence", -1)))
|
||||||
|
player_event_flush_busy = false
|
||||||
|
if player_event_flush_again:
|
||||||
|
player_event_flush_again = false
|
||||||
|
call_deferred("flush_pending_player_events")
|
||||||
|
|
||||||
|
|
||||||
func _bind_socket_signals() -> void:
|
func _bind_socket_signals() -> void:
|
||||||
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
socket.received_matchmaker_matched.connect(_on_matchmaker_matched)
|
||||||
socket.received_match_state.connect(_on_match_state)
|
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)
|
socket.closed.connect(_on_socket_closed)
|
||||||
|
|
||||||
|
|
||||||
func disconnect_from_server() -> void:
|
func disconnect_from_server() -> void:
|
||||||
intentional_disconnect = true
|
intentional_disconnect = true
|
||||||
|
pending_snapshot_payloads.clear()
|
||||||
if socket:
|
if socket:
|
||||||
socket.close()
|
socket.close()
|
||||||
socket = null
|
socket = null
|
||||||
session = null
|
session = null
|
||||||
current_match = null
|
current_match = null
|
||||||
|
friends.clear()
|
||||||
|
friend_statuses.clear()
|
||||||
|
presence_status = ""
|
||||||
matchmaker_ticket = ""
|
matchmaker_ticket = ""
|
||||||
private_room_code = ""
|
private_room_code = ""
|
||||||
lobby_ready = false
|
lobby_ready = false
|
||||||
@@ -136,6 +435,13 @@ func server_info() -> Dictionary:
|
|||||||
return _parse_rpc_result(result)
|
return _parse_rpc_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
func fetch_game_balance() -> Dictionary:
|
||||||
|
if not is_authenticated():
|
||||||
|
return {}
|
||||||
|
var result = await client.rpc_async(session, "get_game_balance", "{}")
|
||||||
|
return _parse_rpc_result(result)
|
||||||
|
|
||||||
|
|
||||||
func submit_run(record: Dictionary) -> bool:
|
func submit_run(record: Dictionary) -> bool:
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
return false
|
return false
|
||||||
@@ -190,6 +496,131 @@ func delete_online_account() -> Dictionary:
|
|||||||
return {"accepted": true}
|
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_display_name(display_name: String) -> Dictionary:
|
||||||
|
var normalized := display_name.strip_edges()
|
||||||
|
if not is_authenticated() or normalized.is_empty():
|
||||||
|
return {"accepted": false, "error": "offline"}
|
||||||
|
var lookup_result = await client.rpc_async(session, "find_player_by_name", JSON.stringify({"display_name": normalized}))
|
||||||
|
var found := _parse_rpc_result(lookup_result)
|
||||||
|
if not bool(found.get("found", false)):
|
||||||
|
return {"accepted": false, "error": String(found.get("error", "not_found"))}
|
||||||
|
var user_id := String(found.get("user_id", ""))
|
||||||
|
if user_id.is_empty() or user_id == session.user_id:
|
||||||
|
return {"accepted": false, "error": "self"}
|
||||||
|
var result = await client.add_friends_async(session, PackedStringArray([user_id]))
|
||||||
|
if result == null or result.is_exception():
|
||||||
|
return {"accepted": false, "error": "send_failed"}
|
||||||
|
await refresh_friends()
|
||||||
|
return {"accepted": true, "display_name": String(found.get("display_name", normalized))}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array:
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
return []
|
return []
|
||||||
@@ -245,18 +676,22 @@ func begin_matchmaking(mode := "quick", difficulty := 1) -> bool:
|
|||||||
|
|
||||||
func cancel_matchmaking() -> void:
|
func cancel_matchmaking() -> void:
|
||||||
intentional_disconnect = true
|
intentional_disconnect = true
|
||||||
|
pending_snapshot_payloads.clear()
|
||||||
|
_finish_match_diagnostics("cancelled_matchmaking")
|
||||||
|
var match_to_leave = current_match
|
||||||
|
current_match = null
|
||||||
if socket and not matchmaker_ticket.is_empty():
|
if socket and not matchmaker_ticket.is_empty():
|
||||||
await socket.remove_matchmaker_async(matchmaker_ticket)
|
await socket.remove_matchmaker_async(matchmaker_ticket)
|
||||||
matchmaker_ticket = ""
|
matchmaker_ticket = ""
|
||||||
if current_match != null and not private_room_code.is_empty():
|
if socket and match_to_leave != null:
|
||||||
await socket.leave_match_async(current_match.match_id)
|
await socket.leave_match_async(match_to_leave.match_id)
|
||||||
current_match = null
|
|
||||||
private_room_code = ""
|
private_room_code = ""
|
||||||
matchmaking_state_changed.emit("idle")
|
matchmaking_state_changed.emit("idle")
|
||||||
|
|
||||||
|
|
||||||
func create_private_room(difficulty := 1) -> bool:
|
func create_private_room(difficulty := 1) -> bool:
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
|
pending_snapshot_payloads.clear()
|
||||||
if not is_online() or socket == null:
|
if not is_online() or socket == null:
|
||||||
matchmaking_state_changed.emit("offline")
|
matchmaking_state_changed.emit("offline")
|
||||||
return false
|
return false
|
||||||
@@ -284,6 +719,7 @@ func create_private_room(difficulty := 1) -> bool:
|
|||||||
|
|
||||||
func join_private_room(room_code: String) -> bool:
|
func join_private_room(room_code: String) -> bool:
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
|
pending_snapshot_payloads.clear()
|
||||||
if not is_online() or socket == null:
|
if not is_online() or socket == null:
|
||||||
matchmaking_state_changed.emit("offline")
|
matchmaking_state_changed.emit("offline")
|
||||||
return false
|
return false
|
||||||
@@ -310,6 +746,7 @@ func join_private_room(room_code: String) -> bool:
|
|||||||
|
|
||||||
func _on_matchmaker_matched(matched) -> void:
|
func _on_matchmaker_matched(matched) -> void:
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
|
pending_snapshot_payloads.clear()
|
||||||
matchmaker_ticket = ""
|
matchmaker_ticket = ""
|
||||||
current_match = await socket.join_matched_async(matched)
|
current_match = await socket.join_matched_async(matched)
|
||||||
if current_match == null or current_match.is_exception():
|
if current_match == null or current_match.is_exception():
|
||||||
@@ -351,9 +788,12 @@ func request_wave_start() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func leave_current_match() -> void:
|
func leave_current_match() -> void:
|
||||||
if socket and current_match != null:
|
pending_snapshot_payloads.clear()
|
||||||
await socket.leave_match_async(current_match.match_id)
|
_finish_match_diagnostics("left_match")
|
||||||
|
var match_to_leave = current_match
|
||||||
current_match = null
|
current_match = null
|
||||||
|
if socket and match_to_leave != null:
|
||||||
|
await socket.leave_match_async(match_to_leave.match_id)
|
||||||
private_room_code = ""
|
private_room_code = ""
|
||||||
lobby_ready = false
|
lobby_ready = false
|
||||||
|
|
||||||
@@ -361,16 +801,40 @@ func leave_current_match() -> void:
|
|||||||
func _on_match_state(data) -> void:
|
func _on_match_state(data) -> void:
|
||||||
if current_match == null or data.match_id != current_match.match_id:
|
if current_match == null or data.match_id != current_match.match_id:
|
||||||
return
|
return
|
||||||
var parsed = JSON.parse_string(data.data)
|
|
||||||
if not parsed is Dictionary:
|
|
||||||
return
|
|
||||||
if data.op_code == OP_SNAPSHOT:
|
if data.op_code == OP_SNAPSHOT:
|
||||||
match_snapshot_received.emit(parsed)
|
_begin_match_diagnostics()
|
||||||
|
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)
|
||||||
|
snapshot_gap_samples.append(gap_ms)
|
||||||
|
if gap_ms > 500:
|
||||||
|
snapshot_large_gap_count += 1
|
||||||
|
_write_diagnostic_record("snapshot_gap", {"duration_ms": gap_ms, "last_server_tick": snapshot_tick_previous})
|
||||||
|
snapshot_previous_ms = now_ms
|
||||||
|
snapshot_count += 1
|
||||||
|
var payload_bytes := String(data.data).to_utf8_buffer().size()
|
||||||
|
snapshot_bytes_max = maxi(snapshot_bytes_max, payload_bytes)
|
||||||
|
snapshot_bytes_total += payload_bytes
|
||||||
|
pending_snapshot_payloads.append(String(data.data))
|
||||||
|
snapshot_queue_max = maxi(snapshot_queue_max, pending_snapshot_payloads.size())
|
||||||
|
if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS:
|
||||||
|
pending_snapshot_payloads.pop_front()
|
||||||
|
snapshot_queue_dropped_count += 1
|
||||||
elif data.op_code == OP_EVENT:
|
elif data.op_code == OP_EVENT:
|
||||||
|
var parsed = JSON.parse_string(data.data)
|
||||||
|
if parsed is Dictionary:
|
||||||
match_event_received.emit(parsed)
|
match_event_received.emit(parsed)
|
||||||
|
|
||||||
|
|
||||||
func _on_socket_closed() -> void:
|
func _on_socket_closed() -> void:
|
||||||
|
print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null))
|
||||||
|
_write_diagnostic_record("socket_closed", {"reconnect": 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 := ""
|
var reconnect_match_id := ""
|
||||||
if not intentional_disconnect and current_match != null:
|
if not intentional_disconnect and current_match != null:
|
||||||
reconnect_match_id = String(current_match.match_id)
|
reconnect_match_id = String(current_match.match_id)
|
||||||
@@ -381,6 +845,28 @@ func _on_socket_closed() -> void:
|
|||||||
reconnect_in_progress = true
|
reconnect_in_progress = true
|
||||||
matchmaking_state_changed.emit("reconnecting")
|
matchmaking_state_changed.emit("reconnecting")
|
||||||
_recover_match.call_deferred(reconnect_match_id)
|
_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:
|
func _recover_match(match_id: String) -> void:
|
||||||
@@ -395,13 +881,19 @@ func _recover_match(match_id: String) -> void:
|
|||||||
continue
|
continue
|
||||||
var rejoined = await socket.join_match_async(match_id)
|
var rejoined = await socket.join_match_async(match_id)
|
||||||
if rejoined != null and not rejoined.is_exception():
|
if rejoined != null and not rejoined.is_exception():
|
||||||
|
print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1))
|
||||||
|
_write_diagnostic_record("socket_reconnected", {"attempt": attempt + 1})
|
||||||
current_match = rejoined
|
current_match = rejoined
|
||||||
intentional_disconnect = false
|
intentional_disconnect = false
|
||||||
reconnect_in_progress = false
|
reconnect_in_progress = false
|
||||||
_set_connection_state("online")
|
_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")
|
matchmaking_state_changed.emit("reconnected")
|
||||||
return
|
return
|
||||||
reconnect_in_progress = false
|
reconnect_in_progress = false
|
||||||
|
print("ONLINE DIAG websocket_reconnect_failed")
|
||||||
|
_write_diagnostic_record("socket_reconnect_failed", {})
|
||||||
matchmaking_state_changed.emit("connection_lost")
|
matchmaking_state_changed.emit("connection_lost")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,31 @@ param(
|
|||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$GodotExe,
|
[string]$GodotExe,
|
||||||
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string]$MakeNsisExe
|
[string]$MakeNsisExe
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
$nsisCandidates = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($MakeNsisExe)) {
|
||||||
|
$nsisCandidates += $MakeNsisExe
|
||||||
|
} else {
|
||||||
|
$pathNsis = Get-Command "makensis.exe" -ErrorAction SilentlyContinue
|
||||||
|
if ($pathNsis) {
|
||||||
|
$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"
|
||||||
|
)
|
||||||
|
$MakeNsisExe = $nsisCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($MakeNsisExe)) {
|
||||||
|
throw "NSIS wurde nicht gefunden. Installiere NSIS oder übergib -MakeNsisExe mit dem Pfad zu makensis.exe."
|
||||||
|
}
|
||||||
|
|
||||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||||
$gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe"
|
$gameExe = Join-Path $projectRoot "build\windows\Void-Bastion.exe"
|
||||||
$installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe"
|
$installer = Join-Path $projectRoot "dist\Void-Bastion-Setup-x64.exe"
|
||||||
@@ -34,6 +54,15 @@ try {
|
|||||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $gameExe)) {
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $gameExe)) {
|
||||||
throw "Der Godot-Windows-Export ist fehlgeschlagen."
|
throw "Der Godot-Windows-Export ist fehlgeschlagen."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prüft, dass Godot die eingebetteten Projektdaten tatsächlich laden kann,
|
||||||
|
# bevor NSIS daraus einen Installer erstellt. Ein bloß vorhandenes EXE reicht
|
||||||
|
# nicht: ein defektes project.binary würde erst beim Start des Spiels auffallen.
|
||||||
|
$launchOutput = & $gameExe --headless --quit 2>&1 | Out-String
|
||||||
|
$launchExitCode = $LASTEXITCODE
|
||||||
|
if (($null -ne $launchExitCode -and $launchExitCode -ne 0) -or $launchOutput -match "Corrupted header in binary project\.binary|Couldn't load project data|Is the \.pck file missing") {
|
||||||
|
throw "Der exportierte Windows-Build kann seine Projektdaten nicht laden. NSIS-Installer wurde nicht erstellt. Ausgabe: $launchOutput"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Set-Content -LiteralPath $projectFile -Value $projectConfiguration -NoNewline
|
Set-Content -LiteralPath $projectFile -Value $projectConfiguration -NoNewline
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ RequestExecutionLevel user
|
|||||||
!include "LogicLib.nsh"
|
!include "LogicLib.nsh"
|
||||||
|
|
||||||
!define PRODUCT_NAME "Void Bastion"
|
!define PRODUCT_NAME "Void Bastion"
|
||||||
!define PRODUCT_VERSION "0.1.0-alpha.4"
|
!define PRODUCT_VERSION "0.1.0-alpha.16"
|
||||||
!define PRODUCT_PUBLISHER "GerGames"
|
!define PRODUCT_PUBLISHER "GerGames"
|
||||||
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
|
!define PRODUCT_WEBSITE "https://void-bastion.gergames.de/"
|
||||||
!define PRODUCT_EXE "Void-Bastion.exe"
|
!define PRODUCT_EXE "Void-Bastion.exe"
|
||||||
@@ -21,7 +21,7 @@ BrandingText "GerGames"
|
|||||||
ShowInstDetails show
|
ShowInstDetails show
|
||||||
ShowUninstDetails show
|
ShowUninstDetails show
|
||||||
|
|
||||||
VIProductVersion "0.1.0.4"
|
VIProductVersion "0.1.0.16"
|
||||||
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
|
VIAddVersionKey /LANG=1031 "ProductName" "${PRODUCT_NAME}"
|
||||||
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
|
VIAddVersionKey /LANG=1031 "CompanyName" "${PRODUCT_PUBLISHER}"
|
||||||
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
|
VIAddVersionKey /LANG=1031 "FileDescription" "${PRODUCT_NAME} Setup"
|
||||||
|
|||||||
+2
-3
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Void Bastion"
|
config/name="Void Bastion"
|
||||||
config/version="0.1.0-alpha.4"
|
config/version="0.1.0-alpha.16"
|
||||||
run/main_scene="res://main.tscn"
|
run/main_scene="res://main.tscn"
|
||||||
config/features=PackedStringArray("4.7")
|
config/features=PackedStringArray("4.7")
|
||||||
config/icon="res://assets/app_icon.png"
|
config/icon="res://assets/app_icon.png"
|
||||||
@@ -34,7 +34,7 @@ window/size/window_width_override=1440
|
|||||||
window/size/window_height_override=810
|
window/size/window_height_override=810
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
window/handheld/orientation=4
|
window/handheld/orientation=0
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
@@ -43,5 +43,4 @@ enabled=PackedStringArray("res://addons/agent_tools/plugin.cfg", "res://addons/A
|
|||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
|
||||||
textures/vram_compression/import_etc2_astc=true
|
textures/vram_compression/import_etc2_astc=true
|
||||||
+17
-1
@@ -28,9 +28,25 @@ 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.
|
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.
|
||||||
|
|
||||||
|
`find_player_by_name` löst einen sichtbaren Spielernamen per exakter, nicht groß-/kleinschreibungsabhängiger Suche in eine interne Benutzer-ID auf. Wenn mehrere Konten denselben Anzeigenamen haben, wird die Anfrage als `ambiguous` abgewiesen, statt versehentlich das falsche Konto hinzuzufügen. Der Client zeigt und kopiert im Freundemenü nur den sichtbaren Namen. Im privaten Warteraum ist für bestätigte Freunde `EINLADEN` verfügbar.
|
||||||
|
|
||||||
|
## Online-Diagnose
|
||||||
|
|
||||||
|
`submit_online_diagnostics` nimmt am Ende einer Online-Partie automatisch technische Berichte der beiden Clients entgegen. Der Server prüft anhand der beim Matchbeitritt gespeicherten Teilnehmerzuordnung, ob das sendende Konto zur Runde gehört. Beide Berichte werden mit derselben 16-stelligen Rundenkennung und unterschiedlichen Seitenzahlen gespeichert. Es werden nur erlaubte numerische Messwerte und vordefinierte Ereignisse übernommen; keine Namen, Nachrichten, IP-Adressen oder Raumcodes. Die Speicherung ist serverprivat und wird nach 14 Tagen beim nächsten Diagnoseabruf oder -upload bereinigt.
|
||||||
|
|
||||||
|
Die Server-zu-Server-RPC `list_online_diagnostics` liefert mit `{}` eine kompakte Übersicht. Mit `{"round_id":"<Kennung>"}` liefert sie beide vollständigen Seitenberichte. Der private HTTP-Schlüssel gehört ausschließlich in die Server-/Webhosting-Konfiguration, niemals in den Spielclient. Weitere Auswertungshinweise stehen in `ONLINE_DIAGNOSE.md`.
|
||||||
|
|
||||||
## Private Statistik-RPCs
|
## Private Statistik-RPCs
|
||||||
|
|
||||||
- `record_activity`: nur für angemeldete Spieler; aktualisiert die pseudonyme Aktivität sowie aggregierte Plattform- und Versionswerte.
|
- `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.
|
- `private_stats`: nur als Server-zu-Server-Aufruf mit `NAKAMA_HTTP_KEY`; liefert die zusammengefassten Werte für die interne Homepage.
|
||||||
|
- `get_game_balance`: authentifizierte Spielclients erhalten die freigegebene Balance-Konfiguration; `set_game_balance`: ausschließlich Server-zu-Server mit `NAKAMA_HTTP_KEY` darf sie ändern. Die Werte gelten für neue Online-Partien; `matchInit` lädt einen privaten Snapshot in den Match-Zustand.
|
||||||
|
- `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.
|
Balancing-Regressionstests: `node tests/runtime_sim_test.js` prüft Runtime- und RPC-Verhalten; `node tests/balance_duel_test.js` spielt reproduzierbare Einzelduelle mit der echten Server-Kampflogik durch; `node tests/balance_mission_test.js` simuliert 15-Wellen-Missionen mit festem Zufallsstart, Turmwahl nach Gegnerzusammensetzung und Level-1-Türmen. Der Missionstest ist ein strenger automatischer Referenz-Bot ohne Upgrades, keine Vorhersage menschlicher Spielergebnisse. Die Werte des Bots sollen als Ausgangspunkt für Balancevergleiche dienen, nicht als alleiniges Freigabekriterium. Alle Tests müssen nach Balanceänderungen erneut laufen.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
var GAME_VERSION = "0.23";
|
var GAME_VERSION = "0.27";
|
||||||
var MAP_COUNT = 7;
|
var MAP_COUNT = 7;
|
||||||
var DIFFICULTY_COUNT = 3;
|
var DIFFICULTY_COUNT = 3;
|
||||||
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
var VALID_MODES = ["mission", "endless", "coop_ai", "coop_online"];
|
||||||
@@ -6,31 +6,144 @@ var MATCH_MODULE = "void_bastion_coop";
|
|||||||
var ROOM_COLLECTION = "private_rooms";
|
var ROOM_COLLECTION = "private_rooms";
|
||||||
var ACTIVITY_COLLECTION = "player_activity";
|
var ACTIVITY_COLLECTION = "player_activity";
|
||||||
var ACTIVITY_KEY = "summary";
|
var ACTIVITY_KEY = "summary";
|
||||||
|
var INTERNAL_TEST_USERNAME_PATTERN = "^(godot_[ab]_[0-9]+|social_[ab]_[0-9]+|GODOT-[AB]|SYSTEMTEST)$";
|
||||||
var STATS_COLLECTION = "internal_stats";
|
var STATS_COLLECTION = "internal_stats";
|
||||||
var STATS_KEY = "global";
|
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 DIAG_PARTICIPANT_COLLECTION = "online_diag_participants";
|
||||||
|
var DIAG_REPORT_COLLECTION = "online_diag_reports";
|
||||||
|
var DIAG_RETENTION_MS = 14 * 86400000;
|
||||||
|
var BALANCE_COLLECTION = "game_balance";
|
||||||
|
var BALANCE_KEY = "global";
|
||||||
var SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
|
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 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 OP_DEV_AUTH = 15, OP_DEV_ENERGY = 16, ADMIN_TOWER_TYPE = 7, DEV_MODE_PASSWORD = "Spring.3";
|
||||||
var REACTOR_X = 1480, REACTOR_Y = 440, REACTOR_RADIUS = 112, PLAYFIELD_TOP = 92, PLAYFIELD_BOTTOM = 758;
|
var DEV_ADMIN_USER_IDS = {
|
||||||
|
"6605c44c-17a2-4cc4-bf3e-e772fd6229b0": true,
|
||||||
|
"6f32e90b-1ed8-4baf-bfa2-17646600da79": true
|
||||||
|
};
|
||||||
|
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 LEFT_PATH = [[-40,120],[1160,120],[1160,360],[360,360],[360,680],[1400,680],[1400,520],[1480,520]];
|
||||||
var RIGHT_PATH = [];
|
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]]);
|
for (var pathIndex = 0; pathIndex < LEFT_PATH.length; pathIndex++) RIGHT_PATH.push([WORLD_WIDTH - LEFT_PATH[pathIndex][0], LEFT_PATH[pathIndex][1]]);
|
||||||
var PATHS = [LEFT_PATH, RIGHT_PATH];
|
var PATHS = [LEFT_PATH, RIGHT_PATH];
|
||||||
var TOWERS = [
|
var TOWERS = [
|
||||||
{cost:120,damage:22,range:190,cooldown:0.55},
|
{cost:120,damage:22,range:190,cooldown:0.55,targetClass:"ground"},
|
||||||
{cost:180,damage:12,range:165,cooldown:0.18},
|
{cost:180,damage:12,range:165,cooldown:0.18,targetClass:"both",bothScale:0.82},
|
||||||
{cost:250,damage:65,range:260,cooldown:1.40},
|
{cost:250,damage:65,range:260,cooldown:1.40,targetClass:"ground"},
|
||||||
{cost:230,damage:48,range:240,cooldown:1.05},
|
{cost:230,damage:34,range:240,cooldown:1.05,targetClass:"both",bothScale:0.72},
|
||||||
{cost:210,damage:34,range:215,cooldown:0.92},
|
{cost:210,damage:20,range:215,cooldown:0.92,targetClass:"both",bothScale:1},
|
||||||
{cost:300,damage:110,range:330,cooldown:1.85}
|
{cost:300,damage:77,range:330,cooldown:1.85,targetClass:"both",bothScale:0.70},
|
||||||
|
{cost:210,damage:22,range:230,cooldown:0.42,targetClass:"air"}
|
||||||
|
];
|
||||||
|
var ENEMIES = [
|
||||||
|
{category:"air",hp:70,speed:105,reward:18,breachDamage:1},
|
||||||
|
{category:"air",hp:185,speed:72,reward:34,breachDamage:2},
|
||||||
|
{category:"air",hp:520,speed:43,reward:78,breachDamage:3},
|
||||||
|
{category:"air",hp:52,speed:178,reward:22,breachDamage:2},
|
||||||
|
{category:"air",hp:260,speed:58,reward:52,breachDamage:2},
|
||||||
|
{category:"air",hp:620,speed:48,reward:108,breachDamage:4},
|
||||||
|
{category:"ground",hp:88,speed:142,reward:22,breachDamage:1},
|
||||||
|
{category:"ground",hp:390,speed:61,reward:61,breachDamage:3},
|
||||||
|
{category:"ground",hp:980,speed:36,reward:132,breachDamage:5}
|
||||||
];
|
];
|
||||||
var ENEMIES = [{hp:70,speed:105,reward:18},{hp:185,speed:72,reward:34},{hp:520,speed:43,reward:78}];
|
|
||||||
var DIFFICULTIES = [
|
var DIFFICULTIES = [
|
||||||
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
|
{hp:1,speed:1,count:1,income:1,spawnDelay:1,startCredits:450},
|
||||||
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
|
{hp:1.32,speed:1.07,count:1.24,income:0.94,spawnDelay:0.91,startCredits:430},
|
||||||
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
|
{hp:1.72,speed:1.15,count:1.48,income:0.84,spawnDelay:0.82,startCredits:400}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function defaultBalanceConfig() {
|
||||||
|
return {schema:3,towers:JSON.parse(JSON.stringify(TOWERS)),enemies:JSON.parse(JSON.stringify(ENEMIES)),admin_tower:{range:650,cooldown:0.08}};
|
||||||
|
}
|
||||||
|
function boundedNumber(value, fallback, minimum, maximum, integer) {
|
||||||
|
var number = Number(value);
|
||||||
|
if (!isFinite(number)) number = fallback;
|
||||||
|
number = Math.max(minimum, Math.min(maximum, number));
|
||||||
|
return integer ? Math.floor(number) : number;
|
||||||
|
}
|
||||||
|
function sanitizeBalanceConfig(input) {
|
||||||
|
var defaults = defaultBalanceConfig();
|
||||||
|
if (!input || !Array.isArray(input.towers) || input.towers.length !== defaults.towers.length ||
|
||||||
|
!Array.isArray(input.enemies) || input.enemies.length !== defaults.enemies.length) throw new Error("invalid balance schema");
|
||||||
|
var towers = defaults.towers.map(function(base, index) {
|
||||||
|
var candidate = input.towers[index] || {};
|
||||||
|
var targetClass = String(candidate.targetClass || base.targetClass);
|
||||||
|
if (["air","ground","both"].indexOf(targetClass) < 0) targetClass = base.targetClass;
|
||||||
|
return {
|
||||||
|
cost:boundedNumber(candidate.cost,base.cost,10,50000,true),
|
||||||
|
damage:boundedNumber(candidate.damage,base.damage,0.1,10000,false),
|
||||||
|
range:boundedNumber(candidate.range,base.range,20,1200,false),
|
||||||
|
cooldown:boundedNumber(candidate.cooldown,base.cooldown,0.04,20,false),
|
||||||
|
targetClass:targetClass,
|
||||||
|
bothScale:boundedNumber(candidate.bothScale,base.bothScale===undefined?1:base.bothScale,0.05,2,false)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var enemies = defaults.enemies.map(function(base, index) {
|
||||||
|
var candidate = input.enemies[index] || {};
|
||||||
|
return {
|
||||||
|
category:base.category,
|
||||||
|
hp:boundedNumber(candidate.hp,base.hp,1,1000000,false),
|
||||||
|
speed:boundedNumber(candidate.speed,base.speed,1,1500,false),
|
||||||
|
reward:boundedNumber(candidate.reward,base.reward,0,50000,true),
|
||||||
|
breachDamage:boundedNumber(candidate.breachDamage,base.breachDamage,1,100,true),
|
||||||
|
shieldResistance:boundedNumber(candidate.shieldResistance,base.shieldResistance,0.01,1,false)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var adminInput=input.admin_tower||{};
|
||||||
|
var adminTower={
|
||||||
|
range:boundedNumber(adminInput.range,defaults.admin_tower.range,80,1200,false),
|
||||||
|
cooldown:boundedNumber(adminInput.cooldown,defaults.admin_tower.cooldown,0.04,20,false)
|
||||||
|
};
|
||||||
|
return {schema:3,towers:towers,enemies:enemies,admin_tower:adminTower};
|
||||||
|
}
|
||||||
|
function readBalanceConfig(nk) {
|
||||||
|
var record = nk && typeof nk.storageRead === "function" ? readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID) : null;
|
||||||
|
var config = defaultBalanceConfig(), updatedAt = 0;
|
||||||
|
if (record && record.value && record.value.config) {
|
||||||
|
try {
|
||||||
|
var storedConfig = record.value.config;
|
||||||
|
config = sanitizeBalanceConfig(storedConfig);
|
||||||
|
updatedAt = Number(record.value.updated_at||0);
|
||||||
|
if (Number(storedConfig.schema || 1) < 2) {
|
||||||
|
var legacyTower = storedConfig.towers[6] || {};
|
||||||
|
var legacyBoss = storedConfig.enemies[5] || {};
|
||||||
|
if (Number(legacyTower.damage) === 20) config.towers[6].damage = 22;
|
||||||
|
if (Number(legacyBoss.hp) === 740) config.enemies[5].hp = 620;
|
||||||
|
}
|
||||||
|
if (Number(storedConfig.schema || 1) < 3) {
|
||||||
|
updatedAt = Date.now();
|
||||||
|
if (nk && typeof nk.storageWrite === "function") {
|
||||||
|
try { nk.storageWrite([{collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record.version||"*"}]); }
|
||||||
|
catch (writeError) { updatedAt = Number(record.value.updated_at||0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) { config = defaultBalanceConfig(); }
|
||||||
|
}
|
||||||
|
return {config:config,updated_at:updatedAt};
|
||||||
|
}
|
||||||
|
function rpcGetGameBalance(ctx, logger, nk, payload) {
|
||||||
|
// Nakama server-to-server HTTP-key calls intentionally have no player userId.
|
||||||
|
// The internal statistics page uses that path; game clients call with a session.
|
||||||
|
return JSON.stringify(readBalanceConfig(nk));
|
||||||
|
}
|
||||||
|
function rpcSetGameBalance(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 config = sanitizeBalanceConfig(request.config);
|
||||||
|
var record = readStorageRecord(nk,BALANCE_COLLECTION,BALANCE_KEY,SYSTEM_USER_ID);
|
||||||
|
var updatedAt = Date.now();
|
||||||
|
var write = {collection:BALANCE_COLLECTION,key:BALANCE_KEY,userId:SYSTEM_USER_ID,value:{config:config,updated_at:updatedAt},permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"};
|
||||||
|
nk.storageWrite([write]);
|
||||||
|
return JSON.stringify({accepted:true,updated_at:updatedAt,config:config});
|
||||||
|
}
|
||||||
|
|
||||||
function clampInteger(value, minimum, maximum) {
|
function clampInteger(value, minimum, maximum) {
|
||||||
var parsed = Math.floor(Number(value));
|
var parsed = Math.floor(Number(value));
|
||||||
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
|
if (!isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error("numeric value outside allowed range");
|
||||||
@@ -121,7 +234,19 @@ function rpcRecordActivity(ctx, logger, nk, payload) {
|
|||||||
var now = Date.now(), day = utcDay(now);
|
var now = Date.now(), day = utcDay(now);
|
||||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||||
updatePlayerStats(nk, playerRecord, platform, version, day, now);
|
updatePlayerStats(nk, playerRecord, platform, version, day, now);
|
||||||
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:{first_seen:playerRecord&&playerRecord.value?Number(playerRecord.value.first_seen||now):now,last_seen:now,last_seen_day:day,platform:platform,version:version},permissionRead:0,permissionWrite:0}]);
|
for (var attempt=0;attempt<3;attempt++) {
|
||||||
|
playerRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,ctx.userId);
|
||||||
|
var activity=playerRecord&&playerRecord.value?JSON.parse(JSON.stringify(playerRecord.value)):{};
|
||||||
|
activity.first_seen=Number(activity.first_seen||now);
|
||||||
|
activity.last_seen=now;
|
||||||
|
activity.last_seen_day=day;
|
||||||
|
activity.platform=platform;
|
||||||
|
activity.version=version;
|
||||||
|
try {
|
||||||
|
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:ctx.userId,value:activity,permissionRead:0,permissionWrite:0,version:playerRecord&&playerRecord.version?playerRecord.version:"*"}]);
|
||||||
|
return JSON.stringify({accepted:true});
|
||||||
|
} catch (error) { if (attempt===2) throw error; }
|
||||||
|
}
|
||||||
return JSON.stringify({accepted:true});
|
return JSON.stringify({accepted:true});
|
||||||
}
|
}
|
||||||
function rpcPrivateStats(ctx, logger, nk, payload) {
|
function rpcPrivateStats(ctx, logger, nk, payload) {
|
||||||
@@ -130,6 +255,206 @@ function rpcPrivateStats(ctx, logger, nk, payload) {
|
|||||||
var stats = record && record.value ? record.value : emptyStats(Date.now());
|
var stats = record && record.value ? record.value : emptyStats(Date.now());
|
||||||
return JSON.stringify(stats);
|
return JSON.stringify(stats);
|
||||||
}
|
}
|
||||||
|
function updatePlayerActivity(nk,userId,event,type,source,sequence,eventAt) {
|
||||||
|
var now=Date.now();
|
||||||
|
for (var attempt=0;attempt<3;attempt++) {
|
||||||
|
var record=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId);
|
||||||
|
var activity=record&&record.value?JSON.parse(JSON.stringify(record.value)):{};
|
||||||
|
var sequences=activity.event_sequences&&typeof activity.event_sequences==="object"?activity.event_sequences:{};
|
||||||
|
if (source && sequence<=Number(sequences[source]||0)) return {accepted:true,duplicate:true};
|
||||||
|
activity.first_seen=Number(activity.first_seen||now);
|
||||||
|
activity.last_seen=now;
|
||||||
|
activity.last_seen_day=utcDay(now);
|
||||||
|
activity.last_played=Math.max(Number(activity.last_played||0),Math.min(now,Math.max(0,Number(eventAt||now))));
|
||||||
|
if (event==="tower_build") {
|
||||||
|
var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[];
|
||||||
|
while (builds.length<TOWERS.length) builds.push(0);
|
||||||
|
builds[type]=Math.max(0,Math.floor(Number(builds[type]||0)))+1;
|
||||||
|
activity.tower_builds=builds;
|
||||||
|
activity.tower_build_total=Math.max(0,Math.floor(Number(activity.tower_build_total||0)))+1;
|
||||||
|
}
|
||||||
|
if (source) { sequences[source]=sequence; activity.event_sequences=sequences; }
|
||||||
|
try {
|
||||||
|
nk.storageWrite([{collection:ACTIVITY_COLLECTION,key:ACTIVITY_KEY,userId:userId,value:activity,permissionRead:0,permissionWrite:0,version:record&&record.version?record.version:"*"}]);
|
||||||
|
return {accepted:true,duplicate:false};
|
||||||
|
} catch (error) { if (attempt===2) throw error; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rpcRecordPlayerEvent(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 event=String(request.event||""), type=Math.floor(Number(request.tower_type));
|
||||||
|
var source=String(request.source||""),sequence=Math.floor(Number(request.sequence));
|
||||||
|
if (["play_start","tower_build"].indexOf(event)<0 || (event==="tower_build"&&(type<0||type>=TOWERS.length)) ||
|
||||||
|
!/^[A-Za-z0-9_-]{8,64}$/.test(source) || !isFinite(sequence) || sequence<1 || sequence>1000000000)
|
||||||
|
return JSON.stringify({accepted:false,error:"invalid_event"});
|
||||||
|
var eventAt=Number(request.at||Date.now());
|
||||||
|
return JSON.stringify(updatePlayerActivity(nk,ctx.userId,event,type,source,sequence,eventAt));
|
||||||
|
}
|
||||||
|
function isInternalTestUsername(username) { return new RegExp(INTERNAL_TEST_USERNAME_PATTERN,"i").test(String(username||"")); }
|
||||||
|
function playerDirectoryRow(row) {
|
||||||
|
return {user_id:String(row.id||""),name:String(row.display_name||row.username||"PILOT").substring(0,32),
|
||||||
|
registered_at:Number(row.registered_at||0),last_seen:Number(row.last_seen||0),last_played:Number(row.last_played||0),
|
||||||
|
tower_build_total:Number(row.tower_build_total||0)};
|
||||||
|
}
|
||||||
|
function rpcListRegisteredPlayers(ctx,logger,nk,payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
var request;
|
||||||
|
try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; }
|
||||||
|
var page=Math.max(0,Math.min(10000,Math.floor(Number(request.page||0))||0));
|
||||||
|
var countRows=nk.sqlQuery("SELECT count(*) AS total, count(*) FILTER (WHERE a.value->>'last_seen_day'=$1) AS active_today FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $2",[utcDay(Date.now()),INTERNAL_TEST_USERNAME_PATTERN]);
|
||||||
|
var rows=nk.sqlQuery("SELECT u.id,u.username,u.display_name,extract(epoch from u.create_time)*1000 AS registered_at,coalesce((a.value->>'last_seen')::bigint,0) AS last_seen,coalesce((a.value->>'last_played')::bigint,0) AS last_played,coalesce((a.value->>'tower_build_total')::bigint,0) AS tower_build_total FROM users u LEFT JOIN storage a ON a.user_id=u.id AND a.collection='player_activity' AND a.key='summary' WHERE u.username<>'' AND u.username !~* $1 ORDER BY last_seen DESC,u.create_time DESC LIMIT 50 OFFSET $2",[INTERNAL_TEST_USERNAME_PATTERN,page*50]);
|
||||||
|
return JSON.stringify({page:page,page_size:50,total:Number(countRows[0]&&countRows[0].total||0),active_today:Number(countRows[0]&&countRows[0].active_today||0),players:rows.map(playerDirectoryRow)});
|
||||||
|
}
|
||||||
|
function rpcGetRegisteredPlayer(ctx,logger,nk,payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
var request;
|
||||||
|
try { request=JSON.parse(payload||"{}"); } catch (error) { request={}; }
|
||||||
|
var userId=String(request.user_id||"");
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(userId)) return JSON.stringify({found:false});
|
||||||
|
var rows=nk.sqlQuery("SELECT id,username,display_name,extract(epoch from create_time)*1000 AS registered_at FROM users WHERE id=$1::uuid LIMIT 1",[userId]);
|
||||||
|
if (!rows.length || isInternalTestUsername(rows[0].username) || !rows[0].username) return JSON.stringify({found:false});
|
||||||
|
var activityRecord=readStorageRecord(nk,ACTIVITY_COLLECTION,ACTIVITY_KEY,userId);
|
||||||
|
var activity=activityRecord&&activityRecord.value?activityRecord.value:{};
|
||||||
|
var builds=Array.isArray(activity.tower_builds)?activity.tower_builds.slice(0,TOWERS.length):[];
|
||||||
|
while (builds.length<TOWERS.length) builds.push(0);
|
||||||
|
return JSON.stringify({found:true,player:{user_id:userId,name:String(rows[0].display_name||rows[0].username).substring(0,32),
|
||||||
|
registered_at:Number(rows[0].registered_at||0),last_seen:Number(activity.last_seen||0),last_played:Number(activity.last_played||0),
|
||||||
|
platform:String(activity.platform||""),version:String(activity.version||""),tower_builds:builds,
|
||||||
|
tower_build_total:Number(activity.tower_build_total||0)}});
|
||||||
|
}
|
||||||
|
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) {
|
function rpcSubmitRun(ctx, logger, nk, payload) {
|
||||||
if (!ctx.userId) throw new Error("authentication required");
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
var run;
|
var run;
|
||||||
@@ -174,6 +499,18 @@ function rpcUpdateDisplayName(ctx, logger, nk, payload) {
|
|||||||
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
|
nk.accountUpdateId(ctx.userId, null, name, null, null, null, null, null);
|
||||||
return JSON.stringify({accepted:true,display_name:name});
|
return JSON.stringify({accepted:true,display_name:name});
|
||||||
}
|
}
|
||||||
|
function rpcFindPlayerByName(ctx, logger, nk, payload) {
|
||||||
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload || "{}"); } catch (error) { return JSON.stringify({found:false,error:"invalid_json"}); }
|
||||||
|
var name = normalizeDisplayName(request.display_name);
|
||||||
|
if (validateDisplayName(name)) return JSON.stringify({found:false,error:"invalid_name"});
|
||||||
|
var rows = nk.sqlQuery("SELECT id, display_name FROM users WHERE lower(display_name) = lower($1) LIMIT 3", [name]);
|
||||||
|
var candidates = rows.filter(function(row) { return String(row.id) !== ctx.userId; });
|
||||||
|
if (!candidates.length) return JSON.stringify({found:false,error:"not_found"});
|
||||||
|
if (candidates.length !== 1 || rows.length > 2) return JSON.stringify({found:false,error:"ambiguous"});
|
||||||
|
return JSON.stringify({found:true,user_id:String(candidates[0].id),display_name:String(candidates[0].display_name)});
|
||||||
|
}
|
||||||
function rpcDeleteAccount(ctx, logger, nk, payload) {
|
function rpcDeleteAccount(ctx, logger, nk, payload) {
|
||||||
if (!ctx.userId) throw new Error("authentication required");
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
var playerRecord = readStorageRecord(nk, ACTIVITY_COLLECTION, ACTIVITY_KEY, ctx.userId);
|
||||||
@@ -223,18 +560,53 @@ function rpcJoinPrivateRoom(ctx, logger, nk, payload) {
|
|||||||
return JSON.stringify({found:true,room_code:code,match_id:String(record.value.match_id)});
|
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) {
|
function matchmakerMatched(ctx, logger, nk, matches) {
|
||||||
var difficulty = 1;
|
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))));
|
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))));
|
||||||
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
|
return nk.matchCreate(MATCH_MODULE,{matchedUsers:matches.length,difficulty:difficulty});
|
||||||
}
|
}
|
||||||
function initialState(params) {
|
function initialState(params) {
|
||||||
var difficulty = Math.max(0,Math.min(2,Math.floor(Number(params.difficulty||1))));
|
var requestedDifficulty = params.difficulty === undefined || params.difficulty === null ? 1 : Number(params.difficulty);
|
||||||
|
var difficulty = Math.max(0,Math.min(2,Math.floor(isFinite(requestedDifficulty) ? requestedDifficulty : 1)));
|
||||||
var credits = DIFFICULTIES[difficulty].startCredits;
|
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,spawnTimer:0,nextSpawnRoute:0,nextEnemyId:1,nextTowerId:1,towers:[],enemies:[],lastSnapshotTick:0};
|
return {presences:{},slots:{},players:{},ready:{},devAuthorized:{},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,balance:defaultBalanceConfig()};
|
||||||
}
|
}
|
||||||
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
|
function matchLabel(state) { return state.mode === "private" ? state.roomCode : JSON.stringify({game:"void_bastion",mode:state.mode,open:!state.started&&!state.gameOver}); }
|
||||||
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
|
function matchInit(ctx,logger,nk,params) { var state=initialState(params||{}); state.balance=readBalanceConfig(nk).config; return {state:state,tickRate:TICK_RATE,label:matchLabel(state)}; }
|
||||||
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
|
function matchJoinAttempt(ctx,logger,nk,dispatcher,tick,state,presence,metadata) { return {state:state,accept:state.slots[presence.userId]!==undefined||Object.keys(state.slots).length<2}; }
|
||||||
function firstFreeSlot(state) {
|
function firstFreeSlot(state) {
|
||||||
var used={},ids=Object.keys(state.slots);
|
var used={},ids=Object.keys(state.slots);
|
||||||
@@ -247,9 +619,15 @@ function snapshot(state,tick) {
|
|||||||
var id=ids[i],slot=state.slots[id],player=state.players[id]||{};
|
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};
|
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,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 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) {
|
function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
|
||||||
var rejoined=false;
|
var rejoined=false;
|
||||||
@@ -263,6 +641,16 @@ function matchJoin(ctx,logger,nk,dispatcher,tick,state,presences) {
|
|||||||
}
|
}
|
||||||
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
|
if (state.started&&Object.keys(state.presences).length===2) { state.pausedForReconnect=false; state.reconnectDeadlineTick=0; if (rejoined) broadcastEvent(dispatcher,"partner_rejoined",{}); }
|
||||||
state.emptyTicks=0;
|
state.emptyTicks=0;
|
||||||
|
if (ctx.matchId) {
|
||||||
|
var roundId = nk.sha256Hash(ctx.matchId).slice(0, 16);
|
||||||
|
var participantSlots = {}, participantIds = Object.keys(state.slots);
|
||||||
|
for (var participantIndex = 0; participantIndex < participantIds.length; participantIndex++) {
|
||||||
|
var participantId = participantIds[participantIndex];
|
||||||
|
participantSlots[nk.sha256Hash(ctx.matchId + ":" + participantId)] = state.slots[participantId];
|
||||||
|
}
|
||||||
|
nk.storageWrite([{collection:DIAG_PARTICIPANT_COLLECTION,key:roundId,userId:SYSTEM_USER_ID,
|
||||||
|
value:{participants:participantSlots,created_at:Date.now()},permissionRead:0,permissionWrite:0}]);
|
||||||
|
}
|
||||||
dispatcher.matchLabelUpdate(matchLabel(state));
|
dispatcher.matchLabelUpdate(matchLabel(state));
|
||||||
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
|
broadcastEvent(dispatcher,"players",{count:Object.keys(state.presences).length,started:state.started,slots:state.slots});
|
||||||
broadcastSnapshot(dispatcher,state,tick);
|
broadcastSnapshot(dispatcher,state,tick);
|
||||||
@@ -274,52 +662,106 @@ function matchLeave(ctx,logger,nk,dispatcher,tick,state,presences) {
|
|||||||
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"partner_left",{reconnect_seconds:60}); broadcastSnapshot(dispatcher,state,tick); return {state:state};
|
||||||
}
|
}
|
||||||
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
|
function parseMessage(nk,message) { try { return JSON.parse(nk.binaryToString(message.data)||"{}"); } catch (error) { return null; } }
|
||||||
function towerDamage(t) { return TOWERS[t.type].damage*(1+(t.level-1)*0.52); }
|
function towerInfo(state,type) {
|
||||||
function towerRange(t) { return TOWERS[t.type].range*(1+(t.level-1)*0.08); }
|
if (type===ADMIN_TOWER_TYPE) {
|
||||||
function towerCooldown(t) { return TOWERS[t.type].cooldown/(1+(t.level-1)*0.12); }
|
var admin=state.balance&&state.balance.admin_tower||defaultBalanceConfig().admin_tower;
|
||||||
function upgradeCost(t) { return Math.floor(TOWERS[t.type].cost*(0.72+t.level*0.28)); }
|
return {cost:0,damage:1000000,range:admin.range,cooldown:admin.cooldown,targetClass:"both",bothScale:1};
|
||||||
|
}
|
||||||
|
return state.balance && state.balance.towers[type] || TOWERS[type];
|
||||||
|
}
|
||||||
|
function enemyInfo(state,type) { return state.balance && state.balance.enemies[type] || ENEMIES[type]; }
|
||||||
|
function towerDamage(state,t) { return towerInfo(state,t.type).damage*(1+(t.level-1)*0.52); }
|
||||||
|
function towerRange(state,t) { return towerInfo(state,t.type).range*(1+(t.level-1)*0.08); }
|
||||||
|
function towerCooldown(state,t) { return towerInfo(state,t.type).cooldown/(1+(t.level-1)*0.12); }
|
||||||
|
function upgradeCost(state,t) { return Math.floor(towerInfo(state,t.type).cost*(0.72+t.level*0.28)); }
|
||||||
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
|
function distanceSquared(ax,ay,bx,by) { var dx=ax-bx,dy=ay-by; return dx*dx+dy*dy; }
|
||||||
function distanceToSegment(px,py,ax,ay,bx,by) {
|
function distanceToSegment(px,py,ax,ay,bx,by) {
|
||||||
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
|
var abx=bx-ax,aby=by-ay,len=abx*abx+aby*aby,t=len>0?((px-ax)*abx+(py-ay)*aby)/len:0;
|
||||||
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
|
t=Math.max(0,Math.min(1,t)); return Math.sqrt(distanceSquared(px,py,ax+abx*t,ay+aby*t));
|
||||||
}
|
}
|
||||||
function validBuildPosition(state,slot,x,y) {
|
function validBuildPosition(state,slot,x,y,allowAnySide) {
|
||||||
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
|
var sx=Math.floor(x/GRID)*GRID+GRID*0.5,sy=Math.floor(y/GRID)*GRID+GRID*0.5;
|
||||||
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
|
if (Math.abs(sx-x)>1||Math.abs(sy-y)>1||x<120||x>WORLD_WIDTH-120||y<PLAYFIELD_TOP||y>PLAYFIELD_BOTTOM) return false;
|
||||||
if (slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
|
if (!allowAnySide&&slot===0&&(x<120||x>=MIRROR_X-GRID*0.5)) return false;
|
||||||
if (slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
|
if (!allowAnySide&&slot===1&&(x<=MIRROR_X+GRID*0.5||x>WORLD_WIDTH-120)) return false;
|
||||||
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
|
if (distanceSquared(x,y,REACTOR_X,REACTOR_Y)<=REACTOR_RADIUS*REACTOR_RADIUS) return false;
|
||||||
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
|
for (var r=0;r<PATHS.length;r++) for (var i=0;i<PATHS[r].length-1;i++) if (distanceToSegment(x,y,PATHS[r][i][0],PATHS[r][i][1],PATHS[r][i+1][0],PATHS[r][i+1][1])<GRID*0.62) return false;
|
||||||
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
|
for (var j=0;j<state.towers.length;j++) if (distanceSquared(x,y,state.towers[j].x,state.towers[j].y)<GRID*GRID*0.52) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function handleBuild(state,slot,payload) {
|
function handleBuild(state,slot,payload,userId) {
|
||||||
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
|
var type=Math.floor(Number(payload.tower_type)),x=Number(payload.x),y=Number(payload.y);
|
||||||
if (!isFinite(x)||!isFinite(y)||type<0||type>=TOWERS.length||!validBuildPosition(state,slot,x,y)||state.credits[slot]<TOWERS[type].cost) return false;
|
var isAdmin=type===ADMIN_TOWER_TYPE;
|
||||||
state.credits[slot]-=TOWERS[type].cost;
|
if (!isFinite(x)||!isFinite(y)||type<0||type>TOWERS.length||(isAdmin&&!state.devAuthorized[userId])||!validBuildPosition(state,slot,x,y,isAdmin)||state.credits[slot]<towerInfo(state,type).cost) return false;
|
||||||
|
state.credits[slot]-=towerInfo(state,type).cost;
|
||||||
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
|
state.towers.push({id:state.nextTowerId++,x:x,y:y,type:type,level:1,owner:slot,cooldown:0.15,angle:slot===0?0:Math.PI}); return true;
|
||||||
}
|
}
|
||||||
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
|
function ownedTowerIndex(state,slot,id) { for (var i=0;i<state.towers.length;i++) if (state.towers[i].id===id&&state.towers[i].owner===slot) return i; return -1; }
|
||||||
function handleUpgrade(state,slot,payload) {
|
function handleUpgrade(state,slot,payload) {
|
||||||
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
|
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id)));
|
||||||
if (i<0||state.towers[i].level>=3) return false;
|
if (i<0||state.towers[i].level>=3) return false;
|
||||||
var cost=upgradeCost(state.towers[i]); if (state.credits[slot]<cost) return false;
|
var cost=upgradeCost(state,state.towers[i]); if (state.credits[slot]<cost) return false;
|
||||||
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
|
state.credits[slot]-=cost; state.towers[i].level+=1; return true;
|
||||||
}
|
}
|
||||||
function handleSell(state,slot,payload) {
|
function handleSell(state,slot,payload) {
|
||||||
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
|
var i=ownedTowerIndex(state,slot,Math.floor(Number(payload.tower_id))); if (i<0) return false;
|
||||||
var tower=state.towers[i]; state.credits[slot]+=Math.floor(TOWERS[tower.type].cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
|
var tower=state.towers[i]; state.credits[slot]+=Math.floor(towerInfo(state,tower.type).cost*(0.55+tower.level*0.12)); state.towers.splice(i,1); return true;
|
||||||
|
}
|
||||||
|
function enemyTypeForWave(wave,roll,difficulty,boss) {
|
||||||
|
if (boss) return wave>=10?8:5;
|
||||||
|
var offset=difficulty===2?2:(difficulty===1?1:0);
|
||||||
|
if (wave>=10&&roll>=0.96) return 8;
|
||||||
|
if (wave>=8&&roll>=0.84&&roll<0.94) return 7;
|
||||||
|
if (wave>=3&&roll>=0.52&&roll<0.64) return 6;
|
||||||
|
if (wave>=10-offset&&roll>=0.94) return 5;
|
||||||
|
if (wave>=7-offset&&roll>=0.80) return 4;
|
||||||
|
if (wave>=4-offset&&roll>=0.65) return 3;
|
||||||
|
if (wave>=7&&roll>=0.45) return 2;
|
||||||
|
if (wave>=3&&roll>=0.28) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
function wavePopulation(state,wave) {
|
||||||
|
var scale=1+Math.max(0,wave-3)*0.08;
|
||||||
|
if (state.difficulty===2) scale+=Math.max(0,wave-4)*0.025;
|
||||||
|
return Math.ceil((7+wave*2)*DIFFICULTIES[state.difficulty].count*scale)*2;
|
||||||
|
}
|
||||||
|
function buildWaveSchedule(state,wave,count) {
|
||||||
|
var schedule=[],bossCount=wave>0&&wave%5===0?2:0;
|
||||||
|
for (var i=0;i<count;i++) {
|
||||||
|
var boss=i>=count-bossCount;
|
||||||
|
var roll=((i+1)*0.61803398875+wave*0.38196601125)%1;
|
||||||
|
schedule.push({type:enemyTypeForWave(wave,roll,state.difficulty,boss),boss:boss});
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
}
|
}
|
||||||
function startWave(state) {
|
function startWave(state) {
|
||||||
if (state.waveActive||state.gameOver||!state.started) return false;
|
if (state.waveActive||state.gameOver||!state.started) return false;
|
||||||
state.wave+=1; state.spawnRemaining=Math.ceil((7+state.wave*2)*DIFFICULTIES[state.difficulty].count)*2; state.spawnTimer=0.1; state.waveActive=true; return true;
|
state.wave+=1; state.waveSchedule=buildWaveSchedule(state,state.wave,wavePopulation(state,state.wave)); state.spawnRemaining=state.waveSchedule.length; state.spawnTimer=0.1; state.waveActive=true; return true;
|
||||||
}
|
}
|
||||||
function spawnEnemy(state) {
|
function spawnEnemy(state) {
|
||||||
var boss=state.wave>0&&state.wave%5===0&&state.spawnRemaining<=2,roll=Math.random();
|
var entry=state.waveSchedule.length?state.waveSchedule.shift():{type:0,boss:false};
|
||||||
var type=boss?2:(state.wave>=7&&roll>0.82?2:(state.wave>=3&&roll>0.53?1:0));
|
var boss=entry.boss,type=entry.type;
|
||||||
var base=ENEMIES[type],d=DIFFICULTIES[state.difficulty],scale=(1+Math.max(0,state.wave-1)*0.115)*(boss?3.25:1)*d.hp;
|
var base=enemyInfo(state,type),d=DIFFICULTIES[state.difficulty];
|
||||||
|
var scale=(1+Math.max(0,state.wave-1)*0.20+Math.max(0,state.wave-3)*0.09+((state.difficulty===2)?Math.max(0,state.wave-5)*0.055:0))*(boss?3.25:1)*d.hp;
|
||||||
|
var hullScale=scale*(boss&&state.wave===5?0.5:1);
|
||||||
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
|
var routeId=state.nextSpawnRoute; state.nextSpawnRoute=(state.nextSpawnRoute+1)%2; var route=PATHS[routeId];
|
||||||
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*scale,max_hp:base.hp*scale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss});
|
var hasShield=type===4||(state.wave>=4&&Math.random()<Math.min(0.42,0.10+state.wave*0.018+state.difficulty*0.06));
|
||||||
|
var maxShieldHp=hasShield?base.hp*scale*0.55:0;
|
||||||
|
if (boss && state.wave === 5) maxShieldHp *= 0.5;
|
||||||
|
state.enemies.push({id:state.nextEnemyId++,type:type,x:route[0][0],y:route[0][1],path_index:1,route_id:routeId,hp:base.hp*hullScale,max_hp:base.hp*hullScale,speed:base.speed*(1+state.wave*0.012)*(boss?0.82:1)*d.speed,reward:Math.floor((base.reward+state.wave)*(boss?4:1)*d.income),angle:0,boss:boss,shield:hasShield,shield_hp:maxShieldHp,max_shield_hp:maxShieldHp,shield_resistance:base.shieldResistance});
|
||||||
|
}
|
||||||
|
function applyDamageToEnemy(enemy,damage,towerType) {
|
||||||
|
if (towerType===ADMIN_TOWER_TYPE) { enemy.shield=false; enemy.shield_hp=0; enemy.hp=0; return; }
|
||||||
|
var applied=damage;
|
||||||
|
if (enemy.shield) {
|
||||||
|
if (towerType===1) {
|
||||||
|
var shieldDamage=Math.min(enemy.shield_hp,damage);
|
||||||
|
enemy.shield_hp-=shieldDamage;
|
||||||
|
if (enemy.shield_hp<=0) { enemy.shield=false; applied=damage-shieldDamage; }
|
||||||
|
else applied=0;
|
||||||
|
} else if (towerType!==5) applied*=enemy.shield_resistance||0.28;
|
||||||
|
}
|
||||||
|
enemy.hp-=applied;
|
||||||
}
|
}
|
||||||
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
|
function removeEnemy(state,index,owner) { state.credits[owner]+=state.enemies[index].reward; state.enemies.splice(index,1); }
|
||||||
function updateEnemies(state,delta) {
|
function updateEnemies(state,delta) {
|
||||||
@@ -328,25 +770,42 @@ function updateEnemies(state,delta) {
|
|||||||
e.angle=Math.atan2(dy,dx);
|
e.angle=Math.atan2(dy,dx);
|
||||||
if (dist<=Math.max(9,e.speed*delta)) {
|
if (dist<=Math.max(9,e.speed*delta)) {
|
||||||
e.x=target[0]; e.y=target[1]; e.path_index+=1;
|
e.x=target[0]; e.y=target[1]; e.path_index+=1;
|
||||||
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(1+e.type+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
|
if (e.path_index>=route.length) { state.reactorLives=Math.max(0,state.reactorLives-(enemyInfo(state,e.type).breachDamage+(e.boss?4:0))); state.enemies.splice(i,1); if (state.reactorLives<=0) { state.gameOver=true; state.waveActive=false; state.spawnRemaining=0; } }
|
||||||
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
|
} else { e.x+=dx/dist*e.speed*delta; e.y+=dy/dist*e.speed*delta; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
|
function enemyProgress(e) { return e.path_index*100000-distanceSquared(e.x,e.y,REACTOR_X,REACTOR_Y); }
|
||||||
function updateTowers(state,delta) {
|
function updateTowers(state,delta) {
|
||||||
|
state.shots=state.shots||[];
|
||||||
for (var i=0;i<state.towers.length;i++) {
|
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;
|
var t=state.towers[i],tower=towerInfo(state,t.type); t.cooldown-=delta; var range=towerRange(state,t),targetIndex=-1,best=-1,bestShield=false;
|
||||||
for (var j=0;j<state.enemies.length;j++) if (distanceSquared(t.x,t.y,state.enemies[j].x,state.enemies[j].y)<=range*range) { var p=enemyProgress(state.enemies[j]); if (p>best) { best=p; targetIndex=j; } }
|
for (var j=0;j<state.enemies.length;j++) {
|
||||||
|
var candidate=state.enemies[j];
|
||||||
|
if (t.type===ADMIN_TOWER_TYPE&&candidate.route_id!==(t.x<MIRROR_X?0:1)) continue;
|
||||||
|
if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,candidate.type).category)&&distanceSquared(t.x,t.y,candidate.x,candidate.y)<=range*range) {
|
||||||
|
var p=enemyProgress(candidate),shieldPriority=t.type===1&&candidate.shield===true;
|
||||||
|
if (targetIndex<0||(shieldPriority&&!bestShield)||(shieldPriority===bestShield&&p>best)) { best=p; bestShield=shieldPriority; targetIndex=j; }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (targetIndex<0) continue;
|
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);
|
var target=state.enemies[targetIndex]; t.angle=Math.atan2(target.y-t.y,target.x-t.x)+([0,3,5,6].indexOf(t.type)>=0?Math.PI:0);
|
||||||
if (t.cooldown>0) continue;
|
if (t.cooldown>0) continue;
|
||||||
var damage=towerDamage(t);
|
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});
|
||||||
if (t.type===2||t.type===3||t.type===4) {
|
var damage=towerDamage(state,t)*(tower.targetClass==="both"?tower.bothScale:1);
|
||||||
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:0.38);
|
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;
|
||||||
|
for (var railIndex=state.enemies.length-1;railIndex>=0;railIndex--) {
|
||||||
|
var railTarget=state.enemies[railIndex];
|
||||||
|
if (distanceSquared(t.x,t.y,railTarget.x,railTarget.y)>rangeSquared) continue;
|
||||||
|
var offsetX=railTarget.x-t.x,offsetY=railTarget.y-t.y,along=offsetX*dx+offsetY*dy,across=Math.abs(offsetX*dy-offsetY*dx);
|
||||||
|
if (along>=0&&along<=range&&across<=18) { applyDamageToEnemy(railTarget,damage,t.type); if (railTarget.hp<=0) removeEnemy(state,railIndex,t.owner); }
|
||||||
|
}
|
||||||
|
} else if (t.type===2||t.type===3||t.type===4) {
|
||||||
|
var radius=t.type===2?76:(t.type===3?92:118),splash=t.type===2?0.45:(t.type===3?0.62:1);
|
||||||
var tx=target.x,ty=target.y,targetId=target.id;
|
var tx=target.x,ty=target.y,targetId=target.id;
|
||||||
for (var k=state.enemies.length-1;k>=0;k--) if (distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { state.enemies[k].hp-=state.enemies[k].id===targetId?damage:damage*splash; if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
for (var k=state.enemies.length-1;k>=0;k--) if ((tower.targetClass==="both"||tower.targetClass===enemyInfo(state,state.enemies[k].type).category)&&distanceSquared(state.enemies[k].x,state.enemies[k].y,tx,ty)<=radius*radius) { applyDamageToEnemy(state.enemies[k],state.enemies[k].id===targetId?damage:damage*splash,t.type); if (state.enemies[k].hp<=0) removeEnemy(state,k,t.owner); }
|
||||||
} else { target.hp-=damage; if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
} else { applyDamageToEnemy(target,damage,t.type); if (target.hp<=0) removeEnemy(state,targetIndex,t.owner); }
|
||||||
t.cooldown=towerCooldown(t);
|
t.cooldown=towerCooldown(state,t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function updateWave(state,delta,dispatcher) {
|
function updateWave(state,delta,dispatcher) {
|
||||||
@@ -361,7 +820,13 @@ function updateWave(state,delta,dispatcher) {
|
|||||||
function handleCommand(nk,dispatcher,state,message) {
|
function handleCommand(nk,dispatcher,state,message) {
|
||||||
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
|
var slot=state.slots[message.sender.userId]; if (slot===undefined) return;
|
||||||
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
|
var payload=parseMessage(nk,message); if (payload===null) return; var changed=false;
|
||||||
if (message.opCode===OP_BUILD) changed=handleBuild(state,slot,payload);
|
if (message.opCode===OP_BUILD) {
|
||||||
|
changed=handleBuild(state,slot,payload,message.sender.userId);
|
||||||
|
if (changed&&Math.floor(Number(payload.tower_type))<TOWERS.length) {
|
||||||
|
try { updatePlayerActivity(nk,message.sender.userId,"tower_build",Math.floor(Number(payload.tower_type)),"",0,Date.now()); }
|
||||||
|
catch (telemetryError) { /* Telemetry must never reject an accepted tower build. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
|
else if (message.opCode===OP_UPGRADE) changed=handleUpgrade(state,slot,payload);
|
||||||
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
|
else if (message.opCode===OP_SELL) changed=handleSell(state,slot,payload);
|
||||||
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
|
else if (message.opCode===OP_START_WAVE) changed=startWave(state);
|
||||||
@@ -372,12 +837,40 @@ function handleCommand(nk,dispatcher,state,message) {
|
|||||||
state.players[message.sender.userId]={name:name||"PILOT"};
|
state.players[message.sender.userId]={name:name||"PILOT"};
|
||||||
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
|
var ids=Object.keys(state.slots),allReady=ids.length===2&&Object.keys(state.presences).length===2;
|
||||||
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
|
for (var i=0;i<ids.length;i++) if (!state.ready[ids[i]]||state.presences[ids[i]]===undefined) allReady=false;
|
||||||
if (allReady) { state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{}); }
|
if (allReady) {
|
||||||
|
state.started=true; state.pausedForReconnect=false; dispatcher.matchLabelUpdate(matchLabel(state)); broadcastEvent(dispatcher,"match_started",{});
|
||||||
|
for (var playerIndex=0;playerIndex<ids.length;playerIndex++) {
|
||||||
|
try { updatePlayerActivity(nk,ids[playerIndex],"play_start",-1,"",0,Date.now()); }
|
||||||
|
catch (telemetryError) { /* Telemetry must never stop the match. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
changed=true;
|
changed=true;
|
||||||
}
|
}
|
||||||
|
else if (message.opCode===OP_DEV_AUTH&&state.started&&!state.gameOver) {
|
||||||
|
if (DEV_ADMIN_USER_IDS[message.sender.userId]===true&&payload.password===DEV_MODE_PASSWORD) {
|
||||||
|
state.devAuthorized[message.sender.userId]=true;
|
||||||
|
broadcastEvent(dispatcher,"developer_authorized",{slot:slot});
|
||||||
|
changed=true;
|
||||||
|
} else {
|
||||||
|
broadcastEvent(dispatcher,"developer_auth_rejected",{slot:slot});
|
||||||
|
changed=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (message.opCode===OP_DEV_ENERGY&&state.started&&!state.gameOver&&state.devAuthorized[message.sender.userId]) {
|
||||||
|
var energyChange=Math.floor(Number(payload.amount));
|
||||||
|
if (energyChange===500||energyChange===-500) {
|
||||||
|
state.credits[slot]=Math.max(0,state.credits[slot]+energyChange);
|
||||||
|
changed=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
|
if (!changed) broadcastEvent(dispatcher,"command_rejected",{op_code:message.opCode,player:slot});
|
||||||
}
|
}
|
||||||
function matchLoop(ctx,logger,nk,dispatcher,tick,state,messages) {
|
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) {
|
if (!Object.keys(state.presences).length) {
|
||||||
state.emptyTicks += 1;
|
state.emptyTicks += 1;
|
||||||
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
|
if (state.emptyTicks >= TICK_RATE * 60) { deletePrivateRoomRecord(nk,state.roomCode); return null; }
|
||||||
@@ -386,21 +879,126 @@ 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.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 (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);
|
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};
|
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}; }
|
function matchTerminate(ctx,logger,nk,dispatcher,tick,state,graceSeconds) { deletePrivateRoomRecord(nk,state.roomCode); broadcastEvent(dispatcher,"server_shutdown",{grace:graceSeconds}); return {state:state}; }
|
||||||
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
|
function matchSignal(ctx,logger,nk,dispatcher,tick,state,data) { return {state:state,data:JSON.stringify(snapshot(state,tick))}; }
|
||||||
|
|
||||||
|
function sanitizeDiagnosticRecord(input) {
|
||||||
|
if (!input || typeof input !== "object") return null;
|
||||||
|
var allowedEvents = ["window","snapshot_gap","tick_jump","frame_stall","socket_closed","socket_reconnected","socket_reconnect_failed","side","end"];
|
||||||
|
var event = String(input.event || "");
|
||||||
|
if (allowedEvents.indexOf(event) < 0) return null;
|
||||||
|
var result = {event:event};
|
||||||
|
var utc = String(input.utc || "");
|
||||||
|
if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d/.test(utc)) result.utc = utc.slice(0, 25);
|
||||||
|
var fields = ["seconds","snapshots","snapshots_per_second","gap_p95_ms","gap_max_ms","gaps_over_500_ms","last_server_tick","tick_jump_max","tick_jumps_over_1","bytes_avg","bytes_max","parse_max_us","apply_max_us","frame_max_ms","frame_stalls_over_200_ms","coalesced","queue_dropped","queue_max","enemies","towers","duration_ms","from_tick","to_tick","steps","attempt"];
|
||||||
|
for (var i = 0; i < fields.length; i++) {
|
||||||
|
var value = Number(input[fields[i]]);
|
||||||
|
if (input[fields[i]] !== undefined && isFinite(value) && value >= 0 && value <= 1000000000) result[fields[i]] = value;
|
||||||
|
}
|
||||||
|
if (event === "end") result.reason = ["left_match","cancelled_matchmaking","game_over","victory","new_match"].indexOf(String(input.reason)) >= 0 ? String(input.reason) : "other";
|
||||||
|
if (event === "side") result.viewing_right = input.viewing_right === true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function rpcSubmitOnlineDiagnostics(ctx, logger, nk, payload) {
|
||||||
|
if (!ctx.userId) throw new Error("authentication required");
|
||||||
|
if (!payload || payload.length > 65000) return JSON.stringify({accepted:false,error:"too_large"});
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload); } catch (error) { return JSON.stringify({accepted:false,error:"invalid_json"}); }
|
||||||
|
var matchId = String(request.match_id || "");
|
||||||
|
if (!matchId || matchId.length > 128 || !Array.isArray(request.records) || request.records.length > 160)
|
||||||
|
return JSON.stringify({accepted:false,error:"invalid_payload"});
|
||||||
|
var roundId = nk.sha256Hash(matchId).slice(0, 16);
|
||||||
|
var participant = readStorageRecord(nk, DIAG_PARTICIPANT_COLLECTION, roundId, SYSTEM_USER_ID);
|
||||||
|
var slots = participant && participant.value ? participant.value.participants || {} : {};
|
||||||
|
var participantKey = nk.sha256Hash(matchId + ":" + ctx.userId);
|
||||||
|
if (slots[participantKey] === undefined || Date.now() - Number(participant.value.created_at || 0) > DIAG_RETENTION_MS)
|
||||||
|
return JSON.stringify({accepted:false,error:"not_participant"});
|
||||||
|
var slot = Number(slots[participantKey]);
|
||||||
|
if (slot !== 0 && slot !== 1) return JSON.stringify({accepted:false,error:"invalid_slot"});
|
||||||
|
var records = request.records.map(sanitizeDiagnosticRecord).filter(function(record) { return record !== null; });
|
||||||
|
if (!records.length) return JSON.stringify({accepted:false,error:"no_records"});
|
||||||
|
var key = roundId + "-" + slot;
|
||||||
|
nk.storageWrite([{collection:DIAG_REPORT_COLLECTION,key:key,userId:SYSTEM_USER_ID,
|
||||||
|
value:{round_id:roundId,slot:slot,created_at:Date.now(),version:normalizeMetricLabel(request.version,"unknown"),records:records},
|
||||||
|
permissionRead:0,permissionWrite:0}]);
|
||||||
|
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
|
||||||
|
if (logger.info) logger.info("ONLINE DIAG received round=%s slot=%d records=%d", roundId, slot, records.length);
|
||||||
|
return JSON.stringify({accepted:true,round_id:roundId,slot:slot});
|
||||||
|
}
|
||||||
|
function pruneOnlineDiagnostics(nk) {
|
||||||
|
var collections = [DIAG_REPORT_COLLECTION, DIAG_PARTICIPANT_COLLECTION];
|
||||||
|
var cutoff = Date.now() - DIAG_RETENTION_MS;
|
||||||
|
for (var c = 0; c < collections.length; c++) {
|
||||||
|
var cursor = "", pages = 0, stale = [];
|
||||||
|
do {
|
||||||
|
var page = nk.storageList(SYSTEM_USER_ID, collections[c], 100, cursor);
|
||||||
|
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
|
||||||
|
for (var i = 0; i < objects.length; i++) {
|
||||||
|
if (Number(objects[i].value && objects[i].value.created_at || 0) < cutoff && objects[i].key)
|
||||||
|
stale.push({collection:collections[c],key:objects[i].key,userId:SYSTEM_USER_ID});
|
||||||
|
}
|
||||||
|
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
|
||||||
|
pages++;
|
||||||
|
} while (cursor && pages < 10);
|
||||||
|
for (var deleteIndex = 0; deleteIndex < stale.length; deleteIndex += 100)
|
||||||
|
nk.storageDelete(stale.slice(deleteIndex, deleteIndex + 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rpcListOnlineDiagnostics(ctx, logger, nk, payload) {
|
||||||
|
if (ctx.userId) throw new Error("server-to-server access required");
|
||||||
|
try { pruneOnlineDiagnostics(nk); } catch (error) { if (logger.warn) logger.warn("ONLINE DIAG cleanup failed: %s", error); }
|
||||||
|
var request;
|
||||||
|
try { request = JSON.parse(payload || "{}"); } catch (error) { request = {}; }
|
||||||
|
var requestedRound = String(request.round_id || "");
|
||||||
|
if (requestedRound) {
|
||||||
|
if (!/^[a-f0-9]{16}$/.test(requestedRound)) return JSON.stringify({reports:[],error:"invalid_round_id"});
|
||||||
|
var pair = nk.storageRead([0,1].map(function(slot) { return {collection:DIAG_REPORT_COLLECTION,key:requestedRound+"-"+slot,userId:SYSTEM_USER_ID}; }));
|
||||||
|
return JSON.stringify({reports:pair.map(function(record) { return record.value; })});
|
||||||
|
}
|
||||||
|
var reports = [], cursor = "", pages = 0, cutoff = Date.now() - DIAG_RETENTION_MS;
|
||||||
|
do {
|
||||||
|
var page = nk.storageList(SYSTEM_USER_ID, DIAG_REPORT_COLLECTION, 100, cursor);
|
||||||
|
var objects = Array.isArray(page) ? page : (page && Array.isArray(page.objects) ? page.objects : []);
|
||||||
|
for (var i = 0; i < objects.length; i++) {
|
||||||
|
var object = objects[i], value = object && object.value ? object.value : {};
|
||||||
|
if (Number(value.created_at || 0) >= cutoff) reports.push({round_id:value.round_id,slot:value.slot,
|
||||||
|
created_at:value.created_at,version:value.version,record_count:Array.isArray(value.records)?value.records.length:0});
|
||||||
|
}
|
||||||
|
cursor = page && !Array.isArray(page) ? String(page.cursor || "") : "";
|
||||||
|
pages++;
|
||||||
|
} while (cursor && pages < 10);
|
||||||
|
reports.sort(function(a,b) { return b.created_at - a.created_at; });
|
||||||
|
return JSON.stringify({reports:reports.slice(0,100),truncated:!!cursor||reports.length>100});
|
||||||
|
}
|
||||||
|
|
||||||
function InitModule(ctx,logger,nk,initializer) {
|
function InitModule(ctx,logger,nk,initializer) {
|
||||||
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
for (var mode=0;mode<VALID_MODES.length;mode++) for (var map=0;map<MAP_COUNT;map++) for (var difficulty=0;difficulty<DIFFICULTY_COUNT;difficulty++) nk.leaderboardCreate(leaderboardId(map,difficulty,VALID_MODES[mode]),true,"desc","best",null,{game:"void_bastion"});
|
||||||
initializer.registerRpc("server_info",rpcServerInfo);
|
initializer.registerRpc("server_info",rpcServerInfo);
|
||||||
initializer.registerRpc("record_activity",rpcRecordActivity);
|
initializer.registerRpc("record_activity",rpcRecordActivity);
|
||||||
|
initializer.registerRpc("record_player_event",rpcRecordPlayerEvent);
|
||||||
initializer.registerRpc("private_stats",rpcPrivateStats);
|
initializer.registerRpc("private_stats",rpcPrivateStats);
|
||||||
|
initializer.registerRpc("list_registered_players",rpcListRegisteredPlayers);
|
||||||
|
initializer.registerRpc("get_registered_player",rpcGetRegisteredPlayer);
|
||||||
|
initializer.registerRpc("get_game_balance",rpcGetGameBalance);
|
||||||
|
initializer.registerRpc("set_game_balance",rpcSetGameBalance);
|
||||||
|
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("submit_run",rpcSubmitRun);
|
||||||
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
|
initializer.registerRpc("update_display_name",rpcUpdateDisplayName);
|
||||||
|
initializer.registerRpc("find_player_by_name",rpcFindPlayerByName);
|
||||||
|
initializer.registerRpc("submit_online_diagnostics",rpcSubmitOnlineDiagnostics);
|
||||||
|
initializer.registerRpc("list_online_diagnostics",rpcListOnlineDiagnostics);
|
||||||
initializer.registerRpc("delete_account",rpcDeleteAccount);
|
initializer.registerRpc("delete_account",rpcDeleteAccount);
|
||||||
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
|
initializer.registerRpc("create_private_room",rpcCreatePrivateRoom);
|
||||||
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
|
initializer.registerRpc("join_private_room",rpcJoinPrivateRoom);
|
||||||
|
initializer.registerRpc("invite_friend",rpcInviteFriend);
|
||||||
initializer.registerMatchmakerMatched(matchmakerMatched);
|
initializer.registerMatchmakerMatched(matchmakerMatched);
|
||||||
initializer.registerMatch(MATCH_MODULE,{matchInit:matchInit,matchJoinAttempt:matchJoinAttempt,matchJoin:matchJoin,matchLeave:matchLeave,matchLoop:matchLoop,matchTerminate:matchTerminate,matchSignal:matchSignal});
|
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);
|
logger.info("Void Bastion online runtime %s loaded",GAME_VERSION);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||||
|
const context = { console, Math, Date, JSON, isFinite };
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
|
||||||
|
function duel(towerType, enemyType, wave = 1, boss = false, difficulty = 1) {
|
||||||
|
const base = context.ENEMIES[enemyType];
|
||||||
|
const waveScale = (1 + Math.max(0, wave - 1) * 0.20 + Math.max(0, wave - 3) * 0.09 + (difficulty === 2 ? Math.max(0, wave - 5) * 0.055 : 0)) * (boss ? 3.25 : 1) * context.DIFFICULTIES[difficulty].hp;
|
||||||
|
const hullScale = waveScale * (boss && wave === 5 ? 0.5 : 1);
|
||||||
|
const shielded = enemyType === 4 || (boss && wave === 5);
|
||||||
|
const shield = shielded ? base.hp * waveScale * 0.55 * (boss && wave === 5 ? 0.5 : 1) : 0;
|
||||||
|
const testedEnemy = { id: 1, type: enemyType, x: -40, y: 120, path_index: 1, route_id: 0,
|
||||||
|
hp: base.hp * hullScale, max_hp: base.hp * hullScale, speed: base.speed * (1 + wave * 0.012) * (boss ? 0.82 : 1) * context.DIFFICULTIES[difficulty].speed,
|
||||||
|
reward: base.reward, shield: shield > 0, shield_hp: shield, max_shield_hp: shield,
|
||||||
|
shield_resistance: base.shieldResistance, boss };
|
||||||
|
const state = {
|
||||||
|
wave,
|
||||||
|
balance: { towers: context.TOWERS, enemies: context.ENEMIES },
|
||||||
|
towers: [{ id: 1, type: towerType, x: 540, y: 220, owner: 0, level: 1, cooldown: 0, angle: 0 }],
|
||||||
|
enemies: [testedEnemy],
|
||||||
|
shots: [], credits: [0, 0], reactorLives: 30, gameOver: false, waveActive: true, spawnRemaining: 0
|
||||||
|
};
|
||||||
|
const tick = 0.1;
|
||||||
|
let elapsed = 0;
|
||||||
|
for (; elapsed < 90 && state.enemies.length; elapsed += tick) {
|
||||||
|
context.updateEnemies(state, tick);
|
||||||
|
context.updateTowers(state, tick);
|
||||||
|
}
|
||||||
|
const alive = state.enemies.length > 0;
|
||||||
|
const breached = state.reactorLives < 30;
|
||||||
|
const remainingHp = Math.max(0, testedEnemy.hp);
|
||||||
|
return { alive, killed: !alive && !breached && remainingHp <= 0, remainingHp, seconds: elapsed,
|
||||||
|
breached, damageTaken: base.hp * hullScale - remainingHp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
let evaluated = 0;
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
const towerData = context.TOWERS[tower];
|
||||||
|
const results = [];
|
||||||
|
for (let enemy = 0; enemy < context.ENEMIES.length; enemy++) {
|
||||||
|
const enemyData = context.ENEMIES[enemy];
|
||||||
|
if (towerData.targetClass !== "both" && towerData.targetClass !== enemyData.category) continue;
|
||||||
|
const regular = duel(tower, enemy);
|
||||||
|
const late = duel(tower, enemy, 15, false, 2);
|
||||||
|
evaluated++;
|
||||||
|
results.push(`${enemy}:${regular.killed ? `kill ${regular.seconds.toFixed(1)}s` : `leaks ${Math.round(regular.remainingHp)} HP`}/${late.killed ? `H W15 kill ${late.seconds.toFixed(1)}s` : `H W15 leaks ${Math.round(late.remainingHp)} HP`}`);
|
||||||
|
}
|
||||||
|
rows.push(`${towerData.targetClass.padEnd(6)} tower ${tower}: ${results.join(" | ")}`);
|
||||||
|
}
|
||||||
|
const bossResults = [];
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
if (context.TOWERS[tower].targetClass !== "air" && context.TOWERS[tower].targetClass !== "both") continue;
|
||||||
|
const result = duel(tower, 5, 5, true, 1);
|
||||||
|
bossResults.push(`tower ${tower}: ${result.killed ? `killed in ${result.seconds.toFixed(1)}s` : `reaches reactor with ${Math.round(result.remainingHp)} HP`}`);
|
||||||
|
}
|
||||||
|
assert.ok(evaluated >= 20, "the matrix must cover all valid tower/enemy matchups");
|
||||||
|
console.log("BALANCE DUEL RESULTS (one level-1 tower, one enemy, fixed path and no reinvestment)");
|
||||||
|
console.log(rows.join("\n"));
|
||||||
|
console.log("WAVE 5 FIRST BOSS (single-tower reference): " + bossResults.join(" | "));
|
||||||
|
console.log(`BALANCE_DUEL_TEST_OK matchups=${evaluated}`);
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||||
|
const context = { console, Math, Date, JSON, isFinite };
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
|
||||||
|
function seedRandom(seed) {
|
||||||
|
let value = seed >>> 0;
|
||||||
|
return function () {
|
||||||
|
value = (value * 1664525 + 1013904223) >>> 0;
|
||||||
|
return value / 0x100000000;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePath(route) {
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < route.length - 1; i++) {
|
||||||
|
const [ax, ay] = route[i], [bx, by] = route[i + 1];
|
||||||
|
const length = Math.hypot(bx - ax, by - ay);
|
||||||
|
const steps = Math.max(1, Math.ceil(length / 40));
|
||||||
|
for (let step = 0; step < steps; step++) {
|
||||||
|
const ratio = step / steps;
|
||||||
|
samples.push([ax + (bx - ax) * ratio, ay + (by - ay) * ratio, length / steps]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSamples = [samplePath(context.PATHS[0]), samplePath(context.PATHS[1])];
|
||||||
|
const areaFactor = [1, 1, 1.65, 1.6, 1.9, 1.35, 1];
|
||||||
|
|
||||||
|
function nextWaveComposition(state, wave) {
|
||||||
|
const schedule = context.buildWaveSchedule(state, wave, context.wavePopulation(state, wave));
|
||||||
|
const counts = Array(context.ENEMIES.length).fill(0);
|
||||||
|
for (const entry of schedule) counts[entry.type] += 1;
|
||||||
|
return { schedule, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseTowerType(state, composition, fixedType) {
|
||||||
|
if (fixedType !== null) return fixedType;
|
||||||
|
let bestType = 1, bestScore = -1;
|
||||||
|
for (let towerType = 0; towerType < context.TOWERS.length; towerType++) {
|
||||||
|
const tower = context.TOWERS[towerType];
|
||||||
|
let score = 0;
|
||||||
|
for (let enemyType = 0; enemyType < context.ENEMIES.length; enemyType++) {
|
||||||
|
const count = composition.counts[enemyType];
|
||||||
|
if (!count) continue;
|
||||||
|
const enemy = context.ENEMIES[enemyType];
|
||||||
|
if (tower.targetClass !== "both" && tower.targetClass !== enemy.category) continue;
|
||||||
|
const damage = tower.damage * (tower.targetClass === "both" ? tower.bothScale || 1 : 1);
|
||||||
|
const exposedDps = damage / tower.cooldown;
|
||||||
|
const shieldChance = enemyType === 4 ? 1 : 0.25;
|
||||||
|
const shieldMultiplier = towerType === 1 || towerType === 5 ? 1 : 1 - shieldChance * (1 - (enemy.shieldResistance || 0.28));
|
||||||
|
const bossWeight = composition.schedule.some(entry => entry.type === enemyType && entry.boss) ? 1.2 : 1;
|
||||||
|
score += count * Math.sqrt(enemy.hp) * exposedDps * shieldMultiplier * areaFactor[towerType] * bossWeight;
|
||||||
|
}
|
||||||
|
score /= Math.max(1, tower.cost);
|
||||||
|
if (score > bestScore) { bestScore = score; bestType = towerType; }
|
||||||
|
}
|
||||||
|
return bestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePosition(state, owner, range) {
|
||||||
|
const xMin = owner === 0 ? 120 : 1520;
|
||||||
|
const xMax = owner === 0 ? 1440 : 2840;
|
||||||
|
let best = null, bestScore = -1;
|
||||||
|
for (let y = 120; y <= 680; y += 80) for (let x = xMin; x <= xMax; x += 80) {
|
||||||
|
if (!context.validBuildPosition(state, owner, x, y)) continue;
|
||||||
|
const rangeSq = range * range;
|
||||||
|
let score = 0;
|
||||||
|
for (const [px, py, weight] of pathSamples[owner]) {
|
||||||
|
const covered = state.towers.some(tower => tower.owner === owner &&
|
||||||
|
context.distanceSquared(tower.x, tower.y, px, py) <= context.towerRange(state, tower) ** 2);
|
||||||
|
if (!covered && context.distanceSquared(x, y, px, py) <= rangeSq) score += weight;
|
||||||
|
}
|
||||||
|
if (score > bestScore) { bestScore = score; best = [x, y]; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMission(difficulty, fixedType = null) {
|
||||||
|
let random = seedRandom(0xB45710 + difficulty * 7919 + (fixedType === null ? 0 : fixedType * 97));
|
||||||
|
context.Math = Object.create(Math);
|
||||||
|
context.Math.random = () => random();
|
||||||
|
const state = context.initialState({ difficulty, mode: "balance_test" });
|
||||||
|
state.started = true;
|
||||||
|
let wavesCompleted = 0, totalSpawned = 0, totalBreached = 0;
|
||||||
|
const trace = [];
|
||||||
|
const maxTicksPerWave = 24000;
|
||||||
|
|
||||||
|
for (let wave = 1; wave <= 15 && !state.gameOver; wave++) {
|
||||||
|
const composition = nextWaveComposition(state, wave);
|
||||||
|
for (let owner = 0; owner < 2; owner++) {
|
||||||
|
let guard = 32;
|
||||||
|
while (guard-- > 0) {
|
||||||
|
const towerType = chooseTowerType(state, composition, fixedType);
|
||||||
|
const tower = context.TOWERS[towerType];
|
||||||
|
if (state.credits[owner] < tower.cost) break;
|
||||||
|
const position = choosePosition(state, owner, tower.range);
|
||||||
|
if (!position) break;
|
||||||
|
state.credits[owner] -= tower.cost;
|
||||||
|
state.towers.push({ id: state.nextTowerId++, type: towerType, x: position[0], y: position[1], owner, level: 1, cooldown: 0.15, angle: owner === 0 ? 0 : Math.PI });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!context.startWave(state)) break;
|
||||||
|
const beforeSpawn = state.nextEnemyId;
|
||||||
|
let tick = 0;
|
||||||
|
while (state.waveActive && !state.gameOver && tick++ < maxTicksPerWave) {
|
||||||
|
context.updateWave(state, 0.1, { broadcastMessage() {} });
|
||||||
|
context.updateEnemies(state, 0.1);
|
||||||
|
context.updateTowers(state, 0.1);
|
||||||
|
}
|
||||||
|
totalSpawned += state.nextEnemyId - beforeSpawn;
|
||||||
|
totalBreached += Math.max(0, 30 - state.reactorLives);
|
||||||
|
trace.push({wave, lives: state.reactorLives, spawned: state.nextEnemyId - beforeSpawn, towers: state.towers.length, credits: [...state.credits], enemyLeft: state.enemies.length, towerTypes: state.towers.map(t => t.type)});
|
||||||
|
if (tick >= maxTicksPerWave) break;
|
||||||
|
if (!state.gameOver) wavesCompleted++;
|
||||||
|
}
|
||||||
|
return { difficulty, fixedType, wavesCompleted, reactorLives: state.reactorLives, trace,
|
||||||
|
towers: state.towers.length, spawned: totalSpawned, remainingEnemies: state.enemies.length,
|
||||||
|
credits: state.credits.reduce((sum, value) => sum + value, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = [];
|
||||||
|
for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) {
|
||||||
|
baseline.push(runMission(difficulty));
|
||||||
|
}
|
||||||
|
const towerRuns = [];
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
for (let difficulty = 0; difficulty < context.DIFFICULTIES.length; difficulty++) {
|
||||||
|
towerRuns.push(runMission(difficulty, tower));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(baseline.length, 3);
|
||||||
|
assert.ok(towerRuns.length === context.TOWERS.length * 3);
|
||||||
|
assert.ok(baseline.every(result => result.spawned > 0), "mission simulations must use the real wave scheduler");
|
||||||
|
console.log("BALANCE MISSION RESULTS (Nakama wave/combat code, fixed-seed greedy build planner, level-1 towers)");
|
||||||
|
console.log("planner: " + baseline.map(r => `D${r.difficulty} ${r.wavesCompleted}/15 waves, reactor ${r.reactorLives}, ${r.towers} towers`).join(" | "));
|
||||||
|
for (let tower = 0; tower < context.TOWERS.length; tower++) {
|
||||||
|
const values = towerRuns.filter(result => result.fixedType === tower)
|
||||||
|
.map(r => `D${r.difficulty} ${r.wavesCompleted}/15, R${r.reactorLives}, ${r.towers}T`).join(" | ");
|
||||||
|
console.log(`tower ${tower} (${context.TOWERS[tower].targetClass}): ${values}`);
|
||||||
|
}
|
||||||
|
console.log(`BALANCE_MISSION_TEST_OK scenarios=${baseline.length + towerRuns.length}`);
|
||||||
@@ -2,12 +2,177 @@ const assert = require("assert");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const vm = require("vm");
|
const vm = require("vm");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
const source = fs.readFileSync(path.join(__dirname, "..", "runtime", "build", "index.js"), "utf8");
|
||||||
const context = { console, Math, Date, JSON, isFinite };
|
const context = { console, Math, Date, JSON, isFinite };
|
||||||
vm.createContext(context);
|
vm.createContext(context);
|
||||||
vm.runInContext(source, 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.");
|
||||||
|
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.");
|
||||||
|
assert.strictEqual(context.TOWERS[6].damage, 22, "The air-only Flak tower should receive its focused damage tuning.");
|
||||||
|
assert.strictEqual(context.ENEMIES[5].hp, 620, "The first aerial boss base hull should use the reduced balance value.");
|
||||||
|
assert.strictEqual(context.initialState({ difficulty: 0 }).difficulty, 0, "Easy must not be coerced to the default difficulty.");
|
||||||
|
assert.strictEqual(context.initialState({ difficulty: 0 }).credits[0], context.DIFFICULTIES[0].startCredits);
|
||||||
|
const balanceStorage = new Map();
|
||||||
|
let balanceVersion = 0;
|
||||||
|
const balanceNk = {
|
||||||
|
storageRead(ops) { return ops.map(op => balanceStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) {
|
||||||
|
for (const op of ops) {
|
||||||
|
balanceVersion += 1;
|
||||||
|
balanceStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: String(balanceVersion) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const serverBalance = JSON.parse(context.rpcGetGameBalance({}, {}, balanceNk, "{}"));
|
||||||
|
assert.strictEqual(serverBalance.config.towers[0].damage, 22, "The internal PHP page calls balance read through Nakama's HTTP-key server-to-server RPC context, which has no userId.");
|
||||||
|
const adminBalance = JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}"));
|
||||||
|
assert.strictEqual(adminBalance.config.towers[0].damage, 22);
|
||||||
|
const editedBalance = JSON.parse(JSON.stringify(adminBalance.config));
|
||||||
|
editedBalance.towers[0].damage = 31;
|
||||||
|
editedBalance.enemies[0].hp = 90;
|
||||||
|
editedBalance.admin_tower.range = 700;
|
||||||
|
editedBalance.admin_tower.cooldown = 0.12;
|
||||||
|
assert.throws(() => context.rpcSetGameBalance({ userId: "pilot-b" }, {}, balanceNk, JSON.stringify({ config: editedBalance })), /server-to-server/);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: editedBalance }))).accepted, true);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcGetGameBalance({ userId: "pilot-b" }, {}, balanceNk, "{}")).config.towers[0].damage, 31);
|
||||||
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.enemies[0].hp, 90);
|
||||||
|
assert.strictEqual(context.matchInit({}, {}, balanceNk, { difficulty: 1 }).state.balance.admin_tower.range, 700);
|
||||||
|
assert.throws(() => context.rpcSetGameBalance({}, {}, balanceNk, JSON.stringify({ config: { towers: [], enemies: [] } })), /invalid balance schema/);
|
||||||
|
const legacyStorage = new Map();
|
||||||
|
const legacyKey = `game_balance:${context.SYSTEM_USER_ID}:global`;
|
||||||
|
const legacyConfig = JSON.parse(JSON.stringify(context.defaultBalanceConfig()));
|
||||||
|
legacyConfig.schema = 1;
|
||||||
|
legacyConfig.towers[0].damage = 31;
|
||||||
|
legacyConfig.towers[6].damage = 20;
|
||||||
|
legacyConfig.enemies[5].hp = 740;
|
||||||
|
legacyStorage.set(legacyKey, { collection: "game_balance", key: "global", userId: context.SYSTEM_USER_ID,
|
||||||
|
value: { config: legacyConfig, updated_at: 1 }, permissionRead: 0, permissionWrite: 0, version: "7" });
|
||||||
|
const migrationNk = {
|
||||||
|
storageRead(ops) { return ops.map(op => legacyStorage.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) { for (const op of ops) legacyStorage.set(`${op.collection}:${op.userId}:${op.key}`, { ...op, version: "8" }); }
|
||||||
|
};
|
||||||
|
const migratedBalance = context.readBalanceConfig(migrationNk);
|
||||||
|
assert.strictEqual(migratedBalance.config.schema, 3);
|
||||||
|
assert.strictEqual(migratedBalance.config.towers[0].damage, 31, "Balance migrations must preserve unrelated admin tuning.");
|
||||||
|
assert.strictEqual(migratedBalance.config.towers[6].damage, 22, "Unchanged legacy Flak defaults should migrate to the new value.");
|
||||||
|
assert.strictEqual(migratedBalance.config.enemies[5].hp, 620, "Unchanged legacy boss HP should migrate to the new value.");
|
||||||
|
assert.strictEqual(migratedBalance.config.admin_tower.range, 650, "Older balance settings should receive the safer admin range.");
|
||||||
|
assert.strictEqual(legacyStorage.get(legacyKey).value.config.schema, 3, "Migrated balance should persist for future matches.");
|
||||||
|
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 nameLookupNk = {
|
||||||
|
sqlQuery(_query, params) {
|
||||||
|
assert.strictEqual(params[0], "Emporex2", "Player search must use parameter binding.");
|
||||||
|
return [{ id: friendId, display_name: "Emporex2" }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {}, nameLookupNk, JSON.stringify({ display_name: "Emporex2" }))),
|
||||||
|
{ found: true, user_id: friendId, display_name: "Emporex2" });
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
||||||
|
{ sqlQuery: () => [{ id: friendId, display_name: "Same" }, { id: "third", display_name: "Same" }] },
|
||||||
|
JSON.stringify({ display_name: "Same" }))).error, "ambiguous", "Duplicate display names must not invite the wrong account.");
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcFindPlayerByName(inviteCtx, {},
|
||||||
|
{ sqlQuery: () => [{ id: hostId, display_name: "Self" }] },
|
||||||
|
JSON.stringify({ display_name: "Self" }))).error, "not_found");
|
||||||
|
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.");
|
||||||
|
assert.strictEqual(shieldTest.shield_hp, 10);
|
||||||
|
context.applyDamageToEnemy(shieldTest, 20, 1);
|
||||||
|
assert.strictEqual(shieldTest.shield, false);
|
||||||
|
assert.strictEqual(shieldTest.hp, 90, "Laser damage beyond shield capacity should spill into hull.");
|
||||||
|
const priorityState = {
|
||||||
|
towers: [{ id: 1, type: 1, x: 800, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
||||||
|
enemies: [
|
||||||
|
{ id: 201, type: 6, x: 850, y: 120, path_index: 1, hp: 100, shield: true, shield_hp: 50, max_shield_hp: 50, shield_resistance: 0.28 },
|
||||||
|
{ id: 202, type: 0, x: 950, y: 120, path_index: 1, hp: 100, shield: false }
|
||||||
|
],
|
||||||
|
credits: [0, 0], wave: 1
|
||||||
|
};
|
||||||
|
context.updateTowers(priorityState, 1);
|
||||||
|
assert.ok(priorityState.enemies[0].shield_hp < 50, "Online lasers should drain a shield before firing at unshielded units.");
|
||||||
|
assert.strictEqual(priorityState.enemies[1].hp, 100);
|
||||||
|
|
||||||
|
const railgunState = {
|
||||||
|
towers: [{ id: 10, type: 5, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
||||||
|
enemies: [
|
||||||
|
{ id: 301, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
|
||||||
|
{ id: 302, type: 6, x: 500, y: 120, path_index: 1, hp: 1000, shield: false },
|
||||||
|
{ id: 303, type: 0, x: 450, y: 150, path_index: 1, hp: 1000, shield: false }
|
||||||
|
],
|
||||||
|
credits: [0, 0], wave: 1
|
||||||
|
};
|
||||||
|
context.updateTowers(railgunState, 0.01);
|
||||||
|
assert.ok(railgunState.enemies[0].hp < 1000 && railgunState.enemies[1].hp < 1000, "Online railgun should pierce enemies behind its first hit on the same line.");
|
||||||
|
assert.strictEqual(railgunState.enemies[2].hp, 1000, "Online railgun should not hit units away from its beam line.");
|
||||||
|
|
||||||
|
const empState = {
|
||||||
|
towers: [{ id: 11, type: 4, x: 300, y: 120, owner: 0, level: 1, cooldown: 0 }],
|
||||||
|
enemies: [
|
||||||
|
{ id: 401, type: 0, x: 400, y: 120, path_index: 1, hp: 1000, shield: false },
|
||||||
|
{ id: 402, type: 6, x: 450, y: 120, path_index: 1, hp: 1000, shield: false },
|
||||||
|
{ id: 403, type: 6, x: 570, y: 120, path_index: 1, hp: 1000, shield: false }
|
||||||
|
],
|
||||||
|
credits: [0, 0], wave: 1
|
||||||
|
};
|
||||||
|
context.updateTowers(empState, 0.01);
|
||||||
|
assert.strictEqual(empState.enemies[0].hp, 980, "EMP should deal 20 damage to its primary target.");
|
||||||
|
assert.strictEqual(empState.enemies[1].hp, 980, "EMP should deal its reduced 20 damage to nearby ground units too.");
|
||||||
|
assert.strictEqual(empState.enemies[2].hp, 1000, "EMP should not damage units outside its area radius.");
|
||||||
|
|
||||||
const sent = [];
|
const sent = [];
|
||||||
const dispatcher = {
|
const dispatcher = {
|
||||||
broadcastMessage(opCode, data) {
|
broadcastMessage(opCode, data) {
|
||||||
@@ -66,6 +231,119 @@ assert.strictEqual(privateStats.platforms.android, 1);
|
|||||||
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
assert.strictEqual(privateStats.versions["0.1.0-alpha.2"], 2);
|
||||||
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
assert.strictEqual(privateStats.daily_active[context.utcDay(Date.now())], 2);
|
||||||
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
assert.throws(() => context.rpcPrivateStats({ userId: "pilot-a" }, logger, metricNk, "{}"), /server-to-server/);
|
||||||
|
const metricSource = "guest_0123456789abcdef0123456789abcdef";
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "play_start", source: metricSource, sequence: 1, at: Date.now() }))), { accepted: true, duplicate: false });
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: false });
|
||||||
|
assert.deepStrictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 1, source: metricSource, sequence: 2, at: Date.now() }))), { accepted: true, duplicate: true });
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcRecordPlayerEvent({ userId: "pilot-metric-a" }, logger, metricNk,
|
||||||
|
JSON.stringify({ event: "tower_build", tower_type: 7, source: metricSource, sequence: 3 }))).accepted, false, "Admin towers must not enter real tower statistics.");
|
||||||
|
context.rpcRecordActivity({ userId: "pilot-metric-a" }, logger, metricNk, JSON.stringify({ platform: "Windows", version: "0.1.0-alpha.16" }));
|
||||||
|
const metricActivity = metricStorage.get("player_activity:pilot-metric-a:summary").value;
|
||||||
|
assert.strictEqual(metricActivity.tower_builds[1], 1, "Repeated telemetry must not double-count towers.");
|
||||||
|
assert.ok(metricActivity.last_played > 0, "Game sessions should track when the player last played.");
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("godot_a_2323883"), true);
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("social_b_2289087"), true);
|
||||||
|
assert.strictEqual(context.isInternalTestUsername("Emporex"), false);
|
||||||
|
const directoryNk = {
|
||||||
|
sqlQuery(sql) {
|
||||||
|
if (sql.includes("count(*) AS total")) return [{ total: 7, active_today: 3 }];
|
||||||
|
if (sql.includes("WHERE id=$1::uuid")) return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000 }];
|
||||||
|
return [{ id: "11111111-1111-1111-1111-111111111111", username: "pilot_real", display_name: "Real Pilot", registered_at: 1000, last_seen: 2000, last_played: 3000, tower_build_total: 2 }];
|
||||||
|
},
|
||||||
|
storageRead() { return [{ value: { tower_builds: [0, 2, 0, 0, 0, 0, 0], tower_build_total: 2, last_seen: 2000, last_played: 3000 } }]; }
|
||||||
|
};
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcListRegisteredPlayers({}, logger, directoryNk, "{}")).total, 7);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcGetRegisteredPlayer({}, logger, directoryNk,
|
||||||
|
JSON.stringify({ user_id: "11111111-1111-1111-1111-111111111111" }))).player.tower_builds[1], 2);
|
||||||
|
assert.throws(() => context.rpcListRegisteredPlayers({ userId: "pilot-a" }, logger, directoryNk, "{}"), /server-to-server/);
|
||||||
|
nk.storageRead = metricNk.storageRead.bind(metricNk);
|
||||||
|
nk.storageWrite = metricNk.storageWrite.bind(metricNk);
|
||||||
|
|
||||||
|
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;
|
let leaderboardWrite = null;
|
||||||
const leaderboardNk = {
|
const leaderboardNk = {
|
||||||
@@ -128,27 +406,142 @@ state = context.matchLoop({}, logger, nk, dispatcher, 5, state, [
|
|||||||
assert.strictEqual(state.towers.length, 2);
|
assert.strictEqual(state.towers.length, 2);
|
||||||
assert.strictEqual(state.credits[0], 310);
|
assert.strictEqual(state.credits[0], 310);
|
||||||
assert.strictEqual(state.credits[1], 250);
|
assert.strictEqual(state.credits[1], 250);
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_builds[0], 1, "Accepted online tower builds should be counted on the server.");
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-b:summary").value.tower_builds[1], 1);
|
||||||
|
|
||||||
state = context.matchLoop({}, logger, nk, dispatcher, 6, state, [message(presenceA, 13, {})]).state;
|
state = context.matchLoop({}, logger, nk, dispatcher, 51, state,
|
||||||
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 }),
|
||||||
|
message(presenceB, 15, { password: "incorrect" }),
|
||||||
|
message(presenceB, 16, { amount: 500 })]).state;
|
||||||
|
assert.strictEqual(state.towers.length, 2, "Admin tower builds must be rejected before developer authorization.");
|
||||||
|
assert.strictEqual(state.credits[1], 250, "Unauthorized player must not be able to adjust test energy.");
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "Wrong developer password must not authorize the player.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 52, state,
|
||||||
|
[message(presenceB, 15, { password: "Spring.3" })]).state;
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-b"], undefined, "A non-admin account must not gain developer access even with the password.");
|
||||||
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["pilot-a"], undefined);
|
||||||
|
assert.strictEqual(context.DEV_ADMIN_USER_IDS["6605c44c-17a2-4cc4-bf3e-e772fd6229b0"], true);
|
||||||
|
context.DEV_ADMIN_USER_IDS["pilot-a"] = true;
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 53, state,
|
||||||
|
[message(presenceA, 15, { password: "Spring.3" })]).state;
|
||||||
|
assert.strictEqual(state.devAuthorized["pilot-a"], true, "Correct developer password should authorize this player for the match.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 54, state,
|
||||||
|
[message(presenceA, 16, { amount: 500 })]).state;
|
||||||
|
assert.strictEqual(state.credits[0], 810, "Authorized test energy addition should change server credits by 500.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 55, state,
|
||||||
|
[message(presenceA, 16, { amount: -500 })]).state;
|
||||||
|
assert.strictEqual(state.credits[0], 310, "Authorized test energy removal should change server credits by 500.");
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 56, state,
|
||||||
|
[message(presenceA, 10, { tower_type: 7, x: 2200, y: 600 })]).state;
|
||||||
|
assert.ok(state.towers.some(tower => tower.type === 7 && tower.owner === 0), "Authorized player may build the admin turret anywhere on the co-op map.");
|
||||||
|
assert.strictEqual(metricStorage.get("player_activity:pilot-a:summary").value.tower_build_total, 1, "Admin towers are excluded from player statistics.");
|
||||||
|
const adminCombatState = {
|
||||||
|
balance: state.balance,
|
||||||
|
towers: [{ id: 99, type: 7, x: 2200, y: 600, owner: 0, level: 1, cooldown: 0, angle: 0 }],
|
||||||
|
enemies: [
|
||||||
|
{ id: 99, type: 8, x: 2250, y: 600, route_id: 1, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 },
|
||||||
|
{ id: 100, type: 8, x: 2250, y: 610, route_id: 0, path_index: 1, hp: 100000, max_hp: 100000, shield: true, shield_hp: 1000, shield_resistance: 0.28 }
|
||||||
|
],
|
||||||
|
shots: [], credits: [0, 0]
|
||||||
|
};
|
||||||
|
context.updateTowers(adminCombatState, 0.1);
|
||||||
|
assert.deepStrictEqual(adminCombatState.enemies.map(enemy => enemy.id), [100], "The admin turret must one-shot shielded enemies only on its own route.");
|
||||||
|
|
||||||
|
state = context.matchLoop({}, logger, nk, dispatcher, 57, state, [message(presenceA, 13, {})]).state;
|
||||||
assert.strictEqual(state.wave, 1);
|
assert.strictEqual(state.wave, 1);
|
||||||
assert.strictEqual(state.waveActive, true);
|
assert.strictEqual(state.waveActive, true);
|
||||||
for (let tick = 7; tick < 30; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
|
assert.strictEqual(context.snapshot(state, 56).wave_types_remaining.length, state.spawnRemaining);
|
||||||
|
const groundSchedule = context.buildWaveSchedule(state, 3, context.wavePopulation(state, 3));
|
||||||
|
assert.ok(groundSchedule.some(entry => context.ENEMIES[entry.type].category === "ground"), "Ground units should enter the wave schedule from wave 3.");
|
||||||
|
for (let tick = 57; tick < 80; tick++) state = context.matchLoop({}, logger, nk, dispatcher, tick, state, []).state;
|
||||||
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
|
assert.ok(state.enemies.length > 0 || state.spawnRemaining > 0);
|
||||||
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
|
assert.ok(sent.some(entry => entry.opCode === 1 && entry.data.started === true));
|
||||||
|
const compactSnapshot = context.snapshot(state, 79);
|
||||||
|
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);
|
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;
|
state = context.matchLoop({}, logger, nk, dispatcher, 80, state, [message(presenceA, 11, { tower_id: towerA.id })]).state;
|
||||||
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
assert.strictEqual(state.towers.find(tower => tower.id === towerA.id).level, 2);
|
||||||
state = context.matchLoop({}, logger, nk, dispatcher, 31, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
state = context.matchLoop({}, logger, nk, dispatcher, 81, state, [message(presenceA, 12, { tower_id: towerA.id })]).state;
|
||||||
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
assert.ok(!state.towers.some(tower => tower.id === towerA.id));
|
||||||
|
|
||||||
state = context.matchLeave({}, logger, nk, dispatcher, 32, state, [presenceB]).state;
|
state = context.matchLeave({}, logger, nk, dispatcher, 82, state, [presenceB]).state;
|
||||||
assert.strictEqual(state.started, true);
|
assert.strictEqual(state.started, true);
|
||||||
assert.strictEqual(state.pausedForReconnect, true);
|
assert.strictEqual(state.pausedForReconnect, true);
|
||||||
assert.strictEqual(context.snapshot(state, 32).players[1].connected, false);
|
assert.strictEqual(context.snapshot(state, 82).players[1].connected, false);
|
||||||
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 33, state, presenceB, {}).accept, true);
|
assert.strictEqual(context.matchJoinAttempt({}, logger, nk, dispatcher, 83, state, presenceB, {}).accept, true);
|
||||||
state = context.matchJoin({}, logger, nk, dispatcher, 33, state, [presenceB]).state;
|
state = context.matchJoin({}, logger, nk, dispatcher, 83, state, [presenceB]).state;
|
||||||
assert.strictEqual(state.pausedForReconnect, false);
|
assert.strictEqual(state.pausedForReconnect, false);
|
||||||
assert.strictEqual(state.slots["pilot-b"], 1);
|
assert.strictEqual(state.slots["pilot-b"], 1);
|
||||||
|
|
||||||
|
const bossState = context.initialState({ difficulty: 1 });
|
||||||
|
bossState.wave = 5;
|
||||||
|
bossState.waveSchedule = [{ type: 5, boss: true }];
|
||||||
|
const originalRandom = context.Math.random;
|
||||||
|
context.Math.random = () => 0;
|
||||||
|
context.spawnEnemy(bossState);
|
||||||
|
context.Math.random = originalRandom;
|
||||||
|
assert.ok(bossState.enemies[0].shield, "The first boss shield test needs a shielded ship.");
|
||||||
|
assert.ok(Math.abs(bossState.enemies[0].max_hp -
|
||||||
|
context.ENEMIES[5].hp * (1 + 4 * 0.20 + 2 * 0.09) * 3.25 * context.DIFFICULTIES[1].hp * 0.5,
|
||||||
|
) < 0.0001, "Wave-five mothership hull should be reduced by 50%.");
|
||||||
|
assert.ok(Math.abs(bossState.enemies[0].max_shield_hp - bossState.enemies[0].max_hp * 0.55) < 0.0001,
|
||||||
|
"The wave-five shield remains at its already-halved absolute strength while hull is reduced.");
|
||||||
|
const laterBossState = context.initialState({ difficulty: 1 });
|
||||||
|
laterBossState.wave = 10;
|
||||||
|
laterBossState.waveSchedule = [{ type: 8, boss: true }];
|
||||||
|
context.Math.random = () => 0;
|
||||||
|
context.spawnEnemy(laterBossState);
|
||||||
|
context.Math.random = originalRandom;
|
||||||
|
assert.strictEqual(laterBossState.enemies[0].max_shield_hp, laterBossState.enemies[0].max_hp * 0.55,
|
||||||
|
"Later bosses retain their original shield balance.");
|
||||||
|
|
||||||
|
const flakAimState = {
|
||||||
|
towers: [{ id: 1, type: 6, x: 0, y: 0, owner: 0, level: 1, cooldown: 1, angle: 0 }],
|
||||||
|
enemies: [{ id: 1, type: 0, x: 100, y: 0, hp: 100, shield: false }],
|
||||||
|
shots: []
|
||||||
|
};
|
||||||
|
context.updateTowers(flakAimState, 0.1);
|
||||||
|
assert.ok(Math.abs(flakAimState.towers[0].angle - Math.PI) < 0.0001,
|
||||||
|
"Flak visualization must include the 180-degree sprite orientation correction.");
|
||||||
|
|
||||||
|
const diagnosticStore = new Map();
|
||||||
|
const diagnosticNk = {
|
||||||
|
sha256Hash(value) { return crypto.createHash("sha256").update(value).digest("hex"); },
|
||||||
|
storageRead(ops) { return ops.map(op => diagnosticStore.get(`${op.collection}:${op.userId}:${op.key}`)).filter(Boolean); },
|
||||||
|
storageWrite(ops) { for (const op of ops) diagnosticStore.set(`${op.collection}:${op.userId}:${op.key}`, { key: op.key, value: op.value }); },
|
||||||
|
storageList(userId, collection) {
|
||||||
|
return { objects: [...diagnosticStore.entries()].filter(([key]) => key.startsWith(`${collection}:${userId}:`)).map(([, object]) => object), cursor: "" };
|
||||||
|
},
|
||||||
|
storageDelete(ops) { for (const op of ops) diagnosticStore.delete(`${op.collection}:${op.userId}:${op.key}`); }
|
||||||
|
};
|
||||||
|
const diagnosticMatchId = "sample-match.123";
|
||||||
|
let diagState = context.initialState({ difficulty: 1 });
|
||||||
|
diagState = context.matchJoin({ matchId: diagnosticMatchId }, logger, diagnosticNk, dispatcher, 1, diagState,
|
||||||
|
[{ userId: hostId, username: "Host" }, { userId: friendId, username: "Guest" }]).state;
|
||||||
|
const report = { match_id: diagnosticMatchId, version: "0.26", records: [
|
||||||
|
{ event: "window", utc: "2026-10-10T13:00:00", snapshots: 90, gap_p95_ms: 103, player_name: "must_not_store" },
|
||||||
|
{ event: "end", utc: "2026-10-10T13:00:01", reason: "left_match" }
|
||||||
|
] };
|
||||||
|
const firstReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, JSON.stringify(report)));
|
||||||
|
const secondReport = JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: friendId }, logger, diagnosticNk, JSON.stringify(report)));
|
||||||
|
assert.strictEqual(firstReport.round_id, secondReport.round_id, "Both players need one shared round ID.");
|
||||||
|
assert.strictEqual(firstReport.slot, 0);
|
||||||
|
assert.strictEqual(secondReport.slot, 1);
|
||||||
|
assert.strictEqual(JSON.parse(context.rpcSubmitOnlineDiagnostics({ userId: "stranger" }, logger, diagnosticNk, JSON.stringify(report))).error,
|
||||||
|
"not_participant", "A third account cannot attach data to the round.");
|
||||||
|
const listedReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk, "{}")).reports;
|
||||||
|
assert.strictEqual(listedReports.length, 2);
|
||||||
|
assert.ok(!JSON.stringify(listedReports).includes("must_not_store"), "The server must discard arbitrary client strings.");
|
||||||
|
const pairReports = JSON.parse(context.rpcListOnlineDiagnostics({}, logger, diagnosticNk,
|
||||||
|
JSON.stringify({ round_id: firstReport.round_id }))).reports;
|
||||||
|
assert.strictEqual(pairReports.length, 2);
|
||||||
|
assert.strictEqual(pairReports[0].round_id, pairReports[1].round_id);
|
||||||
|
assert.ok(!JSON.stringify(pairReports).includes("must_not_store"), "Detailed reports must also omit unapproved strings.");
|
||||||
|
assert.throws(() => context.rpcListOnlineDiagnostics({ userId: hostId }, logger, diagnosticNk, "{}"), /server-to-server/);
|
||||||
|
|
||||||
console.log("VOID_BASTION_SERVER_SIM_OK");
|
console.log("VOID_BASTION_SERVER_SIM_OK");
|
||||||
@@ -32,7 +32,7 @@ func _run() -> void:
|
|||||||
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
|
assert(not info.is_exception(), "Die Godot-Anbindung muss Server-RPCs erreichen.")
|
||||||
var info_data = JSON.parse_string(info.payload)
|
var info_data = JSON.parse_string(info.payload)
|
||||||
assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
|
assert(info_data is Dictionary and info_data.get("status") == "online", "Der Server muss seinen Onlinestatus melden.")
|
||||||
assert(info_data.get("version") == "0.23", "Der Server muss die aktuelle Runtime 0.23 geladen haben: %s" % info_data)
|
assert(info_data.get("version") == "0.27", "Der Server muss die aktuelle Runtime 0.27 geladen haben: %s" % info_data)
|
||||||
|
|
||||||
var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
|
var deletion_client: NakamaClient = nakama.create_client("voidbastion-public-v1", test_host, test_port, test_scheme, 5, NakamaLogger.LOG_LEVEL.ERROR)
|
||||||
var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
|
var deletion_session: NakamaSession = await deletion_client.authenticate_device_async("voidbastion_delete_test_" + suffix, "delete_test_" + suffix, true)
|
||||||
@@ -51,13 +51,13 @@ func _run() -> void:
|
|||||||
var public_leaderboard_name := "Rangpilot %s" % suffix.right(6)
|
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}))
|
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.")
|
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)
|
var submitted = await client_a.rpc_async(session_a, "submit_run", run_payload)
|
||||||
assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.")
|
assert(not submitted.is_exception(), "Ein plausibles Ergebnis muss serverseitig angenommen werden.")
|
||||||
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", null, null, 10)
|
var records = await client_a.list_leaderboard_records_async(session_a, "mission_map1_difficulty1", [session_a.user_id], null, 10)
|
||||||
assert(not records.is_exception() and records.records.size() >= 1, "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
|
assert(not records.is_exception() and (records.records.size() >= 1 or records.owner_records.size() >= 1), "Das Ergebnis muss über das Godot-SDK aus der Rangliste lesbar sein.")
|
||||||
var own_record = null
|
var own_record = null
|
||||||
for record in records.records:
|
for record in records.owner_records:
|
||||||
if record.owner_id == session_a.user_id:
|
if record.owner_id == session_a.user_id:
|
||||||
own_record = record
|
own_record = record
|
||||||
break
|
break
|
||||||
@@ -67,6 +67,17 @@ func _run() -> void:
|
|||||||
var socket_b: NakamaSocket = nakama.create_socket_from(client_b)
|
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_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.")
|
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_a.received_matchmaker_matched.connect(func(value): matched_a = value)
|
||||||
socket_b.received_matchmaker_matched.connect(func(value): matched_b = value)
|
socket_b.received_matchmaker_matched.connect(func(value): matched_b = value)
|
||||||
socket_a.received_match_state.connect(func(value): _capture_snapshot(value, true))
|
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.")
|
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)
|
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.")
|
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()}))
|
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.")
|
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)
|
var found_data = JSON.parse_string(found_room.payload)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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 = "lobby"
|
||||||
|
assert(game._can_invite_friends(), "Im privaten Warteraum muss EINLADEN sichtbar bleiben.")
|
||||||
|
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)
|
||||||
|
var diagnostic_bundle: String = online_manager.get_diagnostic_bundle()
|
||||||
|
assert(diagnostic_bundle.contains("\"event\":\"window\""), "Die Online-Diagnose muss einen kopierbaren Messbericht erzeugen.")
|
||||||
|
assert(diagnostic_bundle.contains("\"queue_dropped\":3"), "Verworfene Zustände im Rückstau müssen erfasst werden.")
|
||||||
|
online_manager._finish_match_diagnostics("test_finished")
|
||||||
|
assert(online_manager.diagnostic_match_key.is_empty())
|
||||||
|
assert(online_manager.diagnostic_finished_match_key == "test-match".sha256_text().substr(0, 12))
|
||||||
|
online_manager._on_match_state({"match_id": "test-match", "op_code": online_manager.OP_SNAPSHOT,
|
||||||
|
"data": JSON.stringify({"tick": 8, "shots": []})})
|
||||||
|
assert(online_manager.diagnostic_file == null, "Nach dem Rundenende darf kein zweiter Diagnosebericht geöffnet werden.")
|
||||||
|
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.")
|
||||||
|
assert(partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x > game.COOP_MIRROR_X),
|
||||||
|
"Für Spieler 1 muss nur die rechte Seite als Partnerseite markiert sein.")
|
||||||
|
game.coop_online_mode = true
|
||||||
|
game.local_player_slot = 1
|
||||||
|
game.draw_geometry_dirty = true
|
||||||
|
game._prepare_draw_geometry()
|
||||||
|
partner_cells = game.build_cell_cache.filter(func(cell: Dictionary) -> bool: return cell.partner)
|
||||||
|
assert(not partner_cells.is_empty() and partner_cells.all(func(cell: Dictionary) -> bool: return cell.rect.get_center().x < game.COOP_MIRROR_X),
|
||||||
|
"Für Spieler 2 muss nur die linke Seite als Partnerseite markiert sein.")
|
||||||
|
game.camera_offset.x = game.COOP_WORLD_WIDTH - game.SCREEN.x
|
||||||
|
game._toggle_coop_side()
|
||||||
|
assert(game.status_text == "PARTNER-SEITE", "Der Seitenwechsel muss Spieler 2 links als Partnerseite benennen.")
|
||||||
|
game._toggle_coop_side()
|
||||||
|
assert(game.status_text == "DEINE SEITE", "Der Seitenwechsel muss Spieler 2 rechts als eigene Seite benennen.")
|
||||||
|
print("VOID_BASTION_ONLINE_SNAPSHOT_OK")
|
||||||
|
get_tree().quit()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cg5nghnbri5bo
|
||||||
@@ -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")
|
||||||
+98
-18
@@ -29,12 +29,18 @@ func _run() -> void:
|
|||||||
var ads_manager = game.get_node("AdsManager")
|
var ads_manager = game.get_node("AdsManager")
|
||||||
var account_manager = game.get_node("AccountManager")
|
var account_manager = game.get_node("AccountManager")
|
||||||
var multiplayer_manager = game.get_node("MultiplayerManager")
|
var multiplayer_manager = game.get_node("MultiplayerManager")
|
||||||
|
game.set("core_crystals", 0)
|
||||||
|
game.call("_adjust_core_crystals", 25)
|
||||||
|
assert(game.get("core_crystals") == 25, "Der Entwicklermodus muss Testkristalle hinzufügen können.")
|
||||||
|
game.call("_adjust_core_crystals", -25)
|
||||||
|
game.call("_adjust_core_crystals", -25)
|
||||||
|
assert(game.get("core_crystals") == 0, "Der Entwicklermodus muss Kristalle abziehen können, ohne unter null zu fallen.")
|
||||||
assert(game.get("MAP_NAMES").size() == 7, "Es müssen sieben Karten vorhanden sein.")
|
assert(game.get("MAP_NAMES").size() == 7, "Es müssen sieben Karten vorhanden sein.")
|
||||||
assert(game.get("MAP_TEXTURES").size() == 7, "Jede Karte benötigt einen eigenen Hintergrund.")
|
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.")
|
assert(game.get("map_buttons").size() == 7, "Alle sieben Kartenvorschauen müssen gleichzeitig auswählbar sein.")
|
||||||
game.set("unlocked_maps", [0, 1, 2])
|
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_DATA").size() == 7, "Das Arsenal muss sieben 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_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)
|
game.call("_handle_pointer", Vector2(760, 815), false)
|
||||||
assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.")
|
assert(game.get("game_mode") == "store", "Der interne Shop muss direkt aus dem Hauptmenü erreichbar sein.")
|
||||||
await process_frame
|
await process_frame
|
||||||
@@ -69,7 +75,8 @@ func _run() -> void:
|
|||||||
assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.")
|
assert(game.get("menu_step") == "map", "Die Missionsvorbereitung benötigt eine Rückkehr zur Kartenauswahl.")
|
||||||
game.set("unlocked_towers", [0, 1, 2])
|
game.set("unlocked_towers", [0, 1, 2])
|
||||||
game.set("game_mode", "playing")
|
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.")
|
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)
|
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.")
|
assert(game.get("game_mode") == "playing", "Der Shop-Zurück-Button muss in die laufende Mission zurückführen.")
|
||||||
@@ -135,10 +142,11 @@ func _run() -> void:
|
|||||||
assert(game.get("game_mode") == "menu", "Die Bestenliste benötigt einen funktionierenden Zurück-Button.")
|
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.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(not account_manager.display_name.is_empty(), "Das Gastprofil benötigt einen sichtbaren Pilotennamen.")
|
||||||
assert(game.call("_app_version") == "0.1.0-alpha.4", "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")
|
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.begins_with("mailto:support@gergames.de?"), "Die Feedback-Schaltfläche muss die öffentliche Supportadresse verwenden.")
|
||||||
assert(support_url.contains("0.1.0-alpha.4") 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
|
account_manager.storage_enabled = false
|
||||||
var original_display_name: String = account_manager.display_name
|
var original_display_name: String = account_manager.display_name
|
||||||
assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")
|
assert(account_manager.validate_display_name("Kommandant Ägir").is_empty(), "Ein normaler internationaler Spielername muss gültig sein.")
|
||||||
@@ -152,14 +160,14 @@ func _run() -> void:
|
|||||||
assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.")
|
assert(account_manager.get_map_best_wave(0, false) >= 3, "Die Profilansicht muss den gespeicherten Missionsbestwert lesen können.")
|
||||||
assert(not ads_manager.mobile_ads_supported, "Auf Windows müssen Anzeigen vollständig deaktiviert bleiben.")
|
assert(not ads_manager.mobile_ads_supported, "Auf Windows müssen Anzeigen vollständig deaktiviert bleiben.")
|
||||||
var update_manager = game.get_node("UpdateManager")
|
var update_manager = game.get_node("UpdateManager")
|
||||||
assert(update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.3"), "Eine neuere Alpha-Version muss als Update erkannt werden.")
|
assert(update_manager.call("_is_newer_version", "0.1.0-alpha.6", "0.1.0-alpha.5"), "Eine neuere Alpha-Version muss als Update erkannt werden.")
|
||||||
assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.")
|
assert(update_manager.call("_is_newer_version", "0.1.0", "0.1.0-alpha.2"), "Eine stabile Version muss neuer als ihre Alpha-Version sein.")
|
||||||
assert(not update_manager.call("_is_newer_version", "0.1.0-alpha.4", "0.1.0-alpha.4"), "Die installierte Version darf nicht als Update angeboten werden.")
|
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 := {}
|
var announced_update := {}
|
||||||
update_manager.update_available.connect(func(info: Dictionary): announced_update.merge(info, true))
|
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.5", "name": "Alpha.5", "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)
|
update_manager.call("_on_request_completed", HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), fake_release_body)
|
||||||
assert(announced_update.get("version") == "0.1.0-alpha.5", "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(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(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")
|
_assert_grid_aligned_path(game.get("path_points"), "Bastion Prime")
|
||||||
@@ -222,16 +230,17 @@ func _run() -> void:
|
|||||||
game.set("selected_difficulty", 0)
|
game.set("selected_difficulty", 0)
|
||||||
game.call("_start_game")
|
game.call("_start_game")
|
||||||
assert(game.get("credits") == 450 and game.get("reactor_lives") == 20, "Leicht muss dem bisherigen Balancing entsprechen.")
|
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.")
|
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.")
|
assert(is_equal_approx(game.get("game_speed"), 5.0), "Die Geschwindigkeitsauswahl muss bis 5× reichen.")
|
||||||
game.set("game_speed", 1.0)
|
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.")
|
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.")
|
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("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.")
|
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")
|
game.call("_start_game")
|
||||||
@@ -264,6 +273,62 @@ func _run() -> void:
|
|||||||
assert(game.get("enemies_to_spawn") > 9, "Mittel muss mehr Gegner als Leicht erzeugen.")
|
assert(game.get("enemies_to_spawn") > 9, "Mittel muss mehr Gegner als Leicht erzeugen.")
|
||||||
game.set("selected_difficulty", 2)
|
game.set("selected_difficulty", 2)
|
||||||
game.call("_start_game")
|
game.call("_start_game")
|
||||||
|
assert(game.get("credits") == 340 and game.get("reactor_lives") == 12, "Schwer muss mit knapperen Startressourcen und weniger Reaktorleben beginnen.")
|
||||||
|
assert(int(game.call("_difficulty").crystals) == 12, "Ein Sieg auf Schwer muss den höheren Kristallbonus gewähren.")
|
||||||
|
assert(game.get("ENEMY_DATA").size() == 9, "Drei zusätzliche Gegnerrollen müssen im Spiel verfügbar sein.")
|
||||||
|
assert(game.call("_enemy_category", 0) == "air" and game.call("_enemy_category", 6) == "ground", "Luft- und Bodeneinheiten müssen eindeutig klassifiziert sein.")
|
||||||
|
assert(not game.call("_tower_can_target", 0, 0) and game.call("_tower_can_target", 1, 0) and game.call("_tower_can_target", 1, 6) and game.call("_tower_can_target", 5, 6), "Kinetik muss auf Boden beschränkt sein; Laser und Railgun dürfen beide Kategorien angreifen.")
|
||||||
|
assert(game.call("_enemy_texture", 6).get_width() > 0 and game.call("_enemy_texture", 8).get_height() > 0, "Bodeneinheiten benötigen eigene lesbare Grafiken.")
|
||||||
|
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") == [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}
|
||||||
|
var rail_behind := {"id": 96002, "type": 6, "pos": Vector2(500, 300), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
|
||||||
|
var rail_offline := {"id": 96003, "type": 0, "pos": Vector2(450, 326), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
|
||||||
|
game.get("enemies").append_array([rail_front, rail_behind, rail_offline])
|
||||||
|
game.call("_fire_tower", {"type": 5, "level": 1, "pos": Vector2(300, 300), "owner": 0}, rail_front)
|
||||||
|
assert(rail_front.hp < 1000.0 and rail_behind.hp < 1000.0 and is_equal_approx(rail_offline.hp, 1000.0), "Der Railgun-Strahl muss Gegner hinter dem Ziel auf der Linie durchschlagen, aber keine Ziele neben der Linie treffen.")
|
||||||
|
assert(game.get("beams")[-1].piercing and game.get("beams")[-1].color == Color("c86bff"), "Der durchschlagende Railgun-Strahl muss sichtbar lila sein.")
|
||||||
|
game.get("enemies").clear()
|
||||||
|
game.get("projectiles").clear()
|
||||||
|
var emp_air := {"id": 96101, "type": 0, "pos": Vector2(500, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
|
||||||
|
var emp_ground := {"id": 96102, "type": 6, "pos": Vector2(550, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
|
||||||
|
var emp_outside := {"id": 96103, "type": 6, "pos": Vector2(700, 500), "hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "shield": false, "reward": 1}
|
||||||
|
game.get("enemies").append_array([emp_air, emp_ground, emp_outside])
|
||||||
|
game.call("_fire_tower", {"type": 4, "level": 1, "pos": Vector2(400, 500), "owner": 0}, emp_air)
|
||||||
|
game.get("projectiles")[-1].speed = 0.0
|
||||||
|
game.get("projectiles")[-1].pos = emp_air.pos
|
||||||
|
game.call("_update_projectiles", 0.01)
|
||||||
|
assert(is_equal_approx(emp_air.hp, 980.0) and is_equal_approx(emp_ground.hp, 980.0) and is_equal_approx(emp_outside.hp, 1000.0), "Der EMP muss Luft und Boden im 118er-Radius jeweils um 20 treffen und Gegner außerhalb verschonen.")
|
||||||
|
game.get("enemies").clear()
|
||||||
|
game.set("path_points", PackedVector2Array([Vector2(0, 0), Vector2(1000, 0), Vector2(1600, 0)]))
|
||||||
|
game.get("enemies").clear()
|
||||||
|
var shield_target := {"id": 201, "type": 6, "pos": Vector2(850, 0), "path_index": 1, "hp": 100.0, "shield": true}
|
||||||
|
var unshielded_target := {"id": 202, "type": 6, "pos": Vector2(950, 0), "path_index": 1, "hp": 500.0, "shield": false}
|
||||||
|
game.get("enemies").append(shield_target)
|
||||||
|
game.get("enemies").append(unshielded_target)
|
||||||
|
assert(game.call("_find_target", {"type": 1, "level": 1, "pos": Vector2(800, 0)}).id == 201, "Lasertürme müssen unabhängig vom Fortschritt immer zuerst ein Schildziel in Reichweite wählen.")
|
||||||
|
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "strongest"}).id == 202, "Die Priorität MEISTE HP muss das Ziel mit den meisten Lebenspunkten wählen.")
|
||||||
|
assert(game.call("_find_target", {"type": 0, "level": 1, "pos": Vector2(800, 0), "target_priority": "last"}).id == 201, "Die Priorität LETZTE muss die Einheit mit dem geringsten Wegfortschritt wählen.")
|
||||||
|
game.get("towers").clear()
|
||||||
|
game.get("towers").append({"type": 0, "target_priority": "first"})
|
||||||
|
game.set("selected_tower_index", 0)
|
||||||
|
game.call("_cycle_selected_tower_priority")
|
||||||
|
assert(game.get("towers")[0].target_priority == "last", "Die Zielpriorität eines ausgewählten Turms muss umstellbar sein.")
|
||||||
|
game.set("selected_tower_index", -1)
|
||||||
|
game.get("towers").clear()
|
||||||
|
game.get("enemies").clear()
|
||||||
|
game.call("_select_map", 0)
|
||||||
|
assert(game.call("_enemy_type_for_wave", 3, 0.9, false) == 3, "Auf Schwer muss der Abfangjäger ab Welle 3 auftreten.")
|
||||||
|
assert(game.call("_enemy_type_for_wave", 5, 0.85, false) == 4, "Auf Schwer muss der Schildkreuzer ab Welle 5 auftreten.")
|
||||||
|
assert(game.call("_enemy_type_for_wave", 8, 0.95, false) == 5, "Auf Schwer muss der Belagerer ab Welle 8 auftreten.")
|
||||||
|
game.set("selected_difficulty", 1)
|
||||||
|
var medium_late_health: float = game.call("_enemy_health_scale", 12, false)
|
||||||
|
game.set("selected_difficulty", 2)
|
||||||
|
assert(game.call("_enemy_health_scale", 12, false) > medium_late_health, "Schwer muss ab der Missionsmitte stärker skalieren als Mittel.")
|
||||||
game.set("wave", 1)
|
game.set("wave", 1)
|
||||||
game.set("enemies_to_spawn", 2)
|
game.set("enemies_to_spawn", 2)
|
||||||
game.call("_spawn_enemy")
|
game.call("_spawn_enemy")
|
||||||
@@ -272,13 +337,25 @@ func _run() -> void:
|
|||||||
game.set("wave", 3)
|
game.set("wave", 3)
|
||||||
game.call("_start_wave")
|
game.call("_start_wave")
|
||||||
assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.")
|
assert(game.get("enemies_to_spawn") > 15, "Ab Welle 4 muss die Gegnerzahl merklich steigen.")
|
||||||
|
var preview_types: Array[int] = game.call("_wave_preview_types", 3)
|
||||||
|
assert(preview_types.has(6) and preview_types.has(0), "Die Vorschau muss Luft- und Bodentypen der gleichen Welle zeigen.")
|
||||||
|
var preview_summary: Dictionary = game.call("_wave_preview_summary", 3)
|
||||||
|
assert(preview_summary.total == preview_types.size() and preview_summary.air + preview_summary.ground == preview_summary.total, "Die Vorschau muss die Gesamtzahl der Schiffe sowie die korrekten Luft-/Bodenanteile zählen.")
|
||||||
|
assert(game.get("wave_type_schedule").size() == game.get("enemies_to_spawn"), "Die Vorschau und der tatsächliche Spawnplan müssen übereinstimmen.")
|
||||||
|
var test_enemy := {"hp": 100.0, "max_hp": 100.0, "base_max_hp": 100.0, "type": 0, "flash": 0.0, "pos": Vector2.ZERO}
|
||||||
|
game.get("enemies").append(test_enemy)
|
||||||
|
game.call("_set_enemy_strength_multiplier", 1.6)
|
||||||
|
assert(is_equal_approx(test_enemy.max_hp, 160.0) and is_equal_approx(test_enemy.hp, 160.0), "Der Testmultiplikator muss die Hülle laufender Gegner anpassen.")
|
||||||
|
game.call("_set_enemy_strength_multiplier", 1.0)
|
||||||
game.get("enemies").clear()
|
game.get("enemies").clear()
|
||||||
var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true}
|
var shielded_enemy := {"hp": 1000.0, "max_hp": 1000.0, "flash": 0.0, "id": 98001, "type": 0, "pos": Vector2.ZERO, "speed": 1.0, "reward": 1, "boss": false, "size_scale": 1.0, "shield": true, "shield_hp": 50.0, "max_shield_hp": 50.0, "shield_resistance": 0.28}
|
||||||
game.get("enemies").append(shielded_enemy)
|
game.get("enemies").append(shielded_enemy)
|
||||||
game.call("_apply_damage", shielded_enemy, 100.0, 0, 0)
|
game.call("_apply_damage", shielded_enemy, 100.0, 0, 0)
|
||||||
assert(is_equal_approx(shielded_enemy.hp, 972.0), "Schilde müssen kinetischen Schaden deutlich reduzieren, aber nicht vollständig blockieren.")
|
assert(is_equal_approx(shielded_enemy.hp, 972.0), "Schilde müssen kinetischen Schaden deutlich reduzieren, aber nicht vollständig blockieren.")
|
||||||
game.call("_apply_damage", shielded_enemy, 100.0, 0, 1)
|
game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
|
||||||
assert(is_equal_approx(shielded_enemy.hp, 872.0), "Laser müssen Schildgegner effektiv beschädigen.")
|
assert(is_equal_approx(shielded_enemy.hp, 972.0) and is_equal_approx(shielded_enemy.shield_hp, 20.0), "Laser müssen das Schildleben statt der Hülle abbauen.")
|
||||||
|
game.call("_apply_damage", shielded_enemy, 30.0, 0, 1)
|
||||||
|
assert(not shielded_enemy.shield and is_equal_approx(shielded_enemy.hp, 962.0), "Der Laser muss beim Schildbruch überschüssigen Schaden auf die Hülle übertragen.")
|
||||||
game.get("enemies").clear()
|
game.get("enemies").clear()
|
||||||
game.set("wave", 5)
|
game.set("wave", 5)
|
||||||
game.set("reactor_lives", 12)
|
game.set("reactor_lives", 12)
|
||||||
@@ -295,10 +372,13 @@ func _run() -> void:
|
|||||||
game.set("wave", 4)
|
game.set("wave", 4)
|
||||||
game.call("_start_wave")
|
game.call("_start_wave")
|
||||||
game.set("enemies_to_spawn", 1)
|
game.set("enemies_to_spawn", 1)
|
||||||
|
game.set("wave_spawn_index", 0)
|
||||||
|
game.set("wave_type_schedule", game.call("_build_wave_schedule", 5, 1))
|
||||||
game.call("_spawn_enemy")
|
game.call("_spawn_enemy")
|
||||||
var boss: Dictionary = game.get("enemies")[-1]
|
var boss: Dictionary = game.get("enemies")[-1]
|
||||||
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
|
assert(boss.boss, "Jede fünfte Welle sollte ein Kommandoschiff erzeugen.")
|
||||||
assert(boss.type == 2, "Das Kommandoschiff sollte die schwere Schiffsklasse verwenden.")
|
assert(boss.type == 5, "Das Kommandoschiff sollte die schwere Belagerer-Klasse verwenden.")
|
||||||
|
assert(is_equal_approx(float(boss.max_hp), float(boss.base_max_hp)), "Die Hülle des ersten Bosses muss im neuen Testbalancing halbiert sein.")
|
||||||
game.call("_start_game")
|
game.call("_start_game")
|
||||||
var crystals_before: int = game.get("core_crystals")
|
var crystals_before: int = game.get("core_crystals")
|
||||||
game.set("wave", 15)
|
game.set("wave", 15)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kwjokxtwfr0u
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user