Add repo hygiene rules and ignore secrets
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
36
.gitignore
vendored
Normal 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
|
||||
32
AGENTS.md
32
AGENTS.md
@@ -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 Forge’s 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.
|
||||
|
||||
32
README.md
32
README.md
@@ -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`.
|
||||
|
||||
83
config/blueprints_buildings.json
Normal file
83
config/blueprints_buildings.json
Normal 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}
|
||||
}
|
||||
]
|
||||
}
|
||||
62
config/planet_classes.json
Normal file
62
config/planet_classes.json
Normal 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
22
config/races.json
Normal 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
14
docker-compose.yml
Normal 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:
|
||||
@@ -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
412
docs/game_rulebook_v1_2.md
Normal 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
27
server/composer.json
Normal 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
29
server/db/migrate.php
Normal 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;
|
||||
}
|
||||
72
server/db/migrations/001_init.sql
Normal file
72
server/db/migrations/001_init.sql
Normal 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
109
server/db/seed.php
Normal 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
12
server/phpunit.xml
Normal 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
19
server/public/index.php
Normal 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
73
server/src/Bootstrap.php
Normal 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;
|
||||
}
|
||||
}
|
||||
76
server/src/Config/ConfigLoader.php
Normal file
76
server/src/Config/ConfigLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
server/src/Config/ConfigValidationException.php
Normal file
29
server/src/Config/ConfigValidationException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
197
server/src/Config/ConfigValidator.php
Normal file
197
server/src/Config/ConfigValidator.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
server/src/Database/ConnectionFactory.php
Normal file
26
server/src/Database/ConnectionFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
server/src/Module/Auth/Middleware/AuthContextMiddleware.php
Normal file
27
server/src/Module/Auth/Middleware/AuthContextMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
server/src/Module/Auth/Service/AuthService.php
Normal file
77
server/src/Module/Auth/Service/AuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
160
server/src/Module/Blueprints/Service/BlueprintService.php
Normal file
160
server/src/Module/Blueprints/Service/BlueprintService.php
Normal 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' => []];
|
||||
}
|
||||
}
|
||||
191
server/src/Module/BuildQueue/Controller/BuildController.php
Normal file
191
server/src/Module/BuildQueue/Controller/BuildController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
server/src/Module/BuildQueue/Routes.php
Normal file
25
server/src/Module/BuildQueue/Routes.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
149
server/src/Module/BuildQueue/Service/BuildQueueService.php
Normal file
149
server/src/Module/BuildQueue/Service/BuildQueueService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
server/src/Module/Economy/Controller/StateController.php
Normal file
70
server/src/Module/Economy/Controller/StateController.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
server/src/Module/Economy/Routes.php
Normal file
24
server/src/Module/Economy/Routes.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
305
server/src/Module/Economy/Service/EconomyService.php
Normal file
305
server/src/Module/Economy/Service/EconomyService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
37
server/src/Module/Permissions/Permissions.php
Normal file
37
server/src/Module/Permissions/Permissions.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
server/src/Module/Permissions/Service/PermissionService.php
Normal file
44
server/src/Module/Permissions/Service/PermissionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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).'
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
server/src/Module/PlanetGenerator/Routes.php
Normal file
23
server/src/Module/PlanetGenerator/Routes.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
147
server/src/Module/PlanetGenerator/Service/PlanetGenerator.php
Normal file
147
server/src/Module/PlanetGenerator/Service/PlanetGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
server/src/Shared/Clock/FixedTimeProvider.php
Normal file
25
server/src/Shared/Clock/FixedTimeProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
server/src/Shared/Clock/SystemTimeProvider.php
Normal file
13
server/src/Shared/Clock/SystemTimeProvider.php
Normal 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');
|
||||
}
|
||||
}
|
||||
10
server/src/Shared/Clock/TimeProvider.php
Normal file
10
server/src/Shared/Clock/TimeProvider.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Clock;
|
||||
|
||||
interface TimeProvider
|
||||
{
|
||||
public function now(): \DateTimeImmutable;
|
||||
}
|
||||
22
server/src/Shared/Http/JsonResponder.php
Normal file
22
server/src/Shared/Http/JsonResponder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
89
server/tests/Integration/BuildStartTest.php
Normal file
89
server/tests/Integration/BuildStartTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
server/tests/Integration/PermissionDenyTest.php
Normal file
62
server/tests/Integration/PermissionDenyTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
27
server/tests/Support/TestAppFactory.php
Normal file
27
server/tests/Support/TestAppFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
server/tests/Support/TestDatabase.php
Normal file
62
server/tests/Support/TestDatabase.php
Normal 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];
|
||||
}
|
||||
}
|
||||
17
server/tests/Unit/MultiplierTest.php
Normal file
17
server/tests/Unit/MultiplierTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
server/tests/Unit/PlanetGeneratorTest.php
Normal file
34
server/tests/Unit/PlanetGeneratorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
server/tests/Unit/QueueSlotsTest.php
Normal file
30
server/tests/Unit/QueueSlotsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
20
server/tests/bootstrap.php
Normal file
20
server/tests/bootstrap.php
Normal 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');
|
||||
}
|
||||
5
web/desktop/public/api/index.php
Normal file
5
web/desktop/public/api/index.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../../../server/public/index.php';
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
5
web/mobile/public/api/index.php
Normal file
5
web/mobile/public/api/index.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../../../server/public/index.php';
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user