Add repo hygiene rules and ignore secrets

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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