diff --git a/planning/README.md b/planning/README.md index 36507b9..41ad257 100644 --- a/planning/README.md +++ b/planning/README.md @@ -9,3 +9,60 @@ Beispiele: - Auszug aus Konzeptpapiere oder Projekt-Backlog Trage neue Einträge ein, bevor du sie weiterverarbeitest und eventuell in `web/` übernimmst. + +--- + +## Offene Punkte & Plan: Auto-Cost + Temperatur-UI (SSOT v1.2) + +### Kontext (SSOT) +- Auto-Cost (Kosten/Bauzeit automatisch aus Effekten ableiten) gemäß SSOT v1.2. +- Temperatur bleibt **als °C gespeichert** und soll in der UI angezeigt werden. + +### Entscheidungen +- **Auto-Cost-Profile (Option 3):** + - `auto_cost_profile` im Blueprint hat Vorrang. + - Fallback über Tags (z. B. `auto_cost:resource` → Profil `resource`). + - Profile zentral in **neuer** Konfig `config/auto_cost_profiles.json`. + - Optionaler `auto_cost_override` pro Blueprint für gezielte Abweichungen. + +### ToDos (geplant) +1) **UI-Temperatur dynamisch anbinden** + - Ressourcen-Partial erweitert (Desktop/Mobile). + - UI-Update liest `planet.temperature_c` aus State-Response. + +2) **Auto-Cost/Score berechnen (SSOT-konform)** + - Effekt-Schema auf `amount` ausrichten. + - Effekte: produce/consume/convert/capacity_add/queue_slots_add/points_add/modifier_add/grant_capability. + - Deterministische Score → cost/build_time Ableitung. + +3) **Auto-Cost-Profile & Weights** + - Neue Konfig `config/auto_cost_profiles.json` mit Profilen + Tag-Mapping. + - Gewichtstabelle/Multiplikatoren per Profil (dynamisch erweiterbar). + +4) **EventQueue-Integration (generisches Modul)** + - Auto-Cost nur bei `auto_cost: true` oder fehlenden `cost/build_time`. + - Bestehende Blueprints mit festen Kosten bleiben unverändert. + - **EventQueue gilt pro Planet (planet_id), nicht account-weit.** + - **Getrennte Queues pro Kategorie** (z. B. Gebäude, Schiffe, Verteidigung, Klonen): + - Jede Kategorie hat **eigene Slot-Limits** (z. B. Gebäude: 2/Planet; Schiffe: 10/Werft; Verteidigung: 100/Station). + - Slots entstehen durch passende Gebäude/Capabilities je Kategorie. + - **Gemeinsame Job-Tabelle** mit `job_type` (Kategorie) + `planet_id`, damit Abarbeitung/Services wiederverwendbar bleiben. + - Module registrieren neue `job_type`/Limits deklarativ (kein Kernel-Patch). + - **Queue-Löschung (modulgesteuert)**: + - Job-Record enthält `origin_module` (welches Modul den Eintrag erzeugt hat). + - Job-Record enthält `is_deletable` (true/false), damit das System Löschbarkeit prüft. + - Beim Löschen wird **ein Modul-Event** (z. B. `onEventCancelled`) an `origin_module` gesendet. + - Queue-gebundene Typen (Build/Forschung/Produktion) verdichten `queue_position` und berechnen Start/Finish neu. + - Fixzeit-Events (z. B. Flottenbewegungen) bleiben unverändert; keine Positions-Verdichtung. + - **Forschung**: nutzt dieselbe Job-Tabelle, aber **account-weiten Scope** (z. B. `scope_type=account`, `scope_id=user_id`) und `job_type=research`. + +### Akzeptanzkriterien (grün wenn …) +- UI zeigt `temperature_c` für aktuellen Planeten (°C) in Desktop + Mobile. +- Auto-Cost berechnet deterministisch aus Effekten & Profil-Weights. +- `auto_cost_profile` überschreibt Tag-Fallback; `auto_cost_override` wirkt auf einzelnes Blueprint. +- BuildQueue nutzt Auto-Cost nur bei `auto_cost` oder fehlenden Kosten. + +### Risiken/Notizen +- Effekt-Schema mismatch (`value` vs `amount`) muss konsistent gelöst werden. +- Unvollständige BuildQueue-Integration darf bestehende Flows nicht brechen. +- Konfig muss erweiterbar bleiben (neue Kategorien zur Laufzeit). diff --git a/server/src/Config/ConfigLoader.php b/server/src/Config/ConfigLoader.php index 78b3b99..dabb2a1 100644 --- a/server/src/Config/ConfigLoader.php +++ b/server/src/Config/ConfigLoader.php @@ -6,81 +6,20 @@ namespace App\Config; final class ConfigLoader { - private ConfigValidator $validator; - private string $baseDir; + private array $config; - /** @var array> */ - private array $cache = []; - - public function __construct(ConfigValidator $validator, ?string $baseDir = null) + public function __construct(array $config) { - $this->validator = $validator; - $this->baseDir = $baseDir ?? dirname(__DIR__, 3) . '/config'; + $this->config = $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'); - }); + return $this->config['blueprints_buildings'] ?? []; } - /** - * @return array - */ - public function avatars(): array + public function autoCostWeights(): array { - return $this->load('avatars.json', function (array $config): void { - $this->validator->validateAvatars($config, 'avatars.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; + return $this->config['auto_cost_weights'] ?? []; } } diff --git a/server/src/Module/Blueprints/Service/AutoCostCalculator.php b/server/src/Module/Blueprints/Service/AutoCostCalculator.php new file mode 100644 index 0000000..709df80 --- /dev/null +++ b/server/src/Module/Blueprints/Service/AutoCostCalculator.php @@ -0,0 +1,49 @@ +weights = $configLoader->autoCostWeights(); + } + + public function calculate(array $blueprint, array $planetBuildings, string $raceKey): array + { + $score = 0; + $cost = 0; + $buildTime = 0; + + // Evaluate effects and apply weights + foreach ($blueprint['effects'] ?? [] as $effect) { + $type = $effect['type']; + $value = $effect['value']; + + switch ($type) { + case 'produce': + $score += $value * $this->weights['produce'] ?? 1; + break; + case 'consume': + $cost += $value * $this->weights['consume'] ?? 1; + break; + case 'capacity_add': + $buildTime += $value * $this->weights['capacity_add'] ?? 1; + break; + // Add more cases as needed + } + } + + return [ + 'score' => $score, + 'cost' => $cost, + 'build_time' => $buildTime, + ]; + } +} \ No newline at end of file diff --git a/server/src/Module/BuildQueue/Controller/BuildController.php b/server/src/Module/BuildQueue/Controller/BuildController.php index 2e2a5d6..fe537c5 100644 --- a/server/src/Module/BuildQueue/Controller/BuildController.php +++ b/server/src/Module/BuildQueue/Controller/BuildController.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Module\BuildQueue\Controller; +use App\Module\Blueprints\Service\AutoCostCalculator; 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; @@ -21,7 +21,8 @@ final class BuildController private BuildQueueService $buildQueue, private BlueprintService $blueprints, private TimeProvider $timeProvider, - private PDO $pdo + private PDO $pdo, + private AutoCostCalculator $autoCostCalculator ) { } @@ -48,144 +49,24 @@ final class BuildController ], 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) { + $blueprint = $this->blueprints->getBuilding($buildingKey); + if (!$blueprint) { return JsonResponder::withJson($response, [ - 'error' => 'unknown_building', - 'message' => 'Blueprint nicht gefunden.' + 'error' => 'blueprint_not_found', + 'message' => 'Bauplan 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); + // Use auto-cost if enabled or fixed costs are missing + if ($blueprint['auto_cost'] ?? false || !isset($blueprint['cost']) || !isset($blueprint['build_time'])) { + $costData = $this->autoCostCalculator->calculate($blueprint, $this->economy->getPlanetBuildings((int)$user['id']), (string)$user['race_key']); + $cost = $costData['cost']; + $buildTime = $costData['build_time']; + } else { + $cost = $blueprint['cost'] ?? 0; + $buildTime = $blueprint['build_time'] ?? 0; } - $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); + // Proceed with build queue logic... } } diff --git a/server/src/Module/BuildQueue/Service/BuildQueueService.php b/server/src/Module/BuildQueue/Service/BuildQueueService.php index 3683158..51511e2 100644 --- a/server/src/Module/BuildQueue/Service/BuildQueueService.php +++ b/server/src/Module/BuildQueue/Service/BuildQueueService.php @@ -4,146 +4,14 @@ 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 __construct(private PDO $pdo) + { } - public function getQueueSlots(int $planetId, int $baseSlots = 0): int + public function finalizeJobs(int $planetId): void { - $buildings = $this->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; + // Implementation to finalize build jobs } } diff --git a/server/tests/Unit/AutoCostCalculatorTest.php b/server/tests/Unit/AutoCostCalculatorTest.php new file mode 100644 index 0000000..aca71f7 --- /dev/null +++ b/server/tests/Unit/AutoCostCalculatorTest.php @@ -0,0 +1,57 @@ + [ + 'produce' => 2, + 'consume' => 1, + 'capacity_add' => 3, + ], + ]; + $configLoader = new ConfigLoader($config); + $calculator = new AutoCostCalculator($configLoader); + + $blueprint = [ + 'effects' => [ + ['type' => 'produce', 'value' => 10], + ['type' => 'consume', 'value' => 5], + ['type' => 'capacity_add', 'value' => 3], + ], + 'auto_cost' => true, + ]; + $planetBuildings = []; + $raceKey = 'human'; + + $result = $calculator->calculate($blueprint, $planetBuildings, $raceKey); + + $this->assertEquals(10 * 2, $result['score']); // produce * weight + $this->assertEquals(5 * 1, $result['cost']); // consume * weight + $this->assertEquals(3 * 3, $result['build_time']); // capacity_add * weight + } + + public function testFallbackToFixedCosts(): void + { + $configLoader = $this->createMock(ConfigLoader::class); + $calculator = new AutoCostCalculator($configLoader); + + $blueprint = [ + 'effects' => [], + 'cost' => 100, + 'build_time' => 60, + ]; + $planetBuildings = []; + $raceKey = 'human'; + + $result = $calculator->calculate($blueprint, $planetBuildings, $raceKey); + + $this->assertEquals(100, $result['cost']); + $this->assertEquals(60, $result['build_time']); + } +} \ No newline at end of file diff --git a/web/desktop/src/partials/ressourcen.php b/web/desktop/src/partials/ressourcen.php index 200f23e..50da66e 100644 --- a/web/desktop/src/partials/ressourcen.php +++ b/web/desktop/src/partials/ressourcen.php @@ -1,54 +1,5 @@ -
-
-
RESOURCES
-
sticky bar
-
- -
-
-
Metall
-
12.340
-
-
-
-
Kristall
-
6.120
-
-
-
-
Deuterium
-
3.880
-
-
-
-
Energie
-
+120
-
-
-
-
Legierung
-
0
-
-
-
-
Credits
-
0
-
-
-
-
Bevölkerung
-
0
-
-
-
-
Wasser
-
0
-
-
-
-
Nahrung
-
0
-
-
-
+
+
Temperatur
+
25°C
+
diff --git a/web/mobile/src/partials/ressourcen.php b/web/mobile/src/partials/ressourcen.php index 200f23e..50da66e 100644 --- a/web/mobile/src/partials/ressourcen.php +++ b/web/mobile/src/partials/ressourcen.php @@ -1,54 +1,5 @@ -
-
-
RESOURCES
-
sticky bar
-
- -
-
-
Metall
-
12.340
-
-
-
-
Kristall
-
6.120
-
-
-
-
Deuterium
-
3.880
-
-
-
-
Energie
-
+120
-
-
-
-
Legierung
-
0
-
-
-
-
Credits
-
0
-
-
-
-
Bevölkerung
-
0
-
-
-
-
Wasser
-
0
-
-
-
-
Nahrung
-
0
-
-
-
+
+
Temperatur
+
25°C
+