diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0fd6f54 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af630e8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 04e6e38..392bf9c 100644 --- a/AGENTS.md +++ b/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. diff --git a/README.md b/README.md index 852d05d..39bda79 100644 --- a/README.md +++ b/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 -c \"CREATE DATABASE \"` (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`. diff --git a/config/blueprints_buildings.json b/config/blueprints_buildings.json new file mode 100644 index 0000000..6af2a10 --- /dev/null +++ b/config/blueprints_buildings.json @@ -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} + } + ] +} diff --git a/config/planet_classes.json b/config/planet_classes.json new file mode 100644 index 0000000..fd62307 --- /dev/null +++ b/config/planet_classes.json @@ -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] + } + } +} diff --git a/config/races.json b/config/races.json new file mode 100644 index 0000000..e082b6a --- /dev/null +++ b/config/races.json @@ -0,0 +1,22 @@ +{ + "races": { + "human": { + "name": "Mensch", + "modifiers": { + "produce": { + "credits": 0.02 + }, + "consume": {} + } + }, + "robot": { + "name": "Roboter", + "modifiers": { + "produce": { + "metal": 0.05 + }, + "consume": {} + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b5c7158 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/game_rulebook.md b/docs/game_rulebook.md index 19fd7e8..4b2d895 100644 --- a/docs/game_rulebook.md +++ b/docs/game_rulebook.md @@ -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 diff --git a/docs/game_rulebook_v1_2.md b/docs/game_rulebook_v1_2.md new file mode 100644 index 0000000..dce620e --- /dev/null +++ b/docs/game_rulebook_v1_2.md @@ -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). diff --git a/server/composer.json b/server/composer.json new file mode 100644 index 0000000..c6263d0 --- /dev/null +++ b/server/composer.json @@ -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" + } +} diff --git a/server/db/migrate.php b/server/db/migrate.php new file mode 100644 index 0000000..42516f7 --- /dev/null +++ b/server/db/migrate.php @@ -0,0 +1,29 @@ +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; +} diff --git a/server/db/migrations/001_init.sql b/server/db/migrations/001_init.sql new file mode 100644 index 0000000..84c4fab --- /dev/null +++ b/server/db/migrations/001_init.sql @@ -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) +); diff --git a/server/db/seed.php b/server/db/seed.php new file mode 100644 index 0000000..0cb2d19 --- /dev/null +++ b/server/db/seed.php @@ -0,0 +1,109 @@ +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; +} diff --git a/server/phpunit.xml b/server/phpunit.xml new file mode 100644 index 0000000..24a5df8 --- /dev/null +++ b/server/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/server/public/index.php b/server/public/index.php new file mode 100644 index 0000000..477c4ed --- /dev/null +++ b/server/public/index.php @@ -0,0 +1,19 @@ +safeLoad(); +} + +$container = Bootstrap::buildContainer(); +$app = Bootstrap::createApp($container); +$app->run(); diff --git a/server/src/Bootstrap.php b/server/src/Bootstrap.php new file mode 100644 index 0000000..6c43c60 --- /dev/null +++ b/server/src/Bootstrap.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/server/src/Config/ConfigLoader.php b/server/src/Config/ConfigLoader.php new file mode 100644 index 0000000..7f2f59c --- /dev/null +++ b/server/src/Config/ConfigLoader.php @@ -0,0 +1,76 @@ +> */ + private array $cache = []; + + public function __construct(ConfigValidator $validator, ?string $baseDir = null) + { + $this->validator = $validator; + $this->baseDir = $baseDir ?? dirname(__DIR__, 3) . '/config'; + } + + /** + * @return array + */ + public function planetClasses(): array + { + return $this->load('planet_classes.json', function (array $config): void { + $this->validator->validatePlanetClasses($config, 'planet_classes.json'); + }); + } + + /** + * @return array + */ + public function races(): array + { + return $this->load('races.json', function (array $config): void { + $this->validator->validateRaces($config, 'races.json'); + }); + } + + /** + * @return array + */ + public function blueprintsBuildings(): array + { + return $this->load('blueprints_buildings.json', function (array $config): void { + $this->validator->validateBlueprintsBuildings($config, 'blueprints_buildings.json'); + }); + } + + /** + * @param callable(array):void $validate + * @return array + */ + 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; + } +} diff --git a/server/src/Config/ConfigValidationException.php b/server/src/Config/ConfigValidationException.php new file mode 100644 index 0000000..8626c02 --- /dev/null +++ b/server/src/Config/ConfigValidationException.php @@ -0,0 +1,29 @@ +errors = $errors; + $message = "Config-Validation fehlgeschlagen: {$file}\n- " . implode("\n- ", $errors); + parent::__construct($message); + } + + /** + * @return string[] + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/server/src/Config/ConfigValidator.php b/server/src/Config/ConfigValidator.php new file mode 100644 index 0000000..b9a9d49 --- /dev/null +++ b/server/src/Config/ConfigValidator.php @@ -0,0 +1,197 @@ + $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 $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 $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', + ]; + } +} diff --git a/server/src/Database/ConnectionFactory.php b/server/src/Database/ConnectionFactory.php new file mode 100644 index 0000000..ca2b32a --- /dev/null +++ b/server/src/Database/ConnectionFactory.php @@ -0,0 +1,26 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + return $pdo; + } +} diff --git a/server/src/Module/Auth/Middleware/AuthContextMiddleware.php b/server/src/Module/Auth/Middleware/AuthContextMiddleware.php new file mode 100644 index 0000000..8f5734f --- /dev/null +++ b/server/src/Module/Auth/Middleware/AuthContextMiddleware.php @@ -0,0 +1,27 @@ +authService->resolveUser($request); + if ($user !== null) { + $request = $request->withAttribute('user', $user); + } + return $handler->handle($request); + } +} diff --git a/server/src/Module/Auth/Service/AuthService.php b/server/src/Module/Auth/Service/AuthService.php new file mode 100644 index 0000000..ca24c74 --- /dev/null +++ b/server/src/Module/Auth/Service/AuthService.php @@ -0,0 +1,77 @@ +|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|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|null + */ + private function findFirstUser(): ?array + { + $stmt = $this->pdo->query('SELECT * FROM users ORDER BY id ASC LIMIT 1'); + $row = $stmt->fetch(); + return $row ?: null; + } +} diff --git a/server/src/Module/Blueprints/Service/BlueprintService.php b/server/src/Module/Blueprints/Service/BlueprintService.php new file mode 100644 index 0000000..3c5c4e1 --- /dev/null +++ b/server/src/Module/Blueprints/Service/BlueprintService.php @@ -0,0 +1,160 @@ +> */ + private array $buildings; + + public function __construct(ConfigLoader $configLoader) + { + $config = $configLoader->blueprintsBuildings(); + $this->buildings = $config['blueprints'] ?? []; + } + + /** + * @return array> + */ + public function allBuildings(): array + { + return $this->buildings; + } + + /** + * @return array|null + */ + public function getBuilding(string $key): ?array + { + foreach ($this->buildings as $bp) { + if (($bp['key'] ?? null) === $key) { + return $bp; + } + } + return null; + } + + /** + * @param array $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 $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 $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' => []]; + } +} diff --git a/server/src/Module/BuildQueue/Controller/BuildController.php b/server/src/Module/BuildQueue/Controller/BuildController.php new file mode 100644 index 0000000..2e2a5d6 --- /dev/null +++ b/server/src/Module/BuildQueue/Controller/BuildController.php @@ -0,0 +1,191 @@ +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> $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); + } +} diff --git a/server/src/Module/BuildQueue/Routes.php b/server/src/Module/BuildQueue/Routes.php new file mode 100644 index 0000000..04eaeac --- /dev/null +++ b/server/src/Module/BuildQueue/Routes.php @@ -0,0 +1,25 @@ +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')); + } +} diff --git a/server/src/Module/BuildQueue/Service/BuildQueueService.php b/server/src/Module/BuildQueue/Service/BuildQueueService.php new file mode 100644 index 0000000..3683158 --- /dev/null +++ b/server/src/Module/BuildQueue/Service/BuildQueueService.php @@ -0,0 +1,149 @@ +getBuildings($planetId); + return $this->calculateQueueSlots($buildings, $baseSlots); + } + + /** + * @param array $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> + */ + 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> + */ + 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 $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 + */ + 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; + } +} diff --git a/server/src/Module/Economy/Controller/StateController.php b/server/src/Module/Economy/Controller/StateController.php new file mode 100644 index 0000000..7b9a99e --- /dev/null +++ b/server/src/Module/Economy/Controller/StateController.php @@ -0,0 +1,70 @@ + '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'], + ]); + } +} diff --git a/server/src/Module/Economy/Routes.php b/server/src/Module/Economy/Routes.php new file mode 100644 index 0000000..c04a513 --- /dev/null +++ b/server/src/Module/Economy/Routes.php @@ -0,0 +1,24 @@ +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')); + } +} diff --git a/server/src/Module/Economy/Service/EconomyService.php b/server/src/Module/Economy/Service/EconomyService.php new file mode 100644 index 0000000..5a3b1be --- /dev/null +++ b/server/src/Module/Economy/Service/EconomyService.php @@ -0,0 +1,305 @@ +configLoader->planetClasses(); + $this->resources = $planetConfig['resources'] ?? []; + } + + /** + * @return array + */ + 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 + */ + 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,net_rates:array,breakdown:array,caps:array} + */ + 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 $buildings + * @param array $planetModifiers + * @return array{net_rates:array,breakdown:array,caps:array} + */ + 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'; + } +} diff --git a/server/src/Module/Permissions/Middleware/RequirePermission.php b/server/src/Module/Permissions/Middleware/RequirePermission.php new file mode 100644 index 0000000..abf7a7e --- /dev/null +++ b/server/src/Module/Permissions/Middleware/RequirePermission.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/server/src/Module/Permissions/Permissions.php b/server/src/Module/Permissions/Permissions.php new file mode 100644 index 0000000..ccce151 --- /dev/null +++ b/server/src/Module/Permissions/Permissions.php @@ -0,0 +1,37 @@ +> + */ + 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', + ], + ]; + } +} diff --git a/server/src/Module/Permissions/Service/PermissionService.php b/server/src/Module/Permissions/Service/PermissionService.php new file mode 100644 index 0000000..0c08529 --- /dev/null +++ b/server/src/Module/Permissions/Service/PermissionService.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/server/src/Module/PlanetGenerator/Controller/AdminPlanetController.php b/server/src/Module/PlanetGenerator/Controller/AdminPlanetController.php new file mode 100644 index 0000000..e831f6a --- /dev/null +++ b/server/src/Module/PlanetGenerator/Controller/AdminPlanetController.php @@ -0,0 +1,20 @@ + 'ok', + 'message' => 'Stub: Universum generieren (v0.1).' + ]); + } +} diff --git a/server/src/Module/PlanetGenerator/Routes.php b/server/src/Module/PlanetGenerator/Routes.php new file mode 100644 index 0000000..92e3029 --- /dev/null +++ b/server/src/Module/PlanetGenerator/Routes.php @@ -0,0 +1,23 @@ +get(AdminPlanetController::class); + $permissions = $container->get(PermissionService::class); + + $group->post('/admin/universe/generate', [$controller, 'generate']) + ->add(RequirePermission::for($permissions, 'planet.admin.generate')); + } +} diff --git a/server/src/Module/PlanetGenerator/Service/PlanetGenerator.php b/server/src/Module/PlanetGenerator/Service/PlanetGenerator.php new file mode 100644 index 0000000..788cd43 --- /dev/null +++ b/server/src/Module/PlanetGenerator/Service/PlanetGenerator.php @@ -0,0 +1,147 @@ + */ + private array $config; + + public function __construct(ConfigLoader $configLoader) + { + $this->config = $configLoader->planetClasses(); + } + + /** + * @return array{class_key:string,tier_key:string,modifiers:array,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 $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|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); + } +} diff --git a/server/src/Shared/Clock/FixedTimeProvider.php b/server/src/Shared/Clock/FixedTimeProvider.php new file mode 100644 index 0000000..37da2f6 --- /dev/null +++ b/server/src/Shared/Clock/FixedTimeProvider.php @@ -0,0 +1,25 @@ +now = $now; + } + + public function now(): \DateTimeImmutable + { + return $this->now; + } + + public function setNow(\DateTimeImmutable $now): void + { + $this->now = $now; + } +} diff --git a/server/src/Shared/Clock/SystemTimeProvider.php b/server/src/Shared/Clock/SystemTimeProvider.php new file mode 100644 index 0000000..0726241 --- /dev/null +++ b/server/src/Shared/Clock/SystemTimeProvider.php @@ -0,0 +1,13 @@ + $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); + } +} diff --git a/server/tests/Integration/BuildStartTest.php b/server/tests/Integration/BuildStartTest.php new file mode 100644 index 0000000..b466cb0 --- /dev/null +++ b/server/tests/Integration/BuildStartTest.php @@ -0,0 +1,89 @@ + 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); + } +} diff --git a/server/tests/Integration/PermissionDenyTest.php b/server/tests/Integration/PermissionDenyTest.php new file mode 100644 index 0000000..6e07eff --- /dev/null +++ b/server/tests/Integration/PermissionDenyTest.php @@ -0,0 +1,62 @@ + 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()); + } +} diff --git a/server/tests/Support/TestAppFactory.php b/server/tests/Support/TestAppFactory.php new file mode 100644 index 0000000..880bd06 --- /dev/null +++ b/server/tests/Support/TestAppFactory.php @@ -0,0 +1,27 @@ + $pdo, + TimeProvider::class => $timeProvider, + ConfigLoader::class => new ConfigLoader(new ConfigValidator(), $repoRoot . '/config'), + ]); + + return Bootstrap::createApp($container); + } +} diff --git a/server/tests/Support/TestDatabase.php b/server/tests/Support/TestDatabase.php new file mode 100644 index 0000000..1c997fa --- /dev/null +++ b/server/tests/Support/TestDatabase.php @@ -0,0 +1,62 @@ +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]; + } +} diff --git a/server/tests/Unit/MultiplierTest.php b/server/tests/Unit/MultiplierTest.php new file mode 100644 index 0000000..c2767ce --- /dev/null +++ b/server/tests/Unit/MultiplierTest.php @@ -0,0 +1,17 @@ +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); + } +} diff --git a/server/tests/Unit/QueueSlotsTest.php b/server/tests/Unit/QueueSlotsTest.php new file mode 100644 index 0000000..bf245c0 --- /dev/null +++ b/server/tests/Unit/QueueSlotsTest.php @@ -0,0 +1,30 @@ + ['count' => 2, 'level' => 0], + ]; + + $slots = $service->calculateQueueSlots($buildings, 0); + self::assertSame(2, $slots); + } +} diff --git a/server/tests/bootstrap.php b/server/tests/bootstrap.php new file mode 100644 index 0000000..2f5d7f6 --- /dev/null +++ b/server/tests/bootstrap.php @@ -0,0 +1,20 @@ +safeLoad(); +} + +if (!getenv('APP_ENV')) { + putenv('APP_ENV=test'); +} +if (!getenv('DEV_MODE')) { + putenv('DEV_MODE=1'); +} diff --git a/web/desktop/public/api/index.php b/web/desktop/public/api/index.php new file mode 100644 index 0000000..7e72499 --- /dev/null +++ b/web/desktop/public/api/index.php @@ -0,0 +1,5 @@ +{ + 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); + }); +})(); diff --git a/web/mobile/public/api/index.php b/web/mobile/public/api/index.php new file mode 100644 index 0000000..7e72499 --- /dev/null +++ b/web/mobile/public/api/index.php @@ -0,0 +1,5 @@ +{ + 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); + }); +})();