Add repo hygiene rules and ignore secrets

This commit is contained in:
2026-02-03 06:55:39 +01:00
parent 88732a8ae7
commit 6035bc1715
52 changed files with 3295 additions and 25 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
APP_ENV=change-me
DEV_MODE=change-me
DEV_USER_ID=change-me
DB_HOST=change-me
DB_PORT=change-me
DB_NAME=change-me
DB_TEST_NAME=change-me
DB_USER=change-me
DB_PASS=change-me

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Env / Secrets
.env
.env.*
!.env.example
# Dependencies
/vendor
/node_modules
/server/vendor
# PHPUnit / cache / runtime
/server/.phpunit.cache
/server/storage
/server/logs
# Build artifacts / caches
/web/**/public/cache
/web/**/public/build
/web/**/public/dist
# Docker / DB data
**/data
**/db-data
**/postgres-data
*.sqlite
*.db
*.dump
*.sql
!/server/db/migrations/*.sql
# IDE / OS
.DS_Store
Thumbs.db
.idea
.vscode
*.swp

View File

@@ -1,23 +1,17 @@
# AGENTS (Repository Guidance)
## Repository Intent
- The `web/desktop` and `web/mobile` folders host the live HUD demo (Desktop and mobile). Keep everything that should be served from a webserver inside those folders.
- `docs/game_rulebook.md` is the **Single Source of Truth (SSOT)** for Galaxy Forges game logic, builder system, resources, and requirements. Any UI work, game behavior, or rules must align with — not contradict — that document unless a new decision is logged.
- `docs/` stores documentation while `planning/` holds sketches/notes that are not meant for deployment.
## Projektregeln für Agents/Codex
1. **SSOT:** `docs/game_rulebook*.md` ist maßgeblich. Änderungen müssen damit übereinstimmen.
2. **Modularität:** Jedes Feature liegt in einem Modul (keine God-Classes, keine Mischzuständigkeiten).
3. **Permissions:** Jede Admin-/Write-Aktion braucht eine Permission; **deny overrides** müssen funktionieren.
4. **Keine Secrets:** Keine `.env`, keine echten Passwörter, keine API-Keys ins Repo.
5. **Default-Credentials:** Nur Platzhalter wie `change-me`/`your-strong-password` verwenden und dokumentieren, dass vor Prod geändert werden muss.
6. **Tests:** Tests müssen grün sein, bevor gemerged wird.
## Key Instructions for Agents
1. **Reference SSOT first.** When implementing features or answering questions related to game balance, resources, blueprints, buildings, or world generation, open `docs/game_rulebook.md` and confirm compliance. Note any missing detail needs a Decision Log entry (see section 13).
2. **Respect folder intent.** Changes to web assets go in `web/desktop` or `web/mobile` only. Use `planning/` for drafts and `docs/` for textual explanations.
3. **Document assumptions.** If you must deviate from `docs/game_rulebook.md`, append an entry to the Decision Log (section 13) explaining the why/when.
4. **Keep mobile/desktop aligned.** When adjusting UI/partials, mirror updates in both `web/desktop` and `web/mobile` unless the change is explicitly mobile-only or desktop-only and documented.
5. **Speak German.** All explanations, comments, and user-facing text from agents must be in German unless the user explicitly requests otherwise in another language.
6. **Smoke tests mandatory.** After every change touching runtime code or assets, perform a smoke test (e.g., `php -S localhost:8000 -t web/desktop/public`) and note the outcome before wrapping up.
## Ordner-Intent (Kurzfassung)
- `web/desktop` und `web/mobile`: Alles, was aus dem Webserver ausgeliefert wird.
- `docs/`: Dokumentation (SSOT liegt hier).
- `planning/`: Skizzen/Notizen, nicht für Deployment.
## When touching docs
- Expand `docs/game_rulebook.md` if you update core rules, flow, or systems described there. Keep numbering/sections intact; append entries in the Decision Log (section 13) with dates.
- The root `README.md` should mention where to find the SSOT and purpose of each folder.
## Decision Log Reminder
- Section 13 of `docs/game_rulebook.md` is the Decision Log. Add a dated entry whenever:
- You introduce a new rule variant or exception.
- The implementation deviates from the documented defaults.
## Sprache
- Alle Agenten-Ausgaben und Kommentare sind **Deutsch**, außer der User verlangt explizit eine andere Sprache.

View File

@@ -7,12 +7,38 @@ Alles, was später auf den Webserver gehört, lebt unter `web/`. Die anderen Ord
- `web/desktop/`: Desktop-geeigneter Build mit eigenem `public/` (Entry-Point) und `src/partials/`.
- `web/mobile/`: Mobile-Version (aktuell ein Spiegel des Desktop-Builds; anpassbar für responsive Varianten).
- `docs/`: Projektdokumentation; siehe `docs/README.md` für Details zur Anwendung.
- `docs/game_rulebook_v1_2.md`: **SSOT** für Regeln, Generator, Blueprints und Permissions.
- `planning/`: Freifläche für Skizzen, Notizen oder Quelltext, der nicht ins Webroot gehört.
- `server/`: Slim-Backend (PSR-4/7/15/11), API-Endpunkte und Tests.
- `config/`: JSON-Konfigurationen (Planetklassen, Rassen, Gebäude-Blueprints).
## Entwicklung
1. `cd /path/to/Space-Theme`
2. `php -S localhost:8000 -t web/desktop/public`
3. Öffne `http://localhost:8000/index.php?s=overview&p=dashboard` (für mobile Tests `-t web/mobile/public`).
1. `cp .env.example .env`
2. `docker compose up -d`
3. `psql -h 127.0.0.1 -U <DB_USER> -c \"CREATE DATABASE <DB_TEST_NAME>\"` (nur einmalig)
4. `cd server && composer install`
5. `php db/migrate.php`
6. `php db/seed.php`
7. `vendor/bin/phpunit`
8. `php -S localhost:8000 -t web/desktop/public`
9. Öffne `http://localhost:8000/index.php?s=overview&p=dashboard` (für mobile Tests `-t web/mobile/public`).
## Konfiguration (.env)
Die Datei `.env.example` enthält **Platzhalter** (`change-me`). Diese Werte **müssen** vor einem Produktivbetrieb ersetzt werden.
Benötigte Variablen:
- `APP_ENV`
- `DEV_MODE`
- `DEV_USER_ID`
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_TEST_NAME`
- `DB_USER`
- `DB_PASS`
## Repo-Hygiene
Vor dem Commit prüfen: **keine Secrets im Diff**.
## Weitere Infos
- Die ausführliche Demo-Beschreibung und Abläufe stehen in `docs/README.md`.

View File

@@ -0,0 +1,83 @@
{
"blueprints": [
{
"kind": "building",
"key": "build_center",
"name": "Bauzentrum",
"model": "stackable",
"tags": ["infrastructure"],
"capabilities": ["queue:build"],
"effects": [
{"type": "queue_slots_add", "amount": 1}
],
"requirements": [],
"access": {
"allowed_races": [],
"blocked_races": []
},
"cost": {"metal": 200, "crystals": 100, "credits": 50},
"build_time": 60,
"properties": {"destroyable": true}
},
{
"kind": "building",
"key": "ore_mine",
"name": "Erzmine",
"model": "stackable",
"tags": ["industry"],
"capabilities": [],
"effects": [
{"type": "produce", "resource": "metal", "amount": 30},
{"type": "consume", "resource": "energy", "amount": 5}
],
"requirements": [],
"access": {
"allowed_races": [],
"blocked_races": []
},
"cost": {"metal": 120, "crystals": 60},
"build_time": 90,
"properties": {"destroyable": true}
},
{
"kind": "building",
"key": "battery",
"name": "Batterie",
"model": "stackable",
"tags": ["energy"],
"capabilities": [],
"effects": [
{"type": "capacity_add", "resource": "energy", "amount": 200}
],
"requirements": [],
"access": {
"allowed_races": [],
"blocked_races": []
},
"cost": {"metal": 80, "crystals": 40},
"build_time": 60,
"properties": {"destroyable": true}
},
{
"kind": "building",
"key": "storage",
"name": "Lager",
"model": "stackable",
"tags": ["storage"],
"capabilities": [],
"effects": [
{"type": "capacity_add", "resource": "metal", "amount": 500},
{"type": "capacity_add", "resource": "crystals", "amount": 300},
{"type": "capacity_add", "resource": "deuterium", "amount": 200}
],
"requirements": [],
"access": {
"allowed_races": [],
"blocked_races": []
},
"cost": {"metal": 150, "crystals": 80},
"build_time": 90,
"properties": {"destroyable": true}
}
]
}

View File

@@ -0,0 +1,62 @@
{
"resources": [
"metal",
"alloy",
"crystals",
"energy",
"credits",
"population",
"water",
"deuterium",
"food"
],
"weights": {
"metal": 1.0,
"alloy": 2.5,
"crystals": 1.2,
"energy": 0.8,
"credits": 0.7,
"population": 0.5,
"water": 1.1,
"deuterium": 1.3,
"food": 0.9
},
"global_bounds": {
"min": -1.0,
"max": 1.0
},
"tiers": {
"normal": {
"target_score": 0.0,
"epsilon": 0.05
},
"rich": {
"target_score": 0.4,
"epsilon": 0.05
},
"legendary": {
"target_score": 1.0,
"epsilon": 0.05
}
},
"classes": {
"temperate": {
"spawn_weight": 1,
"constraints": {},
"bias": {},
"temperature_range_c": [5, 30]
},
"ice": {
"spawn_weight": 1,
"constraints": {
"water": { "min": 0.5 },
"energy": { "max": -0.6 }
},
"bias": {
"water": 0.1,
"energy": -0.1
},
"temperature_range_c": [-80, -10]
}
}
}

22
config/races.json Normal file
View File

@@ -0,0 +1,22 @@
{
"races": {
"human": {
"name": "Mensch",
"modifiers": {
"produce": {
"credits": 0.02
},
"consume": {}
}
},
"robot": {
"name": "Roboter",
"modifiers": {
"produce": {
"metal": 0.05
},
"consume": {}
}
}
}
}

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
db:
image: postgres:16
container_name: galaxyforge-db
ports:
- "5432:5432"
environment:
POSTGRES_DB: galaxyforge
POSTGRES_USER: galaxyforge
POSTGRES_PASSWORD: galaxyforge
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

View File

@@ -1,4 +1,5 @@
# Galaxy Forge — game_rulebook.md (SSOT) v1.0
# Galaxy Forge — game_rulebook.md (ARCHIV)
**Hinweis:** Maßgeblich als SSOT ist ab jetzt `docs/game_rulebook_v1_2.md`. Dieses Dokument bleibt nur als Archiv erhalten.
*Single Source of Truth für Game-Logik, Baukasten-Systeme und Generator-Regeln*
---
@@ -187,6 +188,11 @@ Nach Anwenden der Klassen-Constraints werden die restlichen Modifier so ergänzt
5) Rest-Modifier verteilen (unter Einhaltung globaler Bounds + Bias), bis Zielscore erreicht
6) Falls unmöglich: re-roll oder Tier wechseln (SSOT-Default: re-roll bis max N; dann Tier switch)
### 6.5 Temperatur
1) Planet hat Feld temperature (z.B. integer °C)
2) Planetklasse kann temperature_range haben (min/max)
3) Generator würfelt Temperatur deterministisch (Seed) innerhalb Range
---
## 7) Baukasten-System: Capabilities, Effects, Requirements, Access

412
docs/game_rulebook_v1_2.md Normal file
View File

@@ -0,0 +1,412 @@
# Galaxy Forge — game_rulebook_v1_2.md (SSOT) v1.2
*Single Source of Truth für Game-Logik, Baukasten-Systeme und Generator-Regeln*
---
## 0) Zweck & Arbeitsweise (SSOT)
Dieses Dokument ist die **Single Source of Truth (SSOT)**.
**Codex/Entwicklung darf nichts implementieren, was dem widerspricht.**
**Wenn etwas fehlt oder unklar ist:**
1) **Default-Regeln** in diesem Dokument anwenden,
2) Entscheidung im **Decision Log** (am Ende) festhalten,
3) danach erst implementieren.
---
## 1) High-Level Vision
Galaxy Forge ist ein persistentes Weltraum-Browsergame, dessen Inhalte **datengetrieben** sind:
- Spieler (und später ggf. User-Designer) erstellen **Blueprints** (Gebäude, Schiffe, Forschung, Rassen, Spezialisierungen) über einen **Baukasten**.
- Blueprints bestehen aus **Capabilities (Flags)**, **Effects (standardisierte Bausteine)**, **Requirements**, **Access Rules**.
- Balance soll möglichst **automatisch** über ein **Punkte-/Budget-System** + **Auto-Cost** funktionieren, um Admin-Aufwand zu minimieren.
---
## 2) Kernbegriffe
### 2.1 Blueprint (universell)
Ein Blueprint ist ein datengetriebenes Objekt mit einheitlichem Schema:
- `kind`: `building | ship | research | race | specialization | space_object` (erweiterbar)
- `key`: eindeutiger String
- `name`: Anzeigename
- `tags`: freie Taxonomie-Strings
- `capabilities`: freie Taxonomie-Strings (Flags)
- `effects`: Liste standardisierter Effekt-Bausteine (kleine, feste Bibliothek)
- `requirements`: Liste standardisierter Requirements (kleine, feste Bibliothek)
- `access`: Whitelist/Blacklist nach Race/Specialization (und später ggf. weitere Dimensionen)
- `balance`: Budget/Punkte/Auto-Cost (für usergenerierte Inhalte)
**Wichtig:** Neue Tags/Capabilities/Blueprints müssen ohne Codeänderung möglich sein.
Neue **Effect-Typen** oder **Requirement-Typen** erfordern ggf. Code (Engine-Erweiterung) und sind bewusst klein zu halten.
---
## 3) Ressourcenmodell
### 3.1 Ressourcenarten (SSOT)
Galaxy Forge verwendet diese Ressourcen (erweiterbar, aber stabil halten):
- `metal` (Metall)
- `alloy` (Legierung)
- `crystals` (Kristalle)
- `energy` (Energie)
- `credits` (Credits)
- `population` (Einwohner)
- `water` (Wasser)
- `deuterium` (Deuterium)
- `food` (Nahrungsgüter)
**Einheiten & Lokalisierung:**
- Interne Berechnung und Speicherung erfolgt **in SI-nahem Format** (z.B. Temperatur immer in **°C** als `temperature_c`).
- UI darf je Sprache/Setting anzeigen:
- Fahrenheit: `F = C * 9/5 + 32`
- Celsius: unverändert
- Umrechnung ist reine Anzeige und hat keinen Einfluss auf Serverlogik.
### 3.2 Zeitbasierte Simulation (serverseitig, authoritative)
Der Server speichert pro Planet:
- aktuelle Mengen pro Ressource
- `last_resource_update_at`
Beim Lesen oder bei Aktionen (Bauen, Abholen, Missionen etc.) muss der Server Ressourcen **nachziehen**:
1) `dt = now - last_resource_update_at`
2) `net_rates_per_hour = calc_net_rates(planet)` (Produktion Verbrauch, inkl. Drossel)
3) `amount += net_rates * dt/3600`
4) optional: clamping auf `0..cap` (siehe 3.3)
5) `last_resource_update_at = now`
### 3.3 Caps (Speicherlimits)
Caps sind optional pro Ressource und entstehen über Effects (z.B. `capacity_add`).
Wenn Cap für eine Ressource nicht definiert ist, gilt `cap = infinity` (Default).
Caps dürfen auch für `energy` existieren (Batterien).
---
## 4) Multiplikatoren-System (Planet + Race + Spec + Research + …)
### 4.1 Grundsatz
Blueprints definieren **Grundwerte** (z.B. Mine produziert 50/h).
Der tatsächliche Effekt entsteht durch eine **Multiplikator-Kette**.
Für eine Ressource `r` gilt:
`effective_value(r) = base_value(r) × Π (1 + bonus_i(r))`
- +10% = `+0.10`, 15% = `0.15`
- Multiplikation ist Absicht (Synergien, komplexeres Wirtschaften)
### 4.2 Quellen der Boni (Standard-Kette)
Boni werden gesammelt (Reihenfolge nur für Debug/Anzeige):
1) Planet (konkrete Planet-Modifier)
2) Race (Rassenmodifier)
3) Specialization (Spezialisierungsmodifier)
4) Research (Forschungsmodifier)
5) optional später: Events, Artefakte, Offiziere, Allianzboni …
### 4.3 Separate Behandlung von Produktion & Verbrauch
- Produktion (`produce`, `convert outputs`) und Verbrauch (`consume`, `convert inputs`) können getrennte Boni haben.
- Default: Wenn kein spezieller Bonus existiert, wirkt ein Bonus nur auf das passende Feld (prod vs consume).
### 4.4 Debug-Breakdown (Pflicht für UI)
Die UI soll optional einen Breakdown anzeigen:
- Base
- Planet
- Race
- Spec
- Research
- Result
Das ist essenziell für Transparenz & Balancing.
---
## 5) Weltmodell & Space Objects
### 5.1 Koordinaten
Koordinaten sind `galaxy:system:position` (G:S:P).
### 5.2 Space Objects (Mehrfachobjekte pro Koordinate)
An einer Koordinate können mehrere Space Objects existieren:
- planet
- wormhole
- blackhole
- station
- anomaly
- mission_marker
- debris_field
- beacon
- … (erweiterbar)
Ein Planet ist ein Space Object vom Typ `planet`.
### 5.3 Targeting für Flotten/Missionen
Ein Ziel kann sein:
- `target_object_id` (bevorzugt) oder
- `(coords + target_type)`
Dadurch kann die UI z.B. „anfliegen: Planet / Wurmloch / Station“ auswählen.
---
## 6) Planetklassen & Universum-Generator (Constraints + Budget)
### 6.1 Planetklasse ist „Vorgabe-Regelwerk“
Planetklassen (ice/desert/rock/…) geben dem Generator:
- **Constraints (MUSS):** harte Min/Max-Bedingungen für bestimmte Resource-Modifier
- **Bias (SOLL):** Verteilungspräferenzen, welche Ressourcen eher hoch/runter gehen
Beispiel (Ice):
- water immer >= +50%
- energy immer <= 60%
### 6.2 Planet-Modifier werden pro Planet gezogen und sind stabil
### 6.2a Temperatur (einfach, performant)
Jeder Planet hat eine **Temperatur als Einzelwert in °C**:
- DB/State-Feld: `temperature_c` (integer, z.B. `-40` bis `+80`)
- Planetklasse kann optional `temperature_range_c: [min, max]` definieren.
- Der Universum-Generator würfelt die Temperatur **deterministisch** (Seed) innerhalb des Ranges und **speichert** sie am Planeten.
- Wenn kein `temperature_range_c` definiert ist, gilt **Default**: `temperature_c = 0` (°C).
**Wichtig (v1):** Es gibt **keine Tag/Nacht-Simulation** und keine zeitabhängigen Temperaturkurven.
Temperatur ist in v1 primär:
- Anzeige/Flavor (UI)
- späterer Input für Terraformer/Planet-Anpassung
Damit bleibt die rückwirkende Ressourcenberechnung (z.B. 15 Tage) trivial performant.
**Terraformer (später):**
- darf `temperature_c` verändern (mit Kosten/Constraints),
- ohne dass der Ressourcen-Tick komplizierter wird, solange Temperatur nicht aktiv in Produktionsformeln genutzt wird.
Jeder Planet hat:
- `planet_class_key`
- `planet_seed`
- **konkrete** Modifier-Werte pro Ressource (werden gespeichert)
Warum speichern?
- Balancing-Änderungen sollen nicht rückwirkend alte Planeten verändern.
### 6.3 Budget-/Score-System für Vergleichbarkeit
Planeten sollen trotz Spezialisierung **vergleichbar** sein.
Definiere:
- Gewichte `W[r]` pro Ressource (Wertigkeit)
- Modifier `m[r]` pro Ressource (z.B. +2.0 = +200%, 1.0 = 100%)
- Planet-Score `S = Σ(W[r] × m[r])`
Jeder Planet gehört zu einem Tier mit `target_score`:
- normal: `S ≈ 0`
- rich: `S ≈ +0.4`
- legendary: `S ≈ +1.0`
(Spawnwahrscheinlichkeiten per weight)
**Generator-Regel:**
Nach Anwenden der Klassen-Constraints werden die restlichen Modifier so ergänzt/justiert, dass `S` im Zielbereich liegt (epsilon).
### 6.4 Ablauf Planetgenerierung (deterministisch)
1) planet_class anhand spawn_weight wählen
2) alle `m[r] = 0` initialisieren
3) Constraints anwenden (min/max + ggf. random within range)
4) aktuellen Score S berechnen
5) Rest-Modifier verteilen (unter Einhaltung globaler Bounds + Bias), bis Zielscore erreicht
6) Falls unmöglich: re-roll oder Tier wechseln (SSOT-Default: re-roll bis max N; dann Tier switch)
---
## 7) Baukasten-System: Capabilities, Effects, Requirements, Access
### 7.1 Capabilities (Flags) — frei erweiterbar (Formular-Add)
Capabilities sind freie Strings, z.B.:
- `menu:research`
- `menu:bank`
- `menu:spy`
- `menu:black_market`
- `menu:lottery`
- `shipyard:small|medium|large|mega`
- `defense:orbital`
- `terraforming`
- `ground:cloning`
- `stargate`
**Regel (Feature Unlock):**
Ein Menüpunkt/Feature ist verfügbar, wenn der Spieler **mindestens einen Besitz/State** hat, der die Capability liefert:
- meist: mindestens ein Gebäude mit `capabilities` enthält `menu:xyz`
- alternativ: Forschung kann Capability gewähren (`grant_capability`)
### 7.2 Effects (kleine, feste Bibliothek)
Der Baukasten erlaubt nur diese Effect-Typen (v1):
1) `produce` — +X pro Stunde Ressource
2) `consume`X pro Stunde Ressource
3) `convert` — Inputs/h → Outputs/h (z.B. 20 metal + 5 energy → 5 alloy)
4) `capacity_add` — Cap +X für Ressource (z.B. Lager, Batterie)
5) `queue_slots_add` — zusätzliche Bau-/Queue-Slots (z.B. Bauzentrum)
6) `points_add` — +X pro Stunde für Punkt-Ressourcen (z.B. research_points, spy_points)
7) `modifier_add` — Additiver Bonus (z.B. +0.02 metal prod) auf Ziel (by_resource/by_tag/by_key)
8) `grant_capability` — schaltet Capability frei (v.a. Forschung)
**Hinweis:** Neue Effect-Typen sind möglich, aber erfordern Code-Erweiterung und SSOT-Update.
### 7.3 Requirements (kleine, feste Bibliothek)
Der Baukasten erlaubt diese Requirements (v1):
- `building_count` (min count eines bestimmten Gebäudes)
- `building_tag_count` (min count aller Gebäude mit Tag)
- `research_level` (min Level einer Forschung)
- `has_capability` (z.B. `menu:research` muss vorhanden sein)
- `player_race_in` / `player_race_not_in`
- `player_spec_in` / `player_spec_not_in`
### 7.4 Access Rules (Whitelist/Blacklist) — überall gleich
Zusätzlich oder alternativ zu Requirements können Blueprints `access` definieren:
- `allowed_races`, `blocked_races`
- `allowed_specializations`, `blocked_specializations`
**Regeln:**
- Wenn `allowed_*` nicht leer → muss enthalten sein.
- Wenn in `blocked_*` enthalten → verboten.
Beispiel: Roboter dürfen keine Klonfabrik, aber dürfen Robotik-Werkstatt:
- clone_factory: blocked_races=["robot"]
- robot_workshop: allowed_races=["robot"]
---
## 8) Gebäude (Buildings) — stackable/levelable und Bauzentren-Queues
### 8.1 Gebäude-Modelle
Gebäude unterstützen mindestens:
- `stackable`: man baut N Instanzen (Count)
- `levelable`: ein Gebäude hat Level L (Upgrade)
(hybrid ist optional später)
### 8.2 Paralleles Bauen über Bauzentren
Es gibt ein Gebäude (Blueprint) mit Capability/Effect, das **Queue-Slots** liefert.
Standard: `build_center` ist stackable und hat Effect `queue_slots_add: +1 je count`.
**Regel:**
`queue_slots = base_slots + Σ(queue_slots_add effects)`
SSOT-Default: `base_slots = 0` und mindestens 1 Bauzentrum im Startpaket.
### 8.3 Bauauftrag (Build Job)
Ein Bauauftrag enthält:
- planet_id
- building_key
- mode:
- stackable: delta_count
- levelable: target_level
- started_at, finish_at
- slot_index (0..queue_slots-1)
### 8.4 Start eines Bauauftrags
1) update_resources(planet)
2) freie Slots prüfen (active_jobs < queue_slots)
3) Requirements + Access prüfen
4) Auto-Cost berechnen (oder config cost)
5) Ressourcen sofort abziehen
6) Job anlegen (finish_at)
### 8.5 Abschluss
Wenn `now >= finish_at`:
- stackable: count += delta_count
- levelable: level = target_level
- Job entfernen
- optional: Report/Event
---
## 9) Zerstörbarkeit & Bombardierung (Gebäude können zerstört werden)
### 9.1 Gebäude-Property: destroyable
Gebäude können `properties.destroyable` haben:
- Default: `true`
- Wenn `false`, darf Bombardierung dieses Gebäude nicht reduzieren.
Optional:
- `structure` (Robustheit)
- `bombard_priority` (Zielgewicht)
- `debris_yield` (Ressourcenanteil der Kosten als Trümmer)
### 9.2 Minimalregel (v1)
Bombardierung reduziert direkt:
- stackable: count sinkt
- levelable: level sinkt
Kein separater Damage-State im v1.
---
## 10) Balance für usergenerierte Inhalte: Punkte + Auto-Cost (Anti-Exploit)
### 10.1 Ziel
User sollen Inhalte bauen können, ohne das Spiel zu brechen.
Admin soll nicht jedes Ding manuell balancen müssen.
### 10.2 Grundregel (Anti-Exploit)
**User wählen Effekte, nicht frei Kosten/Zeit.**
Kosten/Zeit/Upkeep werden **automatisch** aus Effekten abgeleitet (Auto-Cost).
Optional gibt es einen Tradeoff-Slider (z.B. „billiger vs schneller“), aber nur in engem Korridor:
- Default: max ±2 Budgetpunkte oder max ±20% Abweichung.
Damit ist „0 Kosten + lange Zeit“ nicht ausnutzbar, weil Kosten nicht frei sind.
### 10.3 Budget pro Blueprint-Kategorie
Jede Kategorie hat ein Budget (Startwerte v1, später balancen):
- building: z.B. 15
- ship: z.B. 20
- research: z.B. 12
- race: z.B. 0 (Summe Traits muss nahe 0)
- specialization: z.B. 0 (nahe 0)
### 10.4 Scoring (Power Score)
Jeder Effect trägt Punkte bei, abhängig von:
- Ressource (über Werttabelle)
- Art des Effekts (produce/convert/stats/modifier)
- Inputs (consume) reduzieren Score
- Constraints/Exklusivität kann Score reduzieren (z.B. „nur race robot“ erlaubt -> leichter Preisabschlag möglich)
**Werttabelle (v1, grob):**
- metal: 1.0
- crystals: 1.2
- deuterium: 1.3
- water: 1.1
- food: 0.9
- energy: 0.8
- alloy: 2.5
- credits: 0.7
(population/points separat)
Die konkrete Score-Formel ist ein Implementierungsdetail, muss aber:
- deterministisch
- transparent (UI zeigt Score)
- nicht trivialisierbar
### 10.5 Auto-Cost Ableitung (Grundsatz)
`cost` und `build_time` steigen monoton mit Score.
Optional zusätzlich: `upkeep` (consume) als natürliche Bremse, statt Hardcaps.
---
## 11) Start-Defaults (v1)
- Universe: 9 Galaxien, 499 Systeme, 15 Positionen (anpassbar per config)
- Start: 1 Planet pro Spieler
- Start: 1 Bauzentrum (damit mindestens 1 Queue-Slot existiert)
- Planetklasse: temperate (oder nach Race preference), aber Generatorregel gilt
- Gebäudeanzahl: keine Hardcaps (Balance über Kosten/Zeit/Upkeep)
---
## 12) Konfig-Dateien (Empfehlung)
- `config/universe.json` (Seeds, Dimensionen, Tier-Weights)
- `config/resources.json` (Werttabelle, caps defaults)
- `config/planet_classes.json` (spawn_weight, constraints, bias)
- `config/blueprints/*.json` (Gebäude, Schiffe, Forschung, Rassen, Specs)
- `config/taxonomy_capabilities.json` (optional UI-Hilfe/Labels)
---
## 13) Decision Log
- [2026-02-03] v1.0 erstellt: Baukasten-Blueprint-System + Planet-Generator (Constraints+Budget) + Build-Center-Queues + destroyable Flag + Auto-Cost Prinzip.
- [2026-02-03] v1.2 ergänzt: Planet-Temperatur als gespeicherter °C-Einzelwert, keine Tag/Nacht-Simulation; UI-Lokalisierung (°C/°F Anzeige).
- [2026-02-03] Default festgelegt: Ohne `temperature_range_c` gilt `temperature_c = 0` (°C).

27
server/composer.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "galaxy-forge/server",
"type": "project",
"require": {
"php": ">=8.2",
"slim/slim": "^4.12",
"slim/psr7": "^1.6",
"php-di/php-di": "^7.0",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
}
}

29
server/db/migrate.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Database\ConnectionFactory;
use Dotenv\Dotenv;
require __DIR__ . '/../vendor/autoload.php';
$repoRoot = dirname(__DIR__, 2);
if (file_exists($repoRoot . '/.env')) {
$dotenv = Dotenv::createImmutable($repoRoot);
$dotenv->safeLoad();
}
$pdo = ConnectionFactory::create();
$migrationsDir = __DIR__ . '/migrations';
$files = glob($migrationsDir . '/*.sql');
sort($files);
foreach ($files as $file) {
$sql = file_get_contents($file);
if ($sql === false) {
throw new RuntimeException("Migration nicht lesbar: {$file}");
}
$pdo->exec($sql);
echo "OK: " . basename($file) . PHP_EOL;
}

View File

@@ -0,0 +1,72 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
race_key TEXT NOT NULL DEFAULT 'human',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS planets (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
class_key TEXT NOT NULL,
planet_seed INT NOT NULL DEFAULT 0,
temperature_c INT NOT NULL DEFAULT 0,
modifiers TEXT NOT NULL,
resources TEXT NOT NULL,
last_resource_update_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS planet_buildings (
id SERIAL PRIMARY KEY,
planet_id INT NOT NULL REFERENCES planets(id) ON DELETE CASCADE,
building_key TEXT NOT NULL,
count INT NOT NULL DEFAULT 0,
level INT NOT NULL DEFAULT 0,
UNIQUE (planet_id, building_key)
);
CREATE TABLE IF NOT EXISTS build_jobs (
id SERIAL PRIMARY KEY,
planet_id INT NOT NULL REFERENCES planets(id) ON DELETE CASCADE,
building_key TEXT NOT NULL,
mode TEXT NOT NULL,
delta_count INT,
target_level INT,
started_at TIMESTAMP NOT NULL,
finish_at TIMESTAMP NOT NULL,
slot_index INT NOT NULL
);
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS permissions (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
module TEXT NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS user_permission_overrides (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission_id INT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
PRIMARY KEY (user_id, permission_id)
);

109
server/db/seed.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Database\ConnectionFactory;
use App\Module\Permissions\Permissions;
use App\Module\PlanetGenerator\Service\PlanetGenerator;
use Dotenv\Dotenv;
require __DIR__ . '/../vendor/autoload.php';
$repoRoot = dirname(__DIR__, 2);
if (file_exists($repoRoot . '/.env')) {
$dotenv = Dotenv::createImmutable($repoRoot);
$dotenv->safeLoad();
}
$pdo = ConnectionFactory::create();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("INSERT INTO roles (key, name) VALUES (:key, :name) ON CONFLICT (key) DO NOTHING");
$stmt->execute(['key' => 'player', 'name' => 'Spieler']);
$stmt->execute(['key' => 'admin', 'name' => 'Admin']);
$permStmt = $pdo->prepare(
'INSERT INTO permissions (key, module, description) VALUES (:key, :module, :description)
ON CONFLICT (key) DO NOTHING'
);
foreach (Permissions::definitions() as $perm) {
$permStmt->execute($perm);
}
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'planet.public.view'
WHERE r.key = 'player'
ON CONFLICT DO NOTHING");
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'planet.admin.generate'
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING");
$stmt = $pdo->prepare("INSERT INTO users (username, race_key) VALUES (:username, :race_key) ON CONFLICT (username) DO NOTHING");
$stmt->execute(['username' => 'dev', 'race_key' => 'human']);
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'dev'")->fetchColumn();
$pdo->exec("INSERT INTO user_roles (user_id, role_id)
SELECT {$userId}, r.id
FROM roles r
WHERE r.key = 'player'
ON CONFLICT DO NOTHING");
$planetExists = $pdo->prepare('SELECT id FROM planets WHERE user_id = :user_id LIMIT 1');
$planetExists->execute(['user_id' => $userId]);
$planetId = $planetExists->fetchColumn();
if (!$planetId) {
$configLoader = new ConfigLoader(new ConfigValidator(), $repoRoot . '/config');
$generator = new PlanetGenerator($configLoader);
$generated = $generator->generate('temperate', 'normal', 42);
$resources = [];
foreach ($configLoader->planetClasses()['resources'] as $res) {
$resources[$res] = 500.0;
}
$stmt = $pdo->prepare(
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)
RETURNING id'
);
$stmt->execute([
'user_id' => $userId,
'name' => 'Earth Prime',
'class_key' => $generated['class_key'],
'planet_seed' => 42,
'temperature_c' => (int)$generated['temperature_c'],
'modifiers' => json_encode($generated['modifiers'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'last_update' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
]);
$planetId = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
VALUES (:planet_id, :building_key, :count, 0)
ON CONFLICT (planet_id, building_key) DO NOTHING'
);
$stmt->execute([
'planet_id' => $planetId,
'building_key' => 'build_center',
'count' => 1,
]);
}
$pdo->commit();
echo "Seed abgeschlossen.\n";
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}

12
server/phpunit.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="GalaxyForge">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

19
server/public/index.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Bootstrap;
use Dotenv\Dotenv;
require __DIR__ . '/../vendor/autoload.php';
$repoRoot = dirname(__DIR__, 1);
$repoRoot = dirname($repoRoot, 1);
if (file_exists($repoRoot . '/.env')) {
$dotenv = Dotenv::createImmutable($repoRoot);
$dotenv->safeLoad();
}
$container = Bootstrap::buildContainer();
$app = Bootstrap::createApp($container);
$app->run();

73
server/src/Bootstrap.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App;
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Database\ConnectionFactory;
use App\Module\Auth\Middleware\AuthContextMiddleware;
use App\Module\Auth\Service\AuthService;
use App\Module\BuildQueue\Routes as BuildQueueRoutes;
use App\Module\Economy\Routes as EconomyRoutes;
use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes;
use App\Shared\Clock\SystemTimeProvider;
use App\Shared\Clock\TimeProvider;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Bootstrap
{
public static function buildContainer(array $overrides = []): ContainerInterface
{
$builder = new ContainerBuilder();
$builder->addDefinitions([
\PDO::class => function () {
return ConnectionFactory::create();
},
TimeProvider::class => function () {
return new SystemTimeProvider();
},
ConfigValidator::class => function () {
return new ConfigValidator();
},
ConfigLoader::class => function (ConfigValidator $validator) {
return new ConfigLoader($validator);
},
]);
if ($overrides) {
$builder->addDefinitions($overrides);
}
return $builder->build();
}
public static function createApp(ContainerInterface $container): App
{
AppFactory::setContainer($container);
$app = AppFactory::create();
$basePath = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/');
if ($basePath !== '' && $basePath !== '.') {
$app->setBasePath($basePath);
}
$app->addBodyParsingMiddleware();
$app->group('', function (RouteCollectorProxyInterface $group) use ($container) {
EconomyRoutes::register($group, $container);
BuildQueueRoutes::register($group, $container);
PlanetGeneratorRoutes::register($group, $container);
})->add(new AuthContextMiddleware($container->get(AuthService::class)));
$displayError = (getenv('APP_ENV') ?: 'dev') !== 'prod';
$app->addErrorMiddleware($displayError, true, true);
return $app;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Config;
final class ConfigLoader
{
private ConfigValidator $validator;
private string $baseDir;
/** @var array<string,array<string,mixed>> */
private array $cache = [];
public function __construct(ConfigValidator $validator, ?string $baseDir = null)
{
$this->validator = $validator;
$this->baseDir = $baseDir ?? dirname(__DIR__, 3) . '/config';
}
/**
* @return array<string,mixed>
*/
public function planetClasses(): array
{
return $this->load('planet_classes.json', function (array $config): void {
$this->validator->validatePlanetClasses($config, 'planet_classes.json');
});
}
/**
* @return array<string,mixed>
*/
public function races(): array
{
return $this->load('races.json', function (array $config): void {
$this->validator->validateRaces($config, 'races.json');
});
}
/**
* @return array<string,mixed>
*/
public function blueprintsBuildings(): array
{
return $this->load('blueprints_buildings.json', function (array $config): void {
$this->validator->validateBlueprintsBuildings($config, 'blueprints_buildings.json');
});
}
/**
* @param callable(array<string,mixed>):void $validate
* @return array<string,mixed>
*/
private function load(string $file, callable $validate): array
{
if (isset($this->cache[$file])) {
return $this->cache[$file];
}
$path = rtrim($this->baseDir, '/') . '/' . $file;
if (!file_exists($path)) {
throw new \RuntimeException("Config-Datei nicht gefunden: {$path}");
}
$raw = file_get_contents($path);
if ($raw === false) {
throw new \RuntimeException("Config-Datei konnte nicht gelesen werden: {$path}");
}
$data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) {
throw new \RuntimeException("Config-Datei muss ein JSON-Objekt sein: {$path}");
}
$validate($data);
$this->cache[$file] = $data;
return $data;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Config;
final class ConfigValidationException extends \RuntimeException
{
/** @var string[] */
private array $errors;
/**
* @param string[] $errors
*/
public function __construct(string $file, array $errors)
{
$this->errors = $errors;
$message = "Config-Validation fehlgeschlagen: {$file}\n- " . implode("\n- ", $errors);
parent::__construct($message);
}
/**
* @return string[]
*/
public function getErrors(): array
{
return $this->errors;
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Config;
final class ConfigValidator
{
/**
* @param array<string,mixed> $config
*/
public function validatePlanetClasses(array $config, string $file): void
{
$errors = [];
foreach (['resources', 'weights', 'tiers', 'classes', 'global_bounds'] as $key) {
if (!array_key_exists($key, $config)) {
$errors[] = "Fehlender Key '{$key}'";
}
}
if (isset($config['resources']) && (!is_array($config['resources']) || $config['resources'] === [])) {
$errors[] = "'resources' muss ein nicht-leeres Array sein";
}
$expected = $this->expectedResources();
if (isset($config['resources']) && is_array($config['resources'])) {
$missing = array_diff($expected, $config['resources']);
if ($missing) {
$errors[] = "'resources' fehlt: " . implode(', ', $missing);
}
}
if (isset($config['weights']) && !is_array($config['weights'])) {
$errors[] = "'weights' muss ein Objekt sein";
}
if (isset($config['weights']) && is_array($config['weights'])) {
foreach ($expected as $res) {
if (!isset($config['weights'][$res]) || !is_numeric($config['weights'][$res])) {
$errors[] = "'weights.{$res}' muss numerisch sein";
}
}
}
if (isset($config['global_bounds']) && is_array($config['global_bounds'])) {
if (!isset($config['global_bounds']['min']) || !is_numeric($config['global_bounds']['min'])) {
$errors[] = "'global_bounds.min' muss numerisch sein";
}
if (!isset($config['global_bounds']['max']) || !is_numeric($config['global_bounds']['max'])) {
$errors[] = "'global_bounds.max' muss numerisch sein";
}
}
if (isset($config['tiers']) && is_array($config['tiers'])) {
foreach ($config['tiers'] as $tierKey => $tier) {
if (!is_array($tier)) {
$errors[] = "Tier '{$tierKey}' muss ein Objekt sein";
continue;
}
if (!isset($tier['target_score']) || !is_numeric($tier['target_score'])) {
$errors[] = "Tier '{$tierKey}': 'target_score' fehlt oder ist nicht numerisch";
}
if (!isset($tier['epsilon']) || !is_numeric($tier['epsilon'])) {
$errors[] = "Tier '{$tierKey}': 'epsilon' fehlt oder ist nicht numerisch";
}
}
}
if (isset($config['classes']) && is_array($config['classes'])) {
foreach ($config['classes'] as $classKey => $class) {
if (!is_array($class)) {
$errors[] = "Klasse '{$classKey}' muss ein Objekt sein";
continue;
}
if (!isset($class['spawn_weight']) || !is_numeric($class['spawn_weight'])) {
$errors[] = "Klasse '{$classKey}': 'spawn_weight' fehlt oder ist nicht numerisch";
}
if (isset($class['constraints']) && !is_array($class['constraints'])) {
$errors[] = "Klasse '{$classKey}': 'constraints' muss ein Objekt sein";
}
if (isset($class['constraints']) && is_array($class['constraints'])) {
foreach ($class['constraints'] as $res => $constraint) {
if (!is_array($constraint)) {
$errors[] = "Constraint '{$classKey}.{$res}' muss ein Objekt sein";
continue;
}
if (isset($constraint['min']) && !is_numeric($constraint['min'])) {
$errors[] = "Constraint '{$classKey}.{$res}.min' muss numerisch sein";
}
if (isset($constraint['max']) && !is_numeric($constraint['max'])) {
$errors[] = "Constraint '{$classKey}.{$res}.max' muss numerisch sein";
}
}
}
if (isset($class['temperature_range_c'])) {
$range = $class['temperature_range_c'];
if (!is_array($range) || count($range) !== 2) {
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' muss ein Array [min,max] sein";
} else {
[$min, $max] = array_values($range);
if (!is_numeric($min) || !is_numeric($max)) {
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' Werte müssen numerisch sein";
} elseif ((float)$min > (float)$max) {
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' min darf nicht größer als max sein";
}
}
}
}
}
if ($errors) {
throw new ConfigValidationException($file, $errors);
}
}
/**
* @param array<string,mixed> $config
*/
public function validateRaces(array $config, string $file): void
{
$errors = [];
if (!isset($config['races']) || !is_array($config['races'])) {
$errors[] = "'races' muss ein Objekt sein";
}
if (isset($config['races']) && is_array($config['races'])) {
foreach ($config['races'] as $raceKey => $race) {
if (!is_array($race)) {
$errors[] = "Race '{$raceKey}' muss ein Objekt sein";
continue;
}
if (empty($race['name'])) {
$errors[] = "Race '{$raceKey}': 'name' fehlt";
}
if (isset($race['modifiers']) && !is_array($race['modifiers'])) {
$errors[] = "Race '{$raceKey}': 'modifiers' muss ein Objekt sein";
}
}
}
if ($errors) {
throw new ConfigValidationException($file, $errors);
}
}
/**
* @param array<string,mixed> $config
*/
public function validateBlueprintsBuildings(array $config, string $file): void
{
$errors = [];
if (!isset($config['blueprints']) || !is_array($config['blueprints'])) {
$errors[] = "'blueprints' muss ein Array sein";
}
if (isset($config['blueprints']) && is_array($config['blueprints'])) {
foreach ($config['blueprints'] as $idx => $bp) {
if (!is_array($bp)) {
$errors[] = "Blueprint #{$idx} muss ein Objekt sein";
continue;
}
foreach (['kind', 'key', 'name'] as $req) {
if (empty($bp[$req])) {
$errors[] = "Blueprint #{$idx}: '{$req}' fehlt";
}
}
if (isset($bp['effects']) && !is_array($bp['effects'])) {
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'effects' muss ein Array sein";
}
if (isset($bp['requirements']) && !is_array($bp['requirements'])) {
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'requirements' muss ein Array sein";
}
if (isset($bp['access']) && !is_array($bp['access'])) {
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'access' muss ein Objekt sein";
}
}
}
if ($errors) {
throw new ConfigValidationException($file, $errors);
}
}
/**
* @return string[]
*/
public function expectedResources(): array
{
return [
'metal',
'alloy',
'crystals',
'energy',
'credits',
'population',
'water',
'deuterium',
'food',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
final class ConnectionFactory
{
public static function create(?string $dbName = null): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '5432';
$name = $dbName ?? (getenv('DB_NAME') ?: 'galaxyforge');
$user = getenv('DB_USER') ?: 'galaxyforge';
$pass = getenv('DB_PASS') ?: 'galaxyforge';
$dsn = "pgsql:host={$host};port={$port};dbname={$name}";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return $pdo;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Middleware;
use App\Module\Auth\Service\AuthService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class AuthContextMiddleware implements MiddlewareInterface
{
public function __construct(private AuthService $authService)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $this->authService->resolveUser($request);
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $handler->handle($request);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Service;
use PDO;
use Psr\Http\Message\ServerRequestInterface;
final class AuthService
{
public function __construct(private PDO $pdo)
{
}
/**
* @return array<string,mixed>|null
*/
public function resolveUser(ServerRequestInterface $request): ?array
{
$id = $this->extractUserId($request);
if ($id !== null) {
return $this->findUserById($id);
}
if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
$devUserId = getenv('DEV_USER_ID');
if ($devUserId !== false && is_numeric($devUserId)) {
$user = $this->findUserById((int)$devUserId);
if ($user) {
return $user;
}
}
return $this->findFirstUser();
}
return null;
}
private function extractUserId(ServerRequestInterface $request): ?int
{
$header = $request->getHeaderLine('X-User-Id');
if ($header !== '' && is_numeric($header)) {
return (int)$header;
}
$header = $request->getHeaderLine('X-Dev-User');
if ($header !== '' && is_numeric($header)) {
return (int)$header;
}
$query = $request->getQueryParams();
if (isset($query['dev_user']) && is_numeric($query['dev_user'])) {
return (int)$query['dev_user'];
}
return null;
}
/**
* @return array<string,mixed>|null
*/
private function findUserById(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* @return array<string,mixed>|null
*/
private function findFirstUser(): ?array
{
$stmt = $this->pdo->query('SELECT * FROM users ORDER BY id ASC LIMIT 1');
$row = $stmt->fetch();
return $row ?: null;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Module\Blueprints\Service;
use App\Config\ConfigLoader;
final class BlueprintService
{
/** @var array<int,array<string,mixed>> */
private array $buildings;
public function __construct(ConfigLoader $configLoader)
{
$config = $configLoader->blueprintsBuildings();
$this->buildings = $config['blueprints'] ?? [];
}
/**
* @return array<int,array<string,mixed>>
*/
public function allBuildings(): array
{
return $this->buildings;
}
/**
* @return array<string,mixed>|null
*/
public function getBuilding(string $key): ?array
{
foreach ($this->buildings as $bp) {
if (($bp['key'] ?? null) === $key) {
return $bp;
}
}
return null;
}
/**
* @param array<string,array{count:int,level:int}> $planetBuildings
* @return array{ok:bool,errors:string[]}
*/
public function checkRequirements(array $blueprint, array $planetBuildings, string $raceKey): array
{
$errors = [];
$requirements = $blueprint['requirements'] ?? [];
if (!is_array($requirements)) {
return ['ok' => false, 'errors' => ['Requirements-Format ungültig.']];
}
foreach ($requirements as $req) {
if (!is_array($req) || empty($req['type'])) {
$errors[] = 'Requirement ohne Typ.';
continue;
}
switch ($req['type']) {
case 'building_count':
$key = (string)($req['key'] ?? '');
$min = (int)($req['min'] ?? 0);
$count = $planetBuildings[$key]['count'] ?? 0;
if ($count < $min) {
$errors[] = "Benötigt {$min}x {$key}.";
}
break;
case 'building_tag_count':
$tag = (string)($req['tag'] ?? '');
$min = (int)($req['min'] ?? 0);
$count = $this->countBuildingsByTag($planetBuildings, $tag);
if ($count < $min) {
$errors[] = "Benötigt {$min} Gebäude mit Tag {$tag}.";
}
break;
case 'has_capability':
$cap = (string)($req['capability'] ?? '');
if (!$this->hasCapability($planetBuildings, $cap)) {
$errors[] = "Capability fehlt: {$cap}.";
}
break;
case 'player_race_in':
$allowed = $req['races'] ?? [];
if (is_array($allowed) && !in_array($raceKey, $allowed, true)) {
$errors[] = "Race {$raceKey} ist nicht erlaubt.";
}
break;
case 'player_race_not_in':
$blocked = $req['races'] ?? [];
if (is_array($blocked) && in_array($raceKey, $blocked, true)) {
$errors[] = "Race {$raceKey} ist ausgeschlossen.";
}
break;
default:
$errors[] = "Requirement-Typ unbekannt: {$req['type']}";
}
}
return ['ok' => $errors === [], 'errors' => $errors];
}
/**
* @param array<string,array{count:int,level:int}> $planetBuildings
*/
private function countBuildingsByTag(array $planetBuildings, string $tag): int
{
$count = 0;
foreach ($planetBuildings as $key => $data) {
$bp = $this->getBuilding($key);
if (!$bp) {
continue;
}
$tags = $bp['tags'] ?? [];
if (is_array($tags) && in_array($tag, $tags, true)) {
$count += (int)$data['count'];
}
}
return $count;
}
/**
* @param array<string,array{count:int,level:int}> $planetBuildings
*/
private function hasCapability(array $planetBuildings, string $capability): bool
{
foreach ($planetBuildings as $key => $data) {
if (($data['count'] ?? 0) < 1) {
continue;
}
$bp = $this->getBuilding($key);
if (!$bp) {
continue;
}
$caps = $bp['capabilities'] ?? [];
if (is_array($caps) && in_array($capability, $caps, true)) {
return true;
}
}
return false;
}
/**
* @return array{ok:bool,errors:string[]}
*/
public function checkAccess(array $blueprint, string $raceKey): array
{
$access = $blueprint['access'] ?? [];
if (!is_array($access)) {
return ['ok' => false, 'errors' => ['Access-Format ungültig.']];
}
$allowed = $access['allowed_races'] ?? [];
if (is_array($allowed) && $allowed !== [] && !in_array($raceKey, $allowed, true)) {
return ['ok' => false, 'errors' => ['Race ist nicht erlaubt.']];
}
$blocked = $access['blocked_races'] ?? [];
if (is_array($blocked) && in_array($raceKey, $blocked, true)) {
return ['ok' => false, 'errors' => ['Race ist blockiert.']];
}
return ['ok' => true, 'errors' => []];
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Module\BuildQueue\Controller;
use App\Module\Blueprints\Service\BlueprintService;
use App\Module\BuildQueue\Service\BuildQueueService;
use App\Module\Economy\Service\EconomyService;
use App\Shared\Clock\TimeProvider;
use App\Shared\Http\JsonResponder;
use PDO;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
final class BuildController
{
public function __construct(
private EconomyService $economy,
private BuildQueueService $buildQueue,
private BlueprintService $blueprints,
private TimeProvider $timeProvider,
private PDO $pdo
) {
}
public function start(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!is_array($user) || !isset($user['id'])) {
return JsonResponder::withJson(new Response(), [
'error' => 'auth_required',
'message' => 'Authentifizierung erforderlich.'
], 401);
}
$body = $request->getParsedBody();
if (!is_array($body)) {
$body = [];
}
$buildingKey = (string)($body['building_key'] ?? '');
$amount = (int)($body['amount'] ?? 1);
if ($buildingKey === '' || $amount < 1) {
return JsonResponder::withJson($response, [
'error' => 'invalid_input',
'message' => 'building_key und amount sind Pflichtfelder.'
], 400);
}
$planetId = isset($body['planet_id']) ? (int)$body['planet_id'] : null;
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
$state = $this->economy->updateResources((int)$planet['id']);
$this->buildQueue->finalizeJobs((int)$planet['id']);
$bp = $this->blueprints->getBuilding($buildingKey);
if (!$bp) {
return JsonResponder::withJson($response, [
'error' => 'unknown_building',
'message' => 'Blueprint nicht gefunden.'
], 404);
}
$raceKey = (string)$user['race_key'];
$access = $this->blueprints->checkAccess($bp, $raceKey);
if (!$access['ok']) {
return JsonResponder::withJson($response, [
'error' => 'access_denied',
'message' => implode(' ', $access['errors'])
], 403);
}
$buildings = $this->economy->getPlanetBuildings((int)$planet['id']);
$reqCheck = $this->blueprints->checkRequirements($bp, $buildings, $raceKey);
if (!$reqCheck['ok']) {
return JsonResponder::withJson($response, [
'error' => 'requirements_failed',
'message' => implode(' ', $reqCheck['errors'])
], 422);
}
$queueSlots = $this->buildQueue->getQueueSlots((int)$planet['id'], 0);
if ($queueSlots <= 0) {
return JsonResponder::withJson($response, [
'error' => 'no_queue_slots',
'message' => 'Keine Bauzentren vorhanden.'
], 409);
}
$activeJobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
if (count($activeJobs) >= $queueSlots) {
return JsonResponder::withJson($response, [
'error' => 'no_queue_slots',
'message' => 'Keine freien Bauplätze verfügbar.'
], 409);
}
$cost = $bp['cost'] ?? [];
if (!is_array($cost)) {
$cost = [];
}
$resources = $state['resources'];
$totalCost = [];
foreach ($cost as $res => $val) {
$totalCost[$res] = (float)$val * $amount;
if (($resources[$res] ?? 0.0) < $totalCost[$res]) {
return JsonResponder::withJson($response, [
'error' => 'insufficient_resources',
'message' => "Zu wenig {$res}."
], 409);
}
}
foreach ($totalCost as $res => $val) {
$resources[$res] -= $val;
}
$stmt = $this->pdo->prepare('UPDATE planets SET resources = :resources WHERE id = :id');
$stmt->execute([
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'id' => (int)$planet['id'],
]);
$model = (string)($bp['model'] ?? 'stackable');
$buildTime = (int)($bp['build_time'] ?? 60) * $amount;
$now = $this->timeProvider->now();
$finishAt = $now->modify('+' . $buildTime . ' seconds');
$slotIndex = $this->nextSlotIndex($activeJobs, $queueSlots);
$stmt = $this->pdo->prepare(
'INSERT INTO build_jobs (planet_id, building_key, mode, delta_count, target_level, started_at, finish_at, slot_index)
VALUES (:planet_id, :building_key, :mode, :delta_count, :target_level, :started_at, :finish_at, :slot_index)
RETURNING *'
);
$stmt->execute([
'planet_id' => (int)$planet['id'],
'building_key' => $buildingKey,
'mode' => $model,
'delta_count' => $model === 'levelable' ? null : $amount,
'target_level' => $model === 'levelable' ? $amount : null,
'started_at' => $now->format('Y-m-d H:i:s'),
'finish_at' => $finishAt->format('Y-m-d H:i:s'),
'slot_index' => $slotIndex,
]);
$job = $stmt->fetch();
return JsonResponder::withJson($response, [
'job' => $job,
'resources' => $resources,
], 201);
}
public function jobs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!is_array($user) || !isset($user['id'])) {
return JsonResponder::withJson(new Response(), [
'error' => 'auth_required',
'message' => 'Authentifizierung erforderlich.'
], 401);
}
$query = $request->getQueryParams();
$planetId = isset($query['planet_id']) ? (int)$query['planet_id'] : null;
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
$finished = $this->buildQueue->finalizeJobs((int)$planet['id']);
$jobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
return JsonResponder::withJson($response, [
'jobs' => $jobs,
'finished' => $finished,
]);
}
/**
* @param array<int,array<string,mixed>> $jobs
*/
private function nextSlotIndex(array $jobs, int $maxSlots): int
{
$used = [];
foreach ($jobs as $job) {
$used[(int)$job['slot_index']] = true;
}
for ($i = 0; $i < $maxSlots; $i++) {
if (!isset($used[$i])) {
return $i;
}
}
return max(0, $maxSlots - 1);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\BuildQueue;
use App\Module\BuildQueue\Controller\BuildController;
use App\Module\Permissions\Middleware\RequirePermission;
use App\Module\Permissions\Service\PermissionService;
use Psr\Container\ContainerInterface;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Routes
{
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
{
$controller = $container->get(BuildController::class);
$permissions = $container->get(PermissionService::class);
$group->post('/build/start', [$controller, 'start'])
->add(RequirePermission::for($permissions, 'planet.public.view'));
$group->get('/build/jobs', [$controller, 'jobs'])
->add(RequirePermission::for($permissions, 'planet.public.view'));
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Module\BuildQueue\Service;
use App\Module\Blueprints\Service\BlueprintService;
use App\Shared\Clock\TimeProvider;
use PDO;
final class BuildQueueService
{
public function __construct(
private PDO $pdo,
private BlueprintService $blueprints,
private TimeProvider $timeProvider
) {
}
public function getQueueSlots(int $planetId, int $baseSlots = 0): int
{
$buildings = $this->getBuildings($planetId);
return $this->calculateQueueSlots($buildings, $baseSlots);
}
/**
* @param array<string,array{count:int,level:int}> $buildings
*/
public function calculateQueueSlots(array $buildings, int $baseSlots = 0): int
{
$slots = $baseSlots;
foreach ($buildings as $key => $data) {
$bp = $this->blueprints->getBuilding($key);
if (!$bp) {
continue;
}
$model = (string)($bp['model'] ?? 'stackable');
$scale = $model === 'levelable' ? (int)$data['level'] : (int)$data['count'];
if ($scale <= 0) {
continue;
}
$effects = $bp['effects'] ?? [];
if (!is_array($effects)) {
continue;
}
foreach ($effects as $effect) {
if (!is_array($effect) || ($effect['type'] ?? '') !== 'queue_slots_add') {
continue;
}
$slots += (int)($effect['amount'] ?? 0) * $scale;
}
}
return $slots;
}
/**
* @return array<int,array<string,mixed>>
*/
public function getActiveJobs(int $planetId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM build_jobs WHERE planet_id = :planet_id ORDER BY finish_at ASC');
$stmt->execute(['planet_id' => $planetId]);
return $stmt->fetchAll();
}
/**
* @return array<int,array<string,mixed>>
*/
public function finalizeJobs(int $planetId): array
{
$now = $this->timeProvider->now()->format('Y-m-d H:i:s');
$stmt = $this->pdo->prepare('SELECT * FROM build_jobs WHERE planet_id = :planet_id AND finish_at <= :now ORDER BY finish_at ASC');
$stmt->execute(['planet_id' => $planetId, 'now' => $now]);
$ready = $stmt->fetchAll();
if (!$ready) {
return [];
}
foreach ($ready as $job) {
$this->applyJob($planetId, $job);
}
$stmt = $this->pdo->prepare('DELETE FROM build_jobs WHERE planet_id = :planet_id AND finish_at <= :now');
$stmt->execute(['planet_id' => $planetId, 'now' => $now]);
return $ready;
}
/**
* @param array<string,mixed> $job
*/
private function applyJob(int $planetId, array $job): void
{
$mode = (string)($job['mode'] ?? 'stackable');
$key = (string)($job['building_key'] ?? '');
if ($key === '') {
return;
}
if ($mode === 'levelable') {
$targetLevel = (int)($job['target_level'] ?? 0);
$stmt = $this->pdo->prepare(
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
VALUES (:planet_id, :building_key, 0, :level)
ON CONFLICT (planet_id, building_key)
DO UPDATE SET level = EXCLUDED.level'
);
$stmt->execute([
'planet_id' => $planetId,
'building_key' => $key,
'level' => $targetLevel,
]);
return;
}
$delta = (int)($job['delta_count'] ?? 0);
if ($delta <= 0) {
return;
}
$stmt = $this->pdo->prepare(
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
VALUES (:planet_id, :building_key, :count, 0)
ON CONFLICT (planet_id, building_key)
DO UPDATE SET count = planet_buildings.count + EXCLUDED.count'
);
$stmt->execute([
'planet_id' => $planetId,
'building_key' => $key,
'count' => $delta,
]);
}
/**
* @return array<string,array{count:int,level:int}>
*/
private function getBuildings(int $planetId): array
{
$stmt = $this->pdo->prepare('SELECT building_key, count, level FROM planet_buildings WHERE planet_id = :planet_id');
$stmt->execute(['planet_id' => $planetId]);
$rows = $stmt->fetchAll();
$buildings = [];
foreach ($rows as $row) {
$buildings[$row['building_key']] = [
'count' => (int)$row['count'],
'level' => (int)$row['level'],
];
}
return $buildings;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Module\Economy\Controller;
use App\Module\BuildQueue\Service\BuildQueueService;
use App\Module\Economy\Service\EconomyService;
use App\Shared\Http\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
final class StateController
{
public function __construct(
private EconomyService $economy,
private BuildQueueService $buildQueue
) {
}
public function health(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return JsonResponder::withJson($response, [
'status' => 'ok',
'time' => (new \DateTimeImmutable('now'))->format('c'),
]);
}
public function state(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!is_array($user) || !isset($user['id'])) {
return JsonResponder::withJson(new Response(), [
'error' => 'auth_required',
'message' => 'Authentifizierung erforderlich.'
], 401);
}
$query = $request->getQueryParams();
$planetId = isset($query['planet_id']) ? (int)$query['planet_id'] : null;
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
$state = $this->economy->updateResources((int)$planet['id']);
$this->buildQueue->finalizeJobs((int)$planet['id']);
$buildings = $this->economy->getPlanetBuildings((int)$planet['id']);
$modifiers = json_decode((string)$planet['modifiers'], true) ?: [];
$calc = $this->economy->calcNetRates($buildings, $modifiers, (string)$user['race_key']);
$queueSlots = $this->buildQueue->getQueueSlots((int)$planet['id'], 0);
$jobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
return JsonResponder::withJson($response, [
'planet' => [
'id' => (int)$planet['id'],
'class_key' => $planet['class_key'],
'planet_seed' => (int)$planet['planet_seed'],
'temperature_c' => (int)$planet['temperature_c'],
'modifiers' => json_decode((string)$planet['modifiers'], true) ?: [],
],
'resources' => $state['resources'],
'net_rates_per_hour' => $calc['net_rates'],
'queue_slots' => $queueSlots,
'active_build_jobs' => $jobs,
'race' => $user['race_key'],
'breakdown' => $calc['breakdown'],
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Economy;
use App\Module\Economy\Controller\StateController;
use App\Module\Permissions\Middleware\RequirePermission;
use App\Module\Permissions\Service\PermissionService;
use Psr\Container\ContainerInterface;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Routes
{
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
{
$controller = $container->get(StateController::class);
$permissions = $container->get(PermissionService::class);
$group->get('/health', [$controller, 'health']);
$group->get('/state', [$controller, 'state'])
->add(RequirePermission::for($permissions, 'planet.public.view'));
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Module\Economy\Service;
use App\Config\ConfigLoader;
use App\Module\Blueprints\Service\BlueprintService;
use App\Shared\Clock\TimeProvider;
use PDO;
final class EconomyService
{
/** @var string[] */
private array $resources;
public function __construct(
private PDO $pdo,
private ConfigLoader $configLoader,
private BlueprintService $blueprints,
private TimeProvider $timeProvider
) {
$planetConfig = $this->configLoader->planetClasses();
$this->resources = $planetConfig['resources'] ?? [];
}
/**
* @return array<string,mixed>
*/
public function getPlanetForUser(int $userId, ?int $planetId = null): array
{
if ($planetId !== null) {
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE id = :id AND user_id = :user_id');
$stmt->execute(['id' => $planetId, 'user_id' => $userId]);
$planet = $stmt->fetch();
if ($planet) {
return $planet;
}
}
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE user_id = :user_id ORDER BY id ASC LIMIT 1');
$stmt->execute(['user_id' => $userId]);
$planet = $stmt->fetch();
if (!$planet) {
throw new \RuntimeException('Kein Planet für User gefunden.');
}
return $planet;
}
/**
* @return array<string,array{count:int,level:int}>
*/
public function getPlanetBuildings(int $planetId): array
{
$stmt = $this->pdo->prepare('SELECT building_key, count, level FROM planet_buildings WHERE planet_id = :planet_id');
$stmt->execute(['planet_id' => $planetId]);
$rows = $stmt->fetchAll();
$buildings = [];
foreach ($rows as $row) {
$buildings[$row['building_key']] = [
'count' => (int)$row['count'],
'level' => (int)$row['level'],
];
}
return $buildings;
}
/**
* @return array{resources:array<string,float>,net_rates:array<string,float>,breakdown:array<string,mixed>,caps:array<string,float>}
*/
public function updateResources(int $planetId): array
{
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE id = :id');
$stmt->execute(['id' => $planetId]);
$planet = $stmt->fetch();
if (!$planet) {
throw new \RuntimeException('Planet nicht gefunden.');
}
$resources = json_decode((string)$planet['resources'], true) ?: [];
foreach ($this->resources as $res) {
if (!isset($resources[$res])) {
$resources[$res] = 0.0;
}
}
$buildings = $this->getPlanetBuildings($planetId);
$raceKey = $this->getRaceForPlanetUser((int)$planet['user_id']);
$modifiers = json_decode((string)$planet['modifiers'], true) ?: [];
$calc = $this->calcNetRates($buildings, $modifiers, $raceKey);
$netRates = $calc['net_rates'];
$caps = $calc['caps'];
$last = new \DateTimeImmutable($planet['last_resource_update_at']);
$now = $this->timeProvider->now();
$dt = max(0, $now->getTimestamp() - $last->getTimestamp());
if ($dt > 0) {
foreach ($this->resources as $res) {
$resources[$res] = (float)$resources[$res] + ($netRates[$res] ?? 0.0) * ($dt / 3600);
if ($resources[$res] < 0) {
$resources[$res] = 0.0;
}
if (isset($caps[$res]) && is_numeric($caps[$res])) {
$resources[$res] = min($resources[$res], (float)$caps[$res]);
}
}
$stmt = $this->pdo->prepare('UPDATE planets SET resources = :resources, last_resource_update_at = :now WHERE id = :id');
$stmt->execute([
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'now' => $now->format('Y-m-d H:i:s'),
'id' => $planetId,
]);
}
return [
'resources' => $resources,
'net_rates' => $netRates,
'breakdown' => $calc['breakdown'],
'caps' => $caps,
];
}
/**
* @param array<string,array{count:int,level:int}> $buildings
* @param array<string,mixed> $planetModifiers
* @return array{net_rates:array<string,float>,breakdown:array<string,mixed>,caps:array<string,float>}
*/
public function calcNetRates(array $buildings, array $planetModifiers, string $raceKey): array
{
$baseProduce = array_fill_keys($this->resources, 0.0);
$baseConsume = array_fill_keys($this->resources, 0.0);
$caps = [];
$planetProduceBonus = array_fill_keys($this->resources, 0.0);
$raceProduceBonus = array_fill_keys($this->resources, 0.0);
$raceConsumeBonus = array_fill_keys($this->resources, 0.0);
$bonusEffectsProduce = array_fill_keys($this->resources, 0.0);
$bonusEffectsConsume = array_fill_keys($this->resources, 0.0);
foreach ($planetModifiers as $res => $val) {
if (array_key_exists($res, $planetProduceBonus)) {
$planetProduceBonus[$res] = (float)$val;
}
}
$races = $this->configLoader->races();
$race = $races['races'][$raceKey] ?? null;
if (is_array($race)) {
$raceProd = $race['modifiers']['produce'] ?? [];
$raceCons = $race['modifiers']['consume'] ?? [];
if (is_array($raceProd)) {
foreach ($raceProd as $res => $val) {
if (array_key_exists($res, $raceProduceBonus)) {
$raceProduceBonus[$res] += (float)$val;
}
}
}
if (is_array($raceCons)) {
foreach ($raceCons as $res => $val) {
if (array_key_exists($res, $raceConsumeBonus)) {
$raceConsumeBonus[$res] += (float)$val;
}
}
}
}
foreach ($buildings as $key => $data) {
$bp = $this->blueprints->getBuilding($key);
if (!$bp) {
continue;
}
$model = (string)($bp['model'] ?? 'stackable');
$scale = $model === 'levelable' ? (int)$data['level'] : (int)$data['count'];
if ($scale <= 0) {
continue;
}
$effects = $bp['effects'] ?? [];
if (!is_array($effects)) {
continue;
}
foreach ($effects as $effect) {
if (!is_array($effect) || empty($effect['type'])) {
continue;
}
switch ($effect['type']) {
case 'produce':
$res = (string)($effect['resource'] ?? '');
$amount = (float)($effect['amount'] ?? 0.0);
if (array_key_exists($res, $baseProduce)) {
$baseProduce[$res] += $amount * $scale;
}
break;
case 'consume':
$res = (string)($effect['resource'] ?? '');
$amount = (float)($effect['amount'] ?? 0.0);
if (array_key_exists($res, $baseConsume)) {
$baseConsume[$res] += $amount * $scale;
}
break;
case 'convert':
$inputs = $effect['inputs'] ?? [];
$outputs = $effect['outputs'] ?? [];
if (is_array($inputs)) {
foreach ($inputs as $res => $amount) {
if (array_key_exists($res, $baseConsume)) {
$baseConsume[$res] += (float)$amount * $scale;
}
}
}
if (is_array($outputs)) {
foreach ($outputs as $res => $amount) {
if (array_key_exists($res, $baseProduce)) {
$baseProduce[$res] += (float)$amount * $scale;
}
}
}
break;
case 'capacity_add':
$res = (string)($effect['resource'] ?? '');
$amount = (float)($effect['amount'] ?? 0.0);
if (!isset($caps[$res])) {
$caps[$res] = 0.0;
}
$caps[$res] += $amount * $scale;
break;
case 'modifier_add':
$target = $effect['target'] ?? [];
if (!is_array($target)) {
break;
}
$res = (string)($target['resource'] ?? '');
$field = (string)($target['field'] ?? 'produce');
$amount = (float)($effect['amount'] ?? 0.0);
if ($field === 'consume' && array_key_exists($res, $bonusEffectsConsume)) {
$bonusEffectsConsume[$res] += $amount;
} elseif (array_key_exists($res, $bonusEffectsProduce)) {
$bonusEffectsProduce[$res] += $amount;
}
break;
}
}
}
$netRates = [];
$effectiveProduce = [];
$effectiveConsume = [];
$prodMultiplier = [];
$consMultiplier = [];
foreach ($this->resources as $res) {
$prodMultiplier[$res] = self::multiplyBonuses([
$planetProduceBonus[$res] ?? 0.0,
$raceProduceBonus[$res] ?? 0.0,
$bonusEffectsProduce[$res] ?? 0.0,
]);
$consMultiplier[$res] = self::multiplyBonuses([
$raceConsumeBonus[$res] ?? 0.0,
$bonusEffectsConsume[$res] ?? 0.0,
]);
$effectiveProduce[$res] = $baseProduce[$res] * $prodMultiplier[$res];
$effectiveConsume[$res] = $baseConsume[$res] * $consMultiplier[$res];
$netRates[$res] = $effectiveProduce[$res] - $effectiveConsume[$res];
}
$breakdown = [
'produce' => [
'base' => $baseProduce,
'planet' => $planetProduceBonus,
'race' => $raceProduceBonus,
'effects' => $bonusEffectsProduce,
'result' => $effectiveProduce,
],
'consume' => [
'base' => $baseConsume,
'race' => $raceConsumeBonus,
'effects' => $bonusEffectsConsume,
'result' => $effectiveConsume,
],
];
return [
'net_rates' => $netRates,
'breakdown' => $breakdown,
'caps' => $caps,
];
}
public static function multiplyBonuses(array $bonuses): float
{
$mult = 1.0;
foreach ($bonuses as $bonus) {
$mult *= (1.0 + (float)$bonus);
}
return $mult;
}
private function getRaceForPlanetUser(int $userId): string
{
$stmt = $this->pdo->prepare('SELECT race_key FROM users WHERE id = :id');
$stmt->execute(['id' => $userId]);
$race = $stmt->fetchColumn();
return $race ? (string)$race : 'human';
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Module\Permissions\Middleware;
use App\Module\Permissions\Service\PermissionService;
use App\Shared\Http\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
final class RequirePermission implements MiddlewareInterface
{
public function __construct(
private PermissionService $permissions,
private string $permissionKey
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $request->getAttribute('user');
if (!is_array($user) || !isset($user['id'])) {
return JsonResponder::withJson(new Response(), [
'error' => 'auth_required',
'message' => 'Authentifizierung erforderlich.'
], 401);
}
if (!$this->permissions->can((int)$user['id'], $this->permissionKey)) {
return JsonResponder::withJson(new Response(), [
'error' => 'forbidden',
'message' => 'Keine Berechtigung für diese Aktion.',
'permission' => $this->permissionKey,
], 403);
}
return $handler->handle($request);
}
public static function for(PermissionService $permissions, string $permissionKey): self
{
return new self($permissions, $permissionKey);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Permissions;
final class Permissions
{
/**
* @return array<int,array<string,string>>
*/
public static function definitions(): array
{
return [
[
'key' => 'planet.admin.generate',
'module' => 'planet',
'description' => 'Universum/Planeten generieren',
],
[
'key' => 'planet.admin.regen',
'module' => 'planet',
'description' => 'Planeten neu generieren',
],
[
'key' => 'planet.public.view',
'module' => 'planet',
'description' => 'Planetenstatus ansehen',
],
[
'key' => 'blueprints.admin.add',
'module' => 'blueprints',
'description' => 'Blueprints administrativ hinzufügen',
],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Module\Permissions\Service;
use PDO;
final class PermissionService
{
public function __construct(private PDO $pdo)
{
}
public function can(int $userId, string $permissionKey): bool
{
$stmt = $this->pdo->prepare(
'SELECT uo.effect
FROM user_permission_overrides uo
JOIN permissions p ON p.id = uo.permission_id
WHERE uo.user_id = :user_id AND p.key = :key
LIMIT 1'
);
$stmt->execute(['user_id' => $userId, 'key' => $permissionKey]);
$override = $stmt->fetchColumn();
if ($override === 'deny') {
return false;
}
if ($override === 'allow') {
return true;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = :user_id AND p.key = :key
LIMIT 1'
);
$stmt->execute(['user_id' => $userId, 'key' => $permissionKey]);
return $stmt->fetchColumn() !== false;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\PlanetGenerator\Controller;
use App\Shared\Http\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class AdminPlanetController
{
public function generate(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return JsonResponder::withJson($response, [
'status' => 'ok',
'message' => 'Stub: Universum generieren (v0.1).'
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\PlanetGenerator;
use App\Module\Permissions\Middleware\RequirePermission;
use App\Module\Permissions\Service\PermissionService;
use App\Module\PlanetGenerator\Controller\AdminPlanetController;
use Psr\Container\ContainerInterface;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Routes
{
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
{
$controller = $container->get(AdminPlanetController::class);
$permissions = $container->get(PermissionService::class);
$group->post('/admin/universe/generate', [$controller, 'generate'])
->add(RequirePermission::for($permissions, 'planet.admin.generate'));
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Module\PlanetGenerator\Service;
use App\Config\ConfigLoader;
final class PlanetGenerator
{
/** @var array<string,mixed> */
private array $config;
public function __construct(ConfigLoader $configLoader)
{
$this->config = $configLoader->planetClasses();
}
/**
* @return array{class_key:string,tier_key:string,modifiers:array<string,float>,score:float,temperature_c:int}
*/
public function generate(string $classKey, string $tierKey, ?int $seed = null): array
{
$classes = $this->config['classes'] ?? [];
if (!isset($classes[$classKey])) {
throw new \RuntimeException("Planetklasse nicht gefunden: {$classKey}");
}
$tiers = $this->config['tiers'] ?? [];
if (!isset($tiers[$tierKey])) {
throw new \RuntimeException("Tier nicht gefunden: {$tierKey}");
}
$resources = $this->config['resources'] ?? [];
$weights = $this->config['weights'] ?? [];
$bounds = $this->config['global_bounds'] ?? ['min' => -1.0, 'max' => 1.0];
$modifiers = [];
foreach ($resources as $res) {
$modifiers[$res] = 0.0;
}
$seed = $seed ?? 0;
$rand = new \Random\Randomizer(new \Random\Engine\Mt19937($seed));
$constraints = $classes[$classKey]['constraints'] ?? [];
foreach ($constraints as $res => $constraint) {
if (!array_key_exists($res, $modifiers) || !is_array($constraint)) {
continue;
}
$min = $constraint['min'] ?? null;
$max = $constraint['max'] ?? null;
if ($min !== null && $max !== null) {
$modifiers[$res] = $this->randomFloat((float)$min, (float)$max, $rand);
} elseif ($min !== null) {
$modifiers[$res] = (float)$min;
} elseif ($max !== null) {
$modifiers[$res] = (float)$max;
}
}
$target = (float)$tiers[$tierKey]['target_score'];
$epsilon = (float)$tiers[$tierKey]['epsilon'];
$score = $this->calculateScore($modifiers);
$delta = $target - $score;
$adjustable = array_diff($resources, array_keys($constraints));
$iterations = 0;
while (abs($delta) > $epsilon && $iterations < 12 && $adjustable) {
$sumWeights = 0.0;
foreach ($adjustable as $res) {
$sumWeights += (float)($weights[$res] ?? 0.0);
}
if ($sumWeights === 0.0) {
break;
}
$achieved = 0.0;
foreach ($adjustable as $res) {
$step = $delta / $sumWeights;
$before = $modifiers[$res];
$after = $this->clamp($before + $step, (float)$bounds['min'], (float)$bounds['max']);
$modifiers[$res] = $after;
$achieved += ((float)$weights[$res] ?? 0.0) * ($after - $before);
}
$delta -= $achieved;
$adjustable = array_values(array_filter($adjustable, function (string $res) use ($modifiers, $bounds): bool {
$min = (float)$bounds['min'];
$max = (float)$bounds['max'];
return $modifiers[$res] > $min && $modifiers[$res] < $max;
}));
$iterations++;
}
$score = $this->calculateScore($modifiers);
$temperatureC = $this->rollTemperature($classes[$classKey]['temperature_range_c'] ?? null, $rand);
return [
'class_key' => $classKey,
'tier_key' => $tierKey,
'modifiers' => $modifiers,
'score' => $score,
'temperature_c' => $temperatureC,
];
}
/**
* @param array<string,float> $modifiers
*/
public function calculateScore(array $modifiers): float
{
$weights = $this->config['weights'] ?? [];
$sum = 0.0;
foreach ($modifiers as $res => $value) {
$sum += ((float)($weights[$res] ?? 0.0)) * (float)$value;
}
return $sum;
}
private function clamp(float $value, float $min, float $max): float
{
return max($min, min($max, $value));
}
private function randomFloat(float $min, float $max, ?\Random\Randomizer $rand): float
{
$int = $rand->getInt(0, 1_000_000);
$ratio = $int / 1_000_000;
return $min + ($max - $min) * $ratio;
}
/**
* @param array<int|float>|null $range
*/
private function rollTemperature($range, \Random\Randomizer $rand): int
{
if (!is_array($range) || count($range) !== 2) {
return 0;
}
[$min, $max] = array_values($range);
$minInt = (int)round((float)$min);
$maxInt = (int)round((float)$max);
if ($minInt > $maxInt) {
return 0;
}
return $rand->getInt($minInt, $maxInt);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Clock;
final class FixedTimeProvider implements TimeProvider
{
private \DateTimeImmutable $now;
public function __construct(\DateTimeImmutable $now)
{
$this->now = $now;
}
public function now(): \DateTimeImmutable
{
return $this->now;
}
public function setNow(\DateTimeImmutable $now): void
{
$this->now = $now;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Shared\Clock;
final class SystemTimeProvider implements TimeProvider
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now');
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Clock;
interface TimeProvider
{
public function now(): \DateTimeImmutable;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Shared\Http;
use Psr\Http\Message\ResponseInterface;
final class JsonResponder
{
/**
* @param array<string,mixed> $data
*/
public static function withJson(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
{
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$response->getBody()->write($payload === false ? '{}' : $payload);
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($status);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Shared\Clock\FixedTimeProvider;
use App\Tests\Support\TestAppFactory;
use App\Tests\Support\TestDatabase;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class BuildStartTest extends TestCase
{
public function testBuildStartAndFinalize(): void
{
$pdo = TestDatabase::create();
TestDatabase::reset($pdo);
$seed = TestDatabase::seedMinimal($pdo);
$userId = (int)$seed['user_id'];
$resources = [
'metal' => 1000.0,
'alloy' => 0.0,
'crystals' => 1000.0,
'energy' => 0.0,
'credits' => 0.0,
'population' => 0.0,
'water' => 0.0,
'deuterium' => 0.0,
'food' => 0.0,
];
$stmt = $pdo->prepare(
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)
RETURNING id'
);
$stmt->execute([
'user_id' => $userId,
'name' => 'Testworld',
'class_key' => 'temperate',
'planet_seed' => 7,
'temperature_c' => 18,
'modifiers' => json_encode(['metal' => 0.0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'last_update' => '2026-02-03 00:00:00',
]);
$planetId = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
VALUES (:planet_id, :building_key, :count, 0)'
);
$stmt->execute(['planet_id' => $planetId, 'building_key' => 'build_center', 'count' => 1]);
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
$app = TestAppFactory::create($pdo, $time);
$factory = new ServerRequestFactory();
$request = $factory->createServerRequest('POST', '/build/start')
->withHeader('Content-Type', 'application/json')
->withHeader('X-User-Id', (string)$userId);
$request->getBody()->write(json_encode([
'building_key' => 'ore_mine',
'amount' => 1,
'planet_id' => $planetId,
]));
$request->getBody()->rewind();
$response = $app->handle($request);
self::assertSame(201, $response->getStatusCode());
$body = json_decode((string)$response->getBody(), true);
self::assertSame(880.0, $body['resources']['metal']);
self::assertSame(940.0, $body['resources']['crystals']);
$time->setNow(new \DateTimeImmutable('2026-02-03 00:02:00'));
$jobsRequest = $factory->createServerRequest('GET', '/build/jobs')
->withHeader('X-User-Id', (string)$userId);
$jobsResponse = $app->handle($jobsRequest);
self::assertSame(200, $jobsResponse->getStatusCode());
$count = (int)$pdo->query("SELECT count FROM planet_buildings WHERE planet_id = {$planetId} AND building_key = 'ore_mine'")->fetchColumn();
self::assertSame(1, $count);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Shared\Clock\FixedTimeProvider;
use App\Tests\Support\TestAppFactory;
use App\Tests\Support\TestDatabase;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
final class PermissionDenyTest extends TestCase
{
public function testDenyOverrideBlocksPermission(): void
{
$pdo = TestDatabase::create();
TestDatabase::reset($pdo);
$seed = TestDatabase::seedMinimal($pdo);
$userId = (int)$seed['user_id'];
$resources = [
'metal' => 100.0,
'alloy' => 0.0,
'crystals' => 50.0,
'energy' => 0.0,
'credits' => 0.0,
'population' => 0.0,
'water' => 0.0,
'deuterium' => 0.0,
'food' => 0.0,
];
$stmt = $pdo->prepare(
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)'
);
$stmt->execute([
'user_id' => $userId,
'name' => 'Denied',
'class_key' => 'temperate',
'planet_seed' => 9,
'temperature_c' => 12,
'modifiers' => json_encode(['metal' => 0.0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'last_update' => '2026-02-03 00:00:00',
]);
$permissionId = (int)$pdo->query("SELECT id FROM permissions WHERE key = 'planet.public.view'")->fetchColumn();
$pdo->exec("INSERT INTO user_permission_overrides (user_id, permission_id, effect) VALUES ({$userId}, {$permissionId}, 'deny')");
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
$app = TestAppFactory::create($pdo, $time);
$factory = new ServerRequestFactory();
$request = $factory->createServerRequest('GET', '/state')
->withHeader('X-User-Id', (string)$userId);
$response = $app->handle($request);
self::assertSame(403, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Bootstrap;
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Shared\Clock\TimeProvider;
use Psr\Container\ContainerInterface;
use Slim\App;
final class TestAppFactory
{
public static function create(\PDO $pdo, TimeProvider $timeProvider): App
{
$repoRoot = dirname(__DIR__, 3);
$container = Bootstrap::buildContainer([
\PDO::class => $pdo,
TimeProvider::class => $timeProvider,
ConfigLoader::class => new ConfigLoader(new ConfigValidator(), $repoRoot . '/config'),
]);
return Bootstrap::createApp($container);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Database\ConnectionFactory;
use PDO;
final class TestDatabase
{
public static function create(): PDO
{
$dbName = getenv('DB_TEST_NAME') ?: (getenv('DB_NAME') ?: 'galaxyforge');
return ConnectionFactory::create($dbName);
}
public static function reset(PDO $pdo): void
{
$tables = [
'user_permission_overrides',
'user_roles',
'role_permissions',
'permissions',
'roles',
'build_jobs',
'planet_buildings',
'planets',
'users',
];
foreach ($tables as $table) {
$pdo->exec("DROP TABLE IF EXISTS {$table} CASCADE");
}
$migration = __DIR__ . '/../../db/migrations/001_init.sql';
$sql = file_get_contents($migration);
if ($sql === false) {
throw new \RuntimeException('Migration nicht lesbar.');
}
$pdo->exec($sql);
}
public static function seedMinimal(PDO $pdo): array
{
$pdo->exec("INSERT INTO roles (key, name) VALUES ('player', 'Spieler')");
$pdo->exec("INSERT INTO roles (key, name) VALUES ('admin', 'Admin')");
$pdo->exec("INSERT INTO permissions (key, module, description) VALUES ('planet.public.view', 'planet', 'Planet ansehen')");
$pdo->exec("INSERT INTO permissions (key, module, description) VALUES ('planet.admin.generate', 'planet', 'Planeten generieren')");
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON r.key = 'player' AND p.key = 'planet.public.view'");
$pdo->exec("INSERT INTO users (username, race_key) VALUES ('tester', 'human')");
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'tester'")->fetchColumn();
$pdo->exec("INSERT INTO user_roles (user_id, role_id)
SELECT {$userId}, r.id FROM roles r WHERE r.key = 'player'");
return ['user_id' => $userId];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Module\Economy\Service\EconomyService;
use PHPUnit\Framework\TestCase;
final class MultiplierTest extends TestCase
{
public function testMultiplierChain(): void
{
$result = EconomyService::multiplyBonuses([0.10, -0.15, 0.10, 0.02]);
self::assertEquals(1.04907, $result, '', 0.0001);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Module\PlanetGenerator\Service\PlanetGenerator;
use PHPUnit\Framework\TestCase;
final class PlanetGeneratorTest extends TestCase
{
public function testIceConstraintsAndScore(): void
{
$loader = new ConfigLoader(new ConfigValidator(), dirname(__DIR__, 3) . '/config');
$generator = new PlanetGenerator($loader);
$result = $generator->generate('ice', 'normal', 1234);
$mods = $result['modifiers'];
self::assertGreaterThanOrEqual(0.5, $mods['water']);
self::assertLessThanOrEqual(-0.6, $mods['energy']);
self::assertGreaterThanOrEqual(-80, $result['temperature_c']);
self::assertLessThanOrEqual(-10, $result['temperature_c']);
$config = $loader->planetClasses();
$target = (float)$config['tiers']['normal']['target_score'];
$epsilon = (float)$config['tiers']['normal']['epsilon'];
$score = $generator->calculateScore($mods);
self::assertEquals($target, $score, '', $epsilon);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use App\Config\ConfigLoader;
use App\Config\ConfigValidator;
use App\Module\Blueprints\Service\BlueprintService;
use App\Module\BuildQueue\Service\BuildQueueService;
use App\Shared\Clock\FixedTimeProvider;
use PHPUnit\Framework\TestCase;
final class QueueSlotsTest extends TestCase
{
public function testQueueSlotsGrowWithBuildCenter(): void
{
$pdo = new \PDO('sqlite::memory:');
$loader = new ConfigLoader(new ConfigValidator(), dirname(__DIR__, 3) . '/config');
$blueprints = new BlueprintService($loader);
$service = new BuildQueueService($pdo, $blueprints, new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00')));
$buildings = [
'build_center' => ['count' => 2, 'level' => 0],
];
$slots = $service->calculateQueueSlots($buildings, 0);
self::assertSame(2, $slots);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$root = dirname(__DIR__, 1);
$repoRoot = dirname($root, 1);
if (file_exists($repoRoot . '/.env')) {
$dotenv = Dotenv\Dotenv::createImmutable($repoRoot);
$dotenv->safeLoad();
}
if (!getenv('APP_ENV')) {
putenv('APP_ENV=test');
}
if (!getenv('DEV_MODE')) {
putenv('DEV_MODE=1');
}

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../../../server/public/index.php';

View File

@@ -118,4 +118,88 @@
if(b.dataset.alertpulse === pulse) b.classList.add("is-selected");
});
});
})();
function formatNumber(val){
const num = Number.isFinite(val) ? val : 0;
return Math.round(num).toLocaleString("de-DE");
}
const resourceMap = {
"Metall": "metal",
"Kristall": "crystals",
"Deuterium": "deuterium",
"Energie": "energy"
};
function updateResourceBar(state){
const stats = document.querySelectorAll(".resource-row .stat");
stats.forEach((stat)=>{
const label = stat.querySelector(".stat-k")?.textContent?.trim();
const key = resourceMap[label];
if(!key) return;
const value = state?.resources?.[key];
if(typeof value !== "number") return;
const valueEl = stat.querySelector(".stat-v");
if(!valueEl) return;
const dot = valueEl.querySelector(".dot");
const display = key === "energy" ? Math.round(value) : Math.floor(value);
const prefix = key === "energy" && display >= 0 ? "+" : "";
valueEl.textContent = "";
if(dot) valueEl.appendChild(dot);
valueEl.appendChild(document.createTextNode(` ${prefix}${formatNumber(display)}`));
});
}
async function fetchState(){
try{
const res = await fetch("/api/state");
if(!res.ok) return null;
return await res.json();
}catch(e){
return null;
}
}
async function refreshState(){
const state = await fetchState();
if(state) updateResourceBar(state);
}
function ensureBuildButton(){
const content = document.getElementById("content");
if(!content || document.getElementById("buildOreMine")) return;
const wrap = document.createElement("div");
wrap.className = "actions";
const btn = document.createElement("button");
btn.className = "btn btn-primary";
btn.id = "buildOreMine";
btn.type = "button";
btn.textContent = "Erzmine bauen";
btn.addEventListener("click", async ()=>{
try{
const res = await fetch("/api/build/start", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({building_key:"ore_mine", amount:1})
});
const data = await res.json();
if(res.ok){
toast("success","Bau gestartet","Erzmine in Queue gelegt");
updateResourceBar({resources: data.resources});
}else{
toast("error","Bau fehlgeschlagen", data.message || "Aktion nicht möglich");
}
}catch(e){
toast("error","Netzwerk","API nicht erreichbar");
}
});
wrap.appendChild(btn);
content.appendChild(wrap);
}
document.addEventListener("DOMContentLoaded", ()=>{
refreshState();
ensureBuildButton();
setInterval(refreshState, 30000);
});
})();

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../../../server/public/index.php';

View File

@@ -118,4 +118,88 @@
if(b.dataset.alertpulse === pulse) b.classList.add("is-selected");
});
});
})();
function formatNumber(val){
const num = Number.isFinite(val) ? val : 0;
return Math.round(num).toLocaleString("de-DE");
}
const resourceMap = {
"Metall": "metal",
"Kristall": "crystals",
"Deuterium": "deuterium",
"Energie": "energy"
};
function updateResourceBar(state){
const stats = document.querySelectorAll(".resource-row .stat");
stats.forEach((stat)=>{
const label = stat.querySelector(".stat-k")?.textContent?.trim();
const key = resourceMap[label];
if(!key) return;
const value = state?.resources?.[key];
if(typeof value !== "number") return;
const valueEl = stat.querySelector(".stat-v");
if(!valueEl) return;
const dot = valueEl.querySelector(".dot");
const display = key === "energy" ? Math.round(value) : Math.floor(value);
const prefix = key === "energy" && display >= 0 ? "+" : "";
valueEl.textContent = "";
if(dot) valueEl.appendChild(dot);
valueEl.appendChild(document.createTextNode(` ${prefix}${formatNumber(display)}`));
});
}
async function fetchState(){
try{
const res = await fetch("/api/state");
if(!res.ok) return null;
return await res.json();
}catch(e){
return null;
}
}
async function refreshState(){
const state = await fetchState();
if(state) updateResourceBar(state);
}
function ensureBuildButton(){
const content = document.getElementById("content");
if(!content || document.getElementById("buildOreMine")) return;
const wrap = document.createElement("div");
wrap.className = "actions";
const btn = document.createElement("button");
btn.className = "btn btn-primary";
btn.id = "buildOreMine";
btn.type = "button";
btn.textContent = "Erzmine bauen";
btn.addEventListener("click", async ()=>{
try{
const res = await fetch("/api/build/start", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({building_key:"ore_mine", amount:1})
});
const data = await res.json();
if(res.ok){
toast("success","Bau gestartet","Erzmine in Queue gelegt");
updateResourceBar({resources: data.resources});
}else{
toast("error","Bau fehlgeschlagen", data.message || "Aktion nicht möglich");
}
}catch(e){
toast("error","Netzwerk","API nicht erreichbar");
}
});
wrap.appendChild(btn);
content.appendChild(wrap);
}
document.addEventListener("DOMContentLoaded", ()=>{
refreshState();
ensureBuildButton();
setInterval(refreshState, 30000);
});
})();