Add repo hygiene rules and ignore secrets
This commit is contained in:
27
server/composer.json
Normal file
27
server/composer.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "galaxy-forge/server",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"slim/slim": "^4.12",
|
||||
"slim/psr7": "^1.6",
|
||||
"php-di/php-di": "^7.0",
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
}
|
||||
}
|
||||
29
server/db/migrate.php
Normal file
29
server/db/migrate.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database\ConnectionFactory;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
if (file_exists($repoRoot . '/.env')) {
|
||||
$dotenv = Dotenv::createImmutable($repoRoot);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
$pdo = ConnectionFactory::create();
|
||||
|
||||
$migrationsDir = __DIR__ . '/migrations';
|
||||
$files = glob($migrationsDir . '/*.sql');
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException("Migration nicht lesbar: {$file}");
|
||||
}
|
||||
$pdo->exec($sql);
|
||||
echo "OK: " . basename($file) . PHP_EOL;
|
||||
}
|
||||
72
server/db/migrations/001_init.sql
Normal file
72
server/db/migrations/001_init.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
race_key TEXT NOT NULL DEFAULT 'human',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
class_key TEXT NOT NULL,
|
||||
planet_seed INT NOT NULL DEFAULT 0,
|
||||
temperature_c INT NOT NULL DEFAULT 0,
|
||||
modifiers TEXT NOT NULL,
|
||||
resources TEXT NOT NULL,
|
||||
last_resource_update_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planet_buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
planet_id INT NOT NULL REFERENCES planets(id) ON DELETE CASCADE,
|
||||
building_key TEXT NOT NULL,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
level INT NOT NULL DEFAULT 0,
|
||||
UNIQUE (planet_id, building_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS build_jobs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
planet_id INT NOT NULL REFERENCES planets(id) ON DELETE CASCADE,
|
||||
building_key TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
delta_count INT,
|
||||
target_level INT,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finish_at TIMESTAMP NOT NULL,
|
||||
slot_index INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
module TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id INT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permission_overrides (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_id INT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
PRIMARY KEY (user_id, permission_id)
|
||||
);
|
||||
109
server/db/seed.php
Normal file
109
server/db/seed.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Config\ConfigValidator;
|
||||
use App\Database\ConnectionFactory;
|
||||
use App\Module\Permissions\Permissions;
|
||||
use App\Module\PlanetGenerator\Service\PlanetGenerator;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$repoRoot = dirname(__DIR__, 2);
|
||||
if (file_exists($repoRoot . '/.env')) {
|
||||
$dotenv = Dotenv::createImmutable($repoRoot);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
$pdo = ConnectionFactory::create();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO roles (key, name) VALUES (:key, :name) ON CONFLICT (key) DO NOTHING");
|
||||
$stmt->execute(['key' => 'player', 'name' => 'Spieler']);
|
||||
$stmt->execute(['key' => 'admin', 'name' => 'Admin']);
|
||||
|
||||
$permStmt = $pdo->prepare(
|
||||
'INSERT INTO permissions (key, module, description) VALUES (:key, :module, :description)
|
||||
ON CONFLICT (key) DO NOTHING'
|
||||
);
|
||||
foreach (Permissions::definitions() as $perm) {
|
||||
$permStmt->execute($perm);
|
||||
}
|
||||
|
||||
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.key = 'planet.public.view'
|
||||
WHERE r.key = 'player'
|
||||
ON CONFLICT DO NOTHING");
|
||||
|
||||
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.key = 'planet.admin.generate'
|
||||
WHERE r.key = 'admin'
|
||||
ON CONFLICT DO NOTHING");
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO users (username, race_key) VALUES (:username, :race_key) ON CONFLICT (username) DO NOTHING");
|
||||
$stmt->execute(['username' => 'dev', 'race_key' => 'human']);
|
||||
|
||||
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'dev'")->fetchColumn();
|
||||
|
||||
$pdo->exec("INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT {$userId}, r.id
|
||||
FROM roles r
|
||||
WHERE r.key = 'player'
|
||||
ON CONFLICT DO NOTHING");
|
||||
|
||||
$planetExists = $pdo->prepare('SELECT id FROM planets WHERE user_id = :user_id LIMIT 1');
|
||||
$planetExists->execute(['user_id' => $userId]);
|
||||
$planetId = $planetExists->fetchColumn();
|
||||
|
||||
if (!$planetId) {
|
||||
$configLoader = new ConfigLoader(new ConfigValidator(), $repoRoot . '/config');
|
||||
$generator = new PlanetGenerator($configLoader);
|
||||
$generated = $generator->generate('temperate', 'normal', 42);
|
||||
|
||||
$resources = [];
|
||||
foreach ($configLoader->planetClasses()['resources'] as $res) {
|
||||
$resources[$res] = 500.0;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
|
||||
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'user_id' => $userId,
|
||||
'name' => 'Earth Prime',
|
||||
'class_key' => $generated['class_key'],
|
||||
'planet_seed' => 42,
|
||||
'temperature_c' => (int)$generated['temperature_c'],
|
||||
'modifiers' => json_encode($generated['modifiers'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'last_update' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$planetId = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
|
||||
VALUES (:planet_id, :building_key, :count, 0)
|
||||
ON CONFLICT (planet_id, building_key) DO NOTHING'
|
||||
);
|
||||
$stmt->execute([
|
||||
'planet_id' => $planetId,
|
||||
'building_key' => 'build_center',
|
||||
'count' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo "Seed abgeschlossen.\n";
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
12
server/phpunit.xml
Normal file
12
server/phpunit.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="GalaxyForge">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
19
server/public/index.php
Normal file
19
server/public/index.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Bootstrap;
|
||||
use Dotenv\Dotenv;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$repoRoot = dirname(__DIR__, 1);
|
||||
$repoRoot = dirname($repoRoot, 1);
|
||||
if (file_exists($repoRoot . '/.env')) {
|
||||
$dotenv = Dotenv::createImmutable($repoRoot);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
$container = Bootstrap::buildContainer();
|
||||
$app = Bootstrap::createApp($container);
|
||||
$app->run();
|
||||
73
server/src/Bootstrap.php
Normal file
73
server/src/Bootstrap.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Config\ConfigValidator;
|
||||
use App\Database\ConnectionFactory;
|
||||
use App\Module\Auth\Middleware\AuthContextMiddleware;
|
||||
use App\Module\Auth\Service\AuthService;
|
||||
use App\Module\BuildQueue\Routes as BuildQueueRoutes;
|
||||
use App\Module\Economy\Routes as EconomyRoutes;
|
||||
use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes;
|
||||
use App\Shared\Clock\SystemTimeProvider;
|
||||
use App\Shared\Clock\TimeProvider;
|
||||
use DI\ContainerBuilder;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Interfaces\RouteCollectorProxyInterface;
|
||||
|
||||
final class Bootstrap
|
||||
{
|
||||
public static function buildContainer(array $overrides = []): ContainerInterface
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
$builder->addDefinitions([
|
||||
\PDO::class => function () {
|
||||
return ConnectionFactory::create();
|
||||
},
|
||||
TimeProvider::class => function () {
|
||||
return new SystemTimeProvider();
|
||||
},
|
||||
ConfigValidator::class => function () {
|
||||
return new ConfigValidator();
|
||||
},
|
||||
ConfigLoader::class => function (ConfigValidator $validator) {
|
||||
return new ConfigLoader($validator);
|
||||
},
|
||||
]);
|
||||
|
||||
if ($overrides) {
|
||||
$builder->addDefinitions($overrides);
|
||||
}
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
public static function createApp(ContainerInterface $container): App
|
||||
{
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
|
||||
$basePath = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/');
|
||||
if ($basePath !== '' && $basePath !== '.') {
|
||||
$app->setBasePath($basePath);
|
||||
}
|
||||
|
||||
$app->addBodyParsingMiddleware();
|
||||
|
||||
$app->group('', function (RouteCollectorProxyInterface $group) use ($container) {
|
||||
EconomyRoutes::register($group, $container);
|
||||
BuildQueueRoutes::register($group, $container);
|
||||
PlanetGeneratorRoutes::register($group, $container);
|
||||
})->add(new AuthContextMiddleware($container->get(AuthService::class)));
|
||||
|
||||
$displayError = (getenv('APP_ENV') ?: 'dev') !== 'prod';
|
||||
$app->addErrorMiddleware($displayError, true, true);
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
76
server/src/Config/ConfigLoader.php
Normal file
76
server/src/Config/ConfigLoader.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
final class ConfigLoader
|
||||
{
|
||||
private ConfigValidator $validator;
|
||||
private string $baseDir;
|
||||
|
||||
/** @var array<string,array<string,mixed>> */
|
||||
private array $cache = [];
|
||||
|
||||
public function __construct(ConfigValidator $validator, ?string $baseDir = null)
|
||||
{
|
||||
$this->validator = $validator;
|
||||
$this->baseDir = $baseDir ?? dirname(__DIR__, 3) . '/config';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function planetClasses(): array
|
||||
{
|
||||
return $this->load('planet_classes.json', function (array $config): void {
|
||||
$this->validator->validatePlanetClasses($config, 'planet_classes.json');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function races(): array
|
||||
{
|
||||
return $this->load('races.json', function (array $config): void {
|
||||
$this->validator->validateRaces($config, 'races.json');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function blueprintsBuildings(): array
|
||||
{
|
||||
return $this->load('blueprints_buildings.json', function (array $config): void {
|
||||
$this->validator->validateBlueprintsBuildings($config, 'blueprints_buildings.json');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array<string,mixed>):void $validate
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function load(string $file, callable $validate): array
|
||||
{
|
||||
if (isset($this->cache[$file])) {
|
||||
return $this->cache[$file];
|
||||
}
|
||||
$path = rtrim($this->baseDir, '/') . '/' . $file;
|
||||
if (!file_exists($path)) {
|
||||
throw new \RuntimeException("Config-Datei nicht gefunden: {$path}");
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
throw new \RuntimeException("Config-Datei konnte nicht gelesen werden: {$path}");
|
||||
}
|
||||
$data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException("Config-Datei muss ein JSON-Objekt sein: {$path}");
|
||||
}
|
||||
$validate($data);
|
||||
$this->cache[$file] = $data;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
29
server/src/Config/ConfigValidationException.php
Normal file
29
server/src/Config/ConfigValidationException.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
final class ConfigValidationException extends \RuntimeException
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $errors;
|
||||
|
||||
/**
|
||||
* @param string[] $errors
|
||||
*/
|
||||
public function __construct(string $file, array $errors)
|
||||
{
|
||||
$this->errors = $errors;
|
||||
$message = "Config-Validation fehlgeschlagen: {$file}\n- " . implode("\n- ", $errors);
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
}
|
||||
197
server/src/Config/ConfigValidator.php
Normal file
197
server/src/Config/ConfigValidator.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
final class ConfigValidator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
public function validatePlanetClasses(array $config, string $file): void
|
||||
{
|
||||
$errors = [];
|
||||
foreach (['resources', 'weights', 'tiers', 'classes', 'global_bounds'] as $key) {
|
||||
if (!array_key_exists($key, $config)) {
|
||||
$errors[] = "Fehlender Key '{$key}'";
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['resources']) && (!is_array($config['resources']) || $config['resources'] === [])) {
|
||||
$errors[] = "'resources' muss ein nicht-leeres Array sein";
|
||||
}
|
||||
|
||||
$expected = $this->expectedResources();
|
||||
if (isset($config['resources']) && is_array($config['resources'])) {
|
||||
$missing = array_diff($expected, $config['resources']);
|
||||
if ($missing) {
|
||||
$errors[] = "'resources' fehlt: " . implode(', ', $missing);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['weights']) && !is_array($config['weights'])) {
|
||||
$errors[] = "'weights' muss ein Objekt sein";
|
||||
}
|
||||
|
||||
if (isset($config['weights']) && is_array($config['weights'])) {
|
||||
foreach ($expected as $res) {
|
||||
if (!isset($config['weights'][$res]) || !is_numeric($config['weights'][$res])) {
|
||||
$errors[] = "'weights.{$res}' muss numerisch sein";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['global_bounds']) && is_array($config['global_bounds'])) {
|
||||
if (!isset($config['global_bounds']['min']) || !is_numeric($config['global_bounds']['min'])) {
|
||||
$errors[] = "'global_bounds.min' muss numerisch sein";
|
||||
}
|
||||
if (!isset($config['global_bounds']['max']) || !is_numeric($config['global_bounds']['max'])) {
|
||||
$errors[] = "'global_bounds.max' muss numerisch sein";
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['tiers']) && is_array($config['tiers'])) {
|
||||
foreach ($config['tiers'] as $tierKey => $tier) {
|
||||
if (!is_array($tier)) {
|
||||
$errors[] = "Tier '{$tierKey}' muss ein Objekt sein";
|
||||
continue;
|
||||
}
|
||||
if (!isset($tier['target_score']) || !is_numeric($tier['target_score'])) {
|
||||
$errors[] = "Tier '{$tierKey}': 'target_score' fehlt oder ist nicht numerisch";
|
||||
}
|
||||
if (!isset($tier['epsilon']) || !is_numeric($tier['epsilon'])) {
|
||||
$errors[] = "Tier '{$tierKey}': 'epsilon' fehlt oder ist nicht numerisch";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($config['classes']) && is_array($config['classes'])) {
|
||||
foreach ($config['classes'] as $classKey => $class) {
|
||||
if (!is_array($class)) {
|
||||
$errors[] = "Klasse '{$classKey}' muss ein Objekt sein";
|
||||
continue;
|
||||
}
|
||||
if (!isset($class['spawn_weight']) || !is_numeric($class['spawn_weight'])) {
|
||||
$errors[] = "Klasse '{$classKey}': 'spawn_weight' fehlt oder ist nicht numerisch";
|
||||
}
|
||||
if (isset($class['constraints']) && !is_array($class['constraints'])) {
|
||||
$errors[] = "Klasse '{$classKey}': 'constraints' muss ein Objekt sein";
|
||||
}
|
||||
if (isset($class['constraints']) && is_array($class['constraints'])) {
|
||||
foreach ($class['constraints'] as $res => $constraint) {
|
||||
if (!is_array($constraint)) {
|
||||
$errors[] = "Constraint '{$classKey}.{$res}' muss ein Objekt sein";
|
||||
continue;
|
||||
}
|
||||
if (isset($constraint['min']) && !is_numeric($constraint['min'])) {
|
||||
$errors[] = "Constraint '{$classKey}.{$res}.min' muss numerisch sein";
|
||||
}
|
||||
if (isset($constraint['max']) && !is_numeric($constraint['max'])) {
|
||||
$errors[] = "Constraint '{$classKey}.{$res}.max' muss numerisch sein";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($class['temperature_range_c'])) {
|
||||
$range = $class['temperature_range_c'];
|
||||
if (!is_array($range) || count($range) !== 2) {
|
||||
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' muss ein Array [min,max] sein";
|
||||
} else {
|
||||
[$min, $max] = array_values($range);
|
||||
if (!is_numeric($min) || !is_numeric($max)) {
|
||||
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' Werte müssen numerisch sein";
|
||||
} elseif ((float)$min > (float)$max) {
|
||||
$errors[] = "Klasse '{$classKey}': 'temperature_range_c' min darf nicht größer als max sein";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
throw new ConfigValidationException($file, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
public function validateRaces(array $config, string $file): void
|
||||
{
|
||||
$errors = [];
|
||||
if (!isset($config['races']) || !is_array($config['races'])) {
|
||||
$errors[] = "'races' muss ein Objekt sein";
|
||||
}
|
||||
if (isset($config['races']) && is_array($config['races'])) {
|
||||
foreach ($config['races'] as $raceKey => $race) {
|
||||
if (!is_array($race)) {
|
||||
$errors[] = "Race '{$raceKey}' muss ein Objekt sein";
|
||||
continue;
|
||||
}
|
||||
if (empty($race['name'])) {
|
||||
$errors[] = "Race '{$raceKey}': 'name' fehlt";
|
||||
}
|
||||
if (isset($race['modifiers']) && !is_array($race['modifiers'])) {
|
||||
$errors[] = "Race '{$raceKey}': 'modifiers' muss ein Objekt sein";
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
throw new ConfigValidationException($file, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
public function validateBlueprintsBuildings(array $config, string $file): void
|
||||
{
|
||||
$errors = [];
|
||||
if (!isset($config['blueprints']) || !is_array($config['blueprints'])) {
|
||||
$errors[] = "'blueprints' muss ein Array sein";
|
||||
}
|
||||
if (isset($config['blueprints']) && is_array($config['blueprints'])) {
|
||||
foreach ($config['blueprints'] as $idx => $bp) {
|
||||
if (!is_array($bp)) {
|
||||
$errors[] = "Blueprint #{$idx} muss ein Objekt sein";
|
||||
continue;
|
||||
}
|
||||
foreach (['kind', 'key', 'name'] as $req) {
|
||||
if (empty($bp[$req])) {
|
||||
$errors[] = "Blueprint #{$idx}: '{$req}' fehlt";
|
||||
}
|
||||
}
|
||||
if (isset($bp['effects']) && !is_array($bp['effects'])) {
|
||||
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'effects' muss ein Array sein";
|
||||
}
|
||||
if (isset($bp['requirements']) && !is_array($bp['requirements'])) {
|
||||
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'requirements' muss ein Array sein";
|
||||
}
|
||||
if (isset($bp['access']) && !is_array($bp['access'])) {
|
||||
$errors[] = "Blueprint '{$bp['key'] ?? $idx}': 'access' muss ein Objekt sein";
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
throw new ConfigValidationException($file, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function expectedResources(): array
|
||||
{
|
||||
return [
|
||||
'metal',
|
||||
'alloy',
|
||||
'crystals',
|
||||
'energy',
|
||||
'credits',
|
||||
'population',
|
||||
'water',
|
||||
'deuterium',
|
||||
'food',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
server/src/Database/ConnectionFactory.php
Normal file
26
server/src/Database/ConnectionFactory.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function create(?string $dbName = null): PDO
|
||||
{
|
||||
$host = getenv('DB_HOST') ?: '127.0.0.1';
|
||||
$port = getenv('DB_PORT') ?: '5432';
|
||||
$name = $dbName ?? (getenv('DB_NAME') ?: 'galaxyforge');
|
||||
$user = getenv('DB_USER') ?: 'galaxyforge';
|
||||
$pass = getenv('DB_PASS') ?: 'galaxyforge';
|
||||
|
||||
$dsn = "pgsql:host={$host};port={$port};dbname={$name}";
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
return $pdo;
|
||||
}
|
||||
}
|
||||
27
server/src/Module/Auth/Middleware/AuthContextMiddleware.php
Normal file
27
server/src/Module/Auth/Middleware/AuthContextMiddleware.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Auth\Middleware;
|
||||
|
||||
use App\Module\Auth\Service\AuthService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
final class AuthContextMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private AuthService $authService)
|
||||
{
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$user = $this->authService->resolveUser($request);
|
||||
if ($user !== null) {
|
||||
$request = $request->withAttribute('user', $user);
|
||||
}
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
77
server/src/Module/Auth/Service/AuthService.php
Normal file
77
server/src/Module/Auth/Service/AuthService.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Auth\Service;
|
||||
|
||||
use PDO;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AuthService
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function resolveUser(ServerRequestInterface $request): ?array
|
||||
{
|
||||
$id = $this->extractUserId($request);
|
||||
if ($id !== null) {
|
||||
return $this->findUserById($id);
|
||||
}
|
||||
|
||||
if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
|
||||
$devUserId = getenv('DEV_USER_ID');
|
||||
if ($devUserId !== false && is_numeric($devUserId)) {
|
||||
$user = $this->findUserById((int)$devUserId);
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
return $this->findFirstUser();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractUserId(ServerRequestInterface $request): ?int
|
||||
{
|
||||
$header = $request->getHeaderLine('X-User-Id');
|
||||
if ($header !== '' && is_numeric($header)) {
|
||||
return (int)$header;
|
||||
}
|
||||
$header = $request->getHeaderLine('X-Dev-User');
|
||||
if ($header !== '' && is_numeric($header)) {
|
||||
return (int)$header;
|
||||
}
|
||||
$query = $request->getQueryParams();
|
||||
if (isset($query['dev_user']) && is_numeric($query['dev_user'])) {
|
||||
return (int)$query['dev_user'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function findUserById(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function findFirstUser(): ?array
|
||||
{
|
||||
$stmt = $this->pdo->query('SELECT * FROM users ORDER BY id ASC LIMIT 1');
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
}
|
||||
160
server/src/Module/Blueprints/Service/BlueprintService.php
Normal file
160
server/src/Module/Blueprints/Service/BlueprintService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Blueprints\Service;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
|
||||
final class BlueprintService
|
||||
{
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private array $buildings;
|
||||
|
||||
public function __construct(ConfigLoader $configLoader)
|
||||
{
|
||||
$config = $configLoader->blueprintsBuildings();
|
||||
$this->buildings = $config['blueprints'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function allBuildings(): array
|
||||
{
|
||||
return $this->buildings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getBuilding(string $key): ?array
|
||||
{
|
||||
foreach ($this->buildings as $bp) {
|
||||
if (($bp['key'] ?? null) === $key) {
|
||||
return $bp;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,level:int}> $planetBuildings
|
||||
* @return array{ok:bool,errors:string[]}
|
||||
*/
|
||||
public function checkRequirements(array $blueprint, array $planetBuildings, string $raceKey): array
|
||||
{
|
||||
$errors = [];
|
||||
$requirements = $blueprint['requirements'] ?? [];
|
||||
if (!is_array($requirements)) {
|
||||
return ['ok' => false, 'errors' => ['Requirements-Format ungültig.']];
|
||||
}
|
||||
|
||||
foreach ($requirements as $req) {
|
||||
if (!is_array($req) || empty($req['type'])) {
|
||||
$errors[] = 'Requirement ohne Typ.';
|
||||
continue;
|
||||
}
|
||||
switch ($req['type']) {
|
||||
case 'building_count':
|
||||
$key = (string)($req['key'] ?? '');
|
||||
$min = (int)($req['min'] ?? 0);
|
||||
$count = $planetBuildings[$key]['count'] ?? 0;
|
||||
if ($count < $min) {
|
||||
$errors[] = "Benötigt {$min}x {$key}.";
|
||||
}
|
||||
break;
|
||||
case 'building_tag_count':
|
||||
$tag = (string)($req['tag'] ?? '');
|
||||
$min = (int)($req['min'] ?? 0);
|
||||
$count = $this->countBuildingsByTag($planetBuildings, $tag);
|
||||
if ($count < $min) {
|
||||
$errors[] = "Benötigt {$min} Gebäude mit Tag {$tag}.";
|
||||
}
|
||||
break;
|
||||
case 'has_capability':
|
||||
$cap = (string)($req['capability'] ?? '');
|
||||
if (!$this->hasCapability($planetBuildings, $cap)) {
|
||||
$errors[] = "Capability fehlt: {$cap}.";
|
||||
}
|
||||
break;
|
||||
case 'player_race_in':
|
||||
$allowed = $req['races'] ?? [];
|
||||
if (is_array($allowed) && !in_array($raceKey, $allowed, true)) {
|
||||
$errors[] = "Race {$raceKey} ist nicht erlaubt.";
|
||||
}
|
||||
break;
|
||||
case 'player_race_not_in':
|
||||
$blocked = $req['races'] ?? [];
|
||||
if (is_array($blocked) && in_array($raceKey, $blocked, true)) {
|
||||
$errors[] = "Race {$raceKey} ist ausgeschlossen.";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$errors[] = "Requirement-Typ unbekannt: {$req['type']}";
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,level:int}> $planetBuildings
|
||||
*/
|
||||
private function countBuildingsByTag(array $planetBuildings, string $tag): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($planetBuildings as $key => $data) {
|
||||
$bp = $this->getBuilding($key);
|
||||
if (!$bp) {
|
||||
continue;
|
||||
}
|
||||
$tags = $bp['tags'] ?? [];
|
||||
if (is_array($tags) && in_array($tag, $tags, true)) {
|
||||
$count += (int)$data['count'];
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,level:int}> $planetBuildings
|
||||
*/
|
||||
private function hasCapability(array $planetBuildings, string $capability): bool
|
||||
{
|
||||
foreach ($planetBuildings as $key => $data) {
|
||||
if (($data['count'] ?? 0) < 1) {
|
||||
continue;
|
||||
}
|
||||
$bp = $this->getBuilding($key);
|
||||
if (!$bp) {
|
||||
continue;
|
||||
}
|
||||
$caps = $bp['capabilities'] ?? [];
|
||||
if (is_array($caps) && in_array($capability, $caps, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,errors:string[]}
|
||||
*/
|
||||
public function checkAccess(array $blueprint, string $raceKey): array
|
||||
{
|
||||
$access = $blueprint['access'] ?? [];
|
||||
if (!is_array($access)) {
|
||||
return ['ok' => false, 'errors' => ['Access-Format ungültig.']];
|
||||
}
|
||||
$allowed = $access['allowed_races'] ?? [];
|
||||
if (is_array($allowed) && $allowed !== [] && !in_array($raceKey, $allowed, true)) {
|
||||
return ['ok' => false, 'errors' => ['Race ist nicht erlaubt.']];
|
||||
}
|
||||
$blocked = $access['blocked_races'] ?? [];
|
||||
if (is_array($blocked) && in_array($raceKey, $blocked, true)) {
|
||||
return ['ok' => false, 'errors' => ['Race ist blockiert.']];
|
||||
}
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
}
|
||||
191
server/src/Module/BuildQueue/Controller/BuildController.php
Normal file
191
server/src/Module/BuildQueue/Controller/BuildController.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\BuildQueue\Controller;
|
||||
|
||||
use App\Module\Blueprints\Service\BlueprintService;
|
||||
use App\Module\BuildQueue\Service\BuildQueueService;
|
||||
use App\Module\Economy\Service\EconomyService;
|
||||
use App\Shared\Clock\TimeProvider;
|
||||
use App\Shared\Http\JsonResponder;
|
||||
use PDO;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
final class BuildController
|
||||
{
|
||||
public function __construct(
|
||||
private EconomyService $economy,
|
||||
private BuildQueueService $buildQueue,
|
||||
private BlueprintService $blueprints,
|
||||
private TimeProvider $timeProvider,
|
||||
private PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
public function start(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
if (!is_array($user) || !isset($user['id'])) {
|
||||
return JsonResponder::withJson(new Response(), [
|
||||
'error' => 'auth_required',
|
||||
'message' => 'Authentifizierung erforderlich.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$body = $request->getParsedBody();
|
||||
if (!is_array($body)) {
|
||||
$body = [];
|
||||
}
|
||||
$buildingKey = (string)($body['building_key'] ?? '');
|
||||
$amount = (int)($body['amount'] ?? 1);
|
||||
if ($buildingKey === '' || $amount < 1) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'invalid_input',
|
||||
'message' => 'building_key und amount sind Pflichtfelder.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$planetId = isset($body['planet_id']) ? (int)$body['planet_id'] : null;
|
||||
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
|
||||
|
||||
$state = $this->economy->updateResources((int)$planet['id']);
|
||||
$this->buildQueue->finalizeJobs((int)$planet['id']);
|
||||
|
||||
$bp = $this->blueprints->getBuilding($buildingKey);
|
||||
if (!$bp) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'unknown_building',
|
||||
'message' => 'Blueprint nicht gefunden.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$raceKey = (string)$user['race_key'];
|
||||
$access = $this->blueprints->checkAccess($bp, $raceKey);
|
||||
if (!$access['ok']) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'access_denied',
|
||||
'message' => implode(' ', $access['errors'])
|
||||
], 403);
|
||||
}
|
||||
|
||||
$buildings = $this->economy->getPlanetBuildings((int)$planet['id']);
|
||||
$reqCheck = $this->blueprints->checkRequirements($bp, $buildings, $raceKey);
|
||||
if (!$reqCheck['ok']) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'requirements_failed',
|
||||
'message' => implode(' ', $reqCheck['errors'])
|
||||
], 422);
|
||||
}
|
||||
|
||||
$queueSlots = $this->buildQueue->getQueueSlots((int)$planet['id'], 0);
|
||||
if ($queueSlots <= 0) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'no_queue_slots',
|
||||
'message' => 'Keine Bauzentren vorhanden.'
|
||||
], 409);
|
||||
}
|
||||
$activeJobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
|
||||
if (count($activeJobs) >= $queueSlots) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'no_queue_slots',
|
||||
'message' => 'Keine freien Bauplätze verfügbar.'
|
||||
], 409);
|
||||
}
|
||||
|
||||
$cost = $bp['cost'] ?? [];
|
||||
if (!is_array($cost)) {
|
||||
$cost = [];
|
||||
}
|
||||
$resources = $state['resources'];
|
||||
$totalCost = [];
|
||||
foreach ($cost as $res => $val) {
|
||||
$totalCost[$res] = (float)$val * $amount;
|
||||
if (($resources[$res] ?? 0.0) < $totalCost[$res]) {
|
||||
return JsonResponder::withJson($response, [
|
||||
'error' => 'insufficient_resources',
|
||||
'message' => "Zu wenig {$res}."
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($totalCost as $res => $val) {
|
||||
$resources[$res] -= $val;
|
||||
}
|
||||
$stmt = $this->pdo->prepare('UPDATE planets SET resources = :resources WHERE id = :id');
|
||||
$stmt->execute([
|
||||
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'id' => (int)$planet['id'],
|
||||
]);
|
||||
|
||||
$model = (string)($bp['model'] ?? 'stackable');
|
||||
$buildTime = (int)($bp['build_time'] ?? 60) * $amount;
|
||||
$now = $this->timeProvider->now();
|
||||
$finishAt = $now->modify('+' . $buildTime . ' seconds');
|
||||
$slotIndex = $this->nextSlotIndex($activeJobs, $queueSlots);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO build_jobs (planet_id, building_key, mode, delta_count, target_level, started_at, finish_at, slot_index)
|
||||
VALUES (:planet_id, :building_key, :mode, :delta_count, :target_level, :started_at, :finish_at, :slot_index)
|
||||
RETURNING *'
|
||||
);
|
||||
$stmt->execute([
|
||||
'planet_id' => (int)$planet['id'],
|
||||
'building_key' => $buildingKey,
|
||||
'mode' => $model,
|
||||
'delta_count' => $model === 'levelable' ? null : $amount,
|
||||
'target_level' => $model === 'levelable' ? $amount : null,
|
||||
'started_at' => $now->format('Y-m-d H:i:s'),
|
||||
'finish_at' => $finishAt->format('Y-m-d H:i:s'),
|
||||
'slot_index' => $slotIndex,
|
||||
]);
|
||||
$job = $stmt->fetch();
|
||||
|
||||
return JsonResponder::withJson($response, [
|
||||
'job' => $job,
|
||||
'resources' => $resources,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function jobs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
if (!is_array($user) || !isset($user['id'])) {
|
||||
return JsonResponder::withJson(new Response(), [
|
||||
'error' => 'auth_required',
|
||||
'message' => 'Authentifizierung erforderlich.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
$planetId = isset($query['planet_id']) ? (int)$query['planet_id'] : null;
|
||||
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
|
||||
|
||||
$finished = $this->buildQueue->finalizeJobs((int)$planet['id']);
|
||||
$jobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
|
||||
|
||||
return JsonResponder::withJson($response, [
|
||||
'jobs' => $jobs,
|
||||
'finished' => $finished,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $jobs
|
||||
*/
|
||||
private function nextSlotIndex(array $jobs, int $maxSlots): int
|
||||
{
|
||||
$used = [];
|
||||
foreach ($jobs as $job) {
|
||||
$used[(int)$job['slot_index']] = true;
|
||||
}
|
||||
for ($i = 0; $i < $maxSlots; $i++) {
|
||||
if (!isset($used[$i])) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return max(0, $maxSlots - 1);
|
||||
}
|
||||
}
|
||||
25
server/src/Module/BuildQueue/Routes.php
Normal file
25
server/src/Module/BuildQueue/Routes.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\BuildQueue;
|
||||
|
||||
use App\Module\BuildQueue\Controller\BuildController;
|
||||
use App\Module\Permissions\Middleware\RequirePermission;
|
||||
use App\Module\Permissions\Service\PermissionService;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\Interfaces\RouteCollectorProxyInterface;
|
||||
|
||||
final class Routes
|
||||
{
|
||||
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
|
||||
{
|
||||
$controller = $container->get(BuildController::class);
|
||||
$permissions = $container->get(PermissionService::class);
|
||||
|
||||
$group->post('/build/start', [$controller, 'start'])
|
||||
->add(RequirePermission::for($permissions, 'planet.public.view'));
|
||||
$group->get('/build/jobs', [$controller, 'jobs'])
|
||||
->add(RequirePermission::for($permissions, 'planet.public.view'));
|
||||
}
|
||||
}
|
||||
149
server/src/Module/BuildQueue/Service/BuildQueueService.php
Normal file
149
server/src/Module/BuildQueue/Service/BuildQueueService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\BuildQueue\Service;
|
||||
|
||||
use App\Module\Blueprints\Service\BlueprintService;
|
||||
use App\Shared\Clock\TimeProvider;
|
||||
use PDO;
|
||||
|
||||
final class BuildQueueService
|
||||
{
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private BlueprintService $blueprints,
|
||||
private TimeProvider $timeProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function getQueueSlots(int $planetId, int $baseSlots = 0): int
|
||||
{
|
||||
$buildings = $this->getBuildings($planetId);
|
||||
return $this->calculateQueueSlots($buildings, $baseSlots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,level:int}> $buildings
|
||||
*/
|
||||
public function calculateQueueSlots(array $buildings, int $baseSlots = 0): int
|
||||
{
|
||||
$slots = $baseSlots;
|
||||
foreach ($buildings as $key => $data) {
|
||||
$bp = $this->blueprints->getBuilding($key);
|
||||
if (!$bp) {
|
||||
continue;
|
||||
}
|
||||
$model = (string)($bp['model'] ?? 'stackable');
|
||||
$scale = $model === 'levelable' ? (int)$data['level'] : (int)$data['count'];
|
||||
if ($scale <= 0) {
|
||||
continue;
|
||||
}
|
||||
$effects = $bp['effects'] ?? [];
|
||||
if (!is_array($effects)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($effects as $effect) {
|
||||
if (!is_array($effect) || ($effect['type'] ?? '') !== 'queue_slots_add') {
|
||||
continue;
|
||||
}
|
||||
$slots += (int)($effect['amount'] ?? 0) * $scale;
|
||||
}
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function getActiveJobs(int $planetId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM build_jobs WHERE planet_id = :planet_id ORDER BY finish_at ASC');
|
||||
$stmt->execute(['planet_id' => $planetId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function finalizeJobs(int $planetId): array
|
||||
{
|
||||
$now = $this->timeProvider->now()->format('Y-m-d H:i:s');
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM build_jobs WHERE planet_id = :planet_id AND finish_at <= :now ORDER BY finish_at ASC');
|
||||
$stmt->execute(['planet_id' => $planetId, 'now' => $now]);
|
||||
$ready = $stmt->fetchAll();
|
||||
if (!$ready) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($ready as $job) {
|
||||
$this->applyJob($planetId, $job);
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM build_jobs WHERE planet_id = :planet_id AND finish_at <= :now');
|
||||
$stmt->execute(['planet_id' => $planetId, 'now' => $now]);
|
||||
return $ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $job
|
||||
*/
|
||||
private function applyJob(int $planetId, array $job): void
|
||||
{
|
||||
$mode = (string)($job['mode'] ?? 'stackable');
|
||||
$key = (string)($job['building_key'] ?? '');
|
||||
if ($key === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($mode === 'levelable') {
|
||||
$targetLevel = (int)($job['target_level'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
|
||||
VALUES (:planet_id, :building_key, 0, :level)
|
||||
ON CONFLICT (planet_id, building_key)
|
||||
DO UPDATE SET level = EXCLUDED.level'
|
||||
);
|
||||
$stmt->execute([
|
||||
'planet_id' => $planetId,
|
||||
'building_key' => $key,
|
||||
'level' => $targetLevel,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$delta = (int)($job['delta_count'] ?? 0);
|
||||
if ($delta <= 0) {
|
||||
return;
|
||||
}
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
|
||||
VALUES (:planet_id, :building_key, :count, 0)
|
||||
ON CONFLICT (planet_id, building_key)
|
||||
DO UPDATE SET count = planet_buildings.count + EXCLUDED.count'
|
||||
);
|
||||
$stmt->execute([
|
||||
'planet_id' => $planetId,
|
||||
'building_key' => $key,
|
||||
'count' => $delta,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{count:int,level:int}>
|
||||
*/
|
||||
private function getBuildings(int $planetId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT building_key, count, level FROM planet_buildings WHERE planet_id = :planet_id');
|
||||
$stmt->execute(['planet_id' => $planetId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$buildings = [];
|
||||
foreach ($rows as $row) {
|
||||
$buildings[$row['building_key']] = [
|
||||
'count' => (int)$row['count'],
|
||||
'level' => (int)$row['level'],
|
||||
];
|
||||
}
|
||||
return $buildings;
|
||||
}
|
||||
}
|
||||
70
server/src/Module/Economy/Controller/StateController.php
Normal file
70
server/src/Module/Economy/Controller/StateController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Economy\Controller;
|
||||
|
||||
use App\Module\BuildQueue\Service\BuildQueueService;
|
||||
use App\Module\Economy\Service\EconomyService;
|
||||
use App\Shared\Http\JsonResponder;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
final class StateController
|
||||
{
|
||||
public function __construct(
|
||||
private EconomyService $economy,
|
||||
private BuildQueueService $buildQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function health(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
return JsonResponder::withJson($response, [
|
||||
'status' => 'ok',
|
||||
'time' => (new \DateTimeImmutable('now'))->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function state(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
if (!is_array($user) || !isset($user['id'])) {
|
||||
return JsonResponder::withJson(new Response(), [
|
||||
'error' => 'auth_required',
|
||||
'message' => 'Authentifizierung erforderlich.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
$planetId = isset($query['planet_id']) ? (int)$query['planet_id'] : null;
|
||||
$planet = $this->economy->getPlanetForUser((int)$user['id'], $planetId);
|
||||
|
||||
$state = $this->economy->updateResources((int)$planet['id']);
|
||||
$this->buildQueue->finalizeJobs((int)$planet['id']);
|
||||
|
||||
$buildings = $this->economy->getPlanetBuildings((int)$planet['id']);
|
||||
$modifiers = json_decode((string)$planet['modifiers'], true) ?: [];
|
||||
$calc = $this->economy->calcNetRates($buildings, $modifiers, (string)$user['race_key']);
|
||||
|
||||
$queueSlots = $this->buildQueue->getQueueSlots((int)$planet['id'], 0);
|
||||
$jobs = $this->buildQueue->getActiveJobs((int)$planet['id']);
|
||||
|
||||
return JsonResponder::withJson($response, [
|
||||
'planet' => [
|
||||
'id' => (int)$planet['id'],
|
||||
'class_key' => $planet['class_key'],
|
||||
'planet_seed' => (int)$planet['planet_seed'],
|
||||
'temperature_c' => (int)$planet['temperature_c'],
|
||||
'modifiers' => json_decode((string)$planet['modifiers'], true) ?: [],
|
||||
],
|
||||
'resources' => $state['resources'],
|
||||
'net_rates_per_hour' => $calc['net_rates'],
|
||||
'queue_slots' => $queueSlots,
|
||||
'active_build_jobs' => $jobs,
|
||||
'race' => $user['race_key'],
|
||||
'breakdown' => $calc['breakdown'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
server/src/Module/Economy/Routes.php
Normal file
24
server/src/Module/Economy/Routes.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Economy;
|
||||
|
||||
use App\Module\Economy\Controller\StateController;
|
||||
use App\Module\Permissions\Middleware\RequirePermission;
|
||||
use App\Module\Permissions\Service\PermissionService;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\Interfaces\RouteCollectorProxyInterface;
|
||||
|
||||
final class Routes
|
||||
{
|
||||
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
|
||||
{
|
||||
$controller = $container->get(StateController::class);
|
||||
$permissions = $container->get(PermissionService::class);
|
||||
|
||||
$group->get('/health', [$controller, 'health']);
|
||||
$group->get('/state', [$controller, 'state'])
|
||||
->add(RequirePermission::for($permissions, 'planet.public.view'));
|
||||
}
|
||||
}
|
||||
305
server/src/Module/Economy/Service/EconomyService.php
Normal file
305
server/src/Module/Economy/Service/EconomyService.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Economy\Service;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Module\Blueprints\Service\BlueprintService;
|
||||
use App\Shared\Clock\TimeProvider;
|
||||
use PDO;
|
||||
|
||||
final class EconomyService
|
||||
{
|
||||
/** @var string[] */
|
||||
private array $resources;
|
||||
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private ConfigLoader $configLoader,
|
||||
private BlueprintService $blueprints,
|
||||
private TimeProvider $timeProvider
|
||||
) {
|
||||
$planetConfig = $this->configLoader->planetClasses();
|
||||
$this->resources = $planetConfig['resources'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function getPlanetForUser(int $userId, ?int $planetId = null): array
|
||||
{
|
||||
if ($planetId !== null) {
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE id = :id AND user_id = :user_id');
|
||||
$stmt->execute(['id' => $planetId, 'user_id' => $userId]);
|
||||
$planet = $stmt->fetch();
|
||||
if ($planet) {
|
||||
return $planet;
|
||||
}
|
||||
}
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE user_id = :user_id ORDER BY id ASC LIMIT 1');
|
||||
$stmt->execute(['user_id' => $userId]);
|
||||
$planet = $stmt->fetch();
|
||||
if (!$planet) {
|
||||
throw new \RuntimeException('Kein Planet für User gefunden.');
|
||||
}
|
||||
return $planet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{count:int,level:int}>
|
||||
*/
|
||||
public function getPlanetBuildings(int $planetId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT building_key, count, level FROM planet_buildings WHERE planet_id = :planet_id');
|
||||
$stmt->execute(['planet_id' => $planetId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$buildings = [];
|
||||
foreach ($rows as $row) {
|
||||
$buildings[$row['building_key']] = [
|
||||
'count' => (int)$row['count'],
|
||||
'level' => (int)$row['level'],
|
||||
];
|
||||
}
|
||||
return $buildings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{resources:array<string,float>,net_rates:array<string,float>,breakdown:array<string,mixed>,caps:array<string,float>}
|
||||
*/
|
||||
public function updateResources(int $planetId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM planets WHERE id = :id');
|
||||
$stmt->execute(['id' => $planetId]);
|
||||
$planet = $stmt->fetch();
|
||||
if (!$planet) {
|
||||
throw new \RuntimeException('Planet nicht gefunden.');
|
||||
}
|
||||
|
||||
$resources = json_decode((string)$planet['resources'], true) ?: [];
|
||||
foreach ($this->resources as $res) {
|
||||
if (!isset($resources[$res])) {
|
||||
$resources[$res] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
$buildings = $this->getPlanetBuildings($planetId);
|
||||
$raceKey = $this->getRaceForPlanetUser((int)$planet['user_id']);
|
||||
$modifiers = json_decode((string)$planet['modifiers'], true) ?: [];
|
||||
|
||||
$calc = $this->calcNetRates($buildings, $modifiers, $raceKey);
|
||||
$netRates = $calc['net_rates'];
|
||||
$caps = $calc['caps'];
|
||||
|
||||
$last = new \DateTimeImmutable($planet['last_resource_update_at']);
|
||||
$now = $this->timeProvider->now();
|
||||
$dt = max(0, $now->getTimestamp() - $last->getTimestamp());
|
||||
if ($dt > 0) {
|
||||
foreach ($this->resources as $res) {
|
||||
$resources[$res] = (float)$resources[$res] + ($netRates[$res] ?? 0.0) * ($dt / 3600);
|
||||
if ($resources[$res] < 0) {
|
||||
$resources[$res] = 0.0;
|
||||
}
|
||||
if (isset($caps[$res]) && is_numeric($caps[$res])) {
|
||||
$resources[$res] = min($resources[$res], (float)$caps[$res]);
|
||||
}
|
||||
}
|
||||
$stmt = $this->pdo->prepare('UPDATE planets SET resources = :resources, last_resource_update_at = :now WHERE id = :id');
|
||||
$stmt->execute([
|
||||
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'now' => $now->format('Y-m-d H:i:s'),
|
||||
'id' => $planetId,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'resources' => $resources,
|
||||
'net_rates' => $netRates,
|
||||
'breakdown' => $calc['breakdown'],
|
||||
'caps' => $caps,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,level:int}> $buildings
|
||||
* @param array<string,mixed> $planetModifiers
|
||||
* @return array{net_rates:array<string,float>,breakdown:array<string,mixed>,caps:array<string,float>}
|
||||
*/
|
||||
public function calcNetRates(array $buildings, array $planetModifiers, string $raceKey): array
|
||||
{
|
||||
$baseProduce = array_fill_keys($this->resources, 0.0);
|
||||
$baseConsume = array_fill_keys($this->resources, 0.0);
|
||||
$caps = [];
|
||||
|
||||
$planetProduceBonus = array_fill_keys($this->resources, 0.0);
|
||||
$raceProduceBonus = array_fill_keys($this->resources, 0.0);
|
||||
$raceConsumeBonus = array_fill_keys($this->resources, 0.0);
|
||||
$bonusEffectsProduce = array_fill_keys($this->resources, 0.0);
|
||||
$bonusEffectsConsume = array_fill_keys($this->resources, 0.0);
|
||||
|
||||
foreach ($planetModifiers as $res => $val) {
|
||||
if (array_key_exists($res, $planetProduceBonus)) {
|
||||
$planetProduceBonus[$res] = (float)$val;
|
||||
}
|
||||
}
|
||||
|
||||
$races = $this->configLoader->races();
|
||||
$race = $races['races'][$raceKey] ?? null;
|
||||
if (is_array($race)) {
|
||||
$raceProd = $race['modifiers']['produce'] ?? [];
|
||||
$raceCons = $race['modifiers']['consume'] ?? [];
|
||||
if (is_array($raceProd)) {
|
||||
foreach ($raceProd as $res => $val) {
|
||||
if (array_key_exists($res, $raceProduceBonus)) {
|
||||
$raceProduceBonus[$res] += (float)$val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_array($raceCons)) {
|
||||
foreach ($raceCons as $res => $val) {
|
||||
if (array_key_exists($res, $raceConsumeBonus)) {
|
||||
$raceConsumeBonus[$res] += (float)$val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($buildings as $key => $data) {
|
||||
$bp = $this->blueprints->getBuilding($key);
|
||||
if (!$bp) {
|
||||
continue;
|
||||
}
|
||||
$model = (string)($bp['model'] ?? 'stackable');
|
||||
$scale = $model === 'levelable' ? (int)$data['level'] : (int)$data['count'];
|
||||
if ($scale <= 0) {
|
||||
continue;
|
||||
}
|
||||
$effects = $bp['effects'] ?? [];
|
||||
if (!is_array($effects)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($effects as $effect) {
|
||||
if (!is_array($effect) || empty($effect['type'])) {
|
||||
continue;
|
||||
}
|
||||
switch ($effect['type']) {
|
||||
case 'produce':
|
||||
$res = (string)($effect['resource'] ?? '');
|
||||
$amount = (float)($effect['amount'] ?? 0.0);
|
||||
if (array_key_exists($res, $baseProduce)) {
|
||||
$baseProduce[$res] += $amount * $scale;
|
||||
}
|
||||
break;
|
||||
case 'consume':
|
||||
$res = (string)($effect['resource'] ?? '');
|
||||
$amount = (float)($effect['amount'] ?? 0.0);
|
||||
if (array_key_exists($res, $baseConsume)) {
|
||||
$baseConsume[$res] += $amount * $scale;
|
||||
}
|
||||
break;
|
||||
case 'convert':
|
||||
$inputs = $effect['inputs'] ?? [];
|
||||
$outputs = $effect['outputs'] ?? [];
|
||||
if (is_array($inputs)) {
|
||||
foreach ($inputs as $res => $amount) {
|
||||
if (array_key_exists($res, $baseConsume)) {
|
||||
$baseConsume[$res] += (float)$amount * $scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_array($outputs)) {
|
||||
foreach ($outputs as $res => $amount) {
|
||||
if (array_key_exists($res, $baseProduce)) {
|
||||
$baseProduce[$res] += (float)$amount * $scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'capacity_add':
|
||||
$res = (string)($effect['resource'] ?? '');
|
||||
$amount = (float)($effect['amount'] ?? 0.0);
|
||||
if (!isset($caps[$res])) {
|
||||
$caps[$res] = 0.0;
|
||||
}
|
||||
$caps[$res] += $amount * $scale;
|
||||
break;
|
||||
case 'modifier_add':
|
||||
$target = $effect['target'] ?? [];
|
||||
if (!is_array($target)) {
|
||||
break;
|
||||
}
|
||||
$res = (string)($target['resource'] ?? '');
|
||||
$field = (string)($target['field'] ?? 'produce');
|
||||
$amount = (float)($effect['amount'] ?? 0.0);
|
||||
if ($field === 'consume' && array_key_exists($res, $bonusEffectsConsume)) {
|
||||
$bonusEffectsConsume[$res] += $amount;
|
||||
} elseif (array_key_exists($res, $bonusEffectsProduce)) {
|
||||
$bonusEffectsProduce[$res] += $amount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$netRates = [];
|
||||
$effectiveProduce = [];
|
||||
$effectiveConsume = [];
|
||||
$prodMultiplier = [];
|
||||
$consMultiplier = [];
|
||||
|
||||
foreach ($this->resources as $res) {
|
||||
$prodMultiplier[$res] = self::multiplyBonuses([
|
||||
$planetProduceBonus[$res] ?? 0.0,
|
||||
$raceProduceBonus[$res] ?? 0.0,
|
||||
$bonusEffectsProduce[$res] ?? 0.0,
|
||||
]);
|
||||
$consMultiplier[$res] = self::multiplyBonuses([
|
||||
$raceConsumeBonus[$res] ?? 0.0,
|
||||
$bonusEffectsConsume[$res] ?? 0.0,
|
||||
]);
|
||||
|
||||
$effectiveProduce[$res] = $baseProduce[$res] * $prodMultiplier[$res];
|
||||
$effectiveConsume[$res] = $baseConsume[$res] * $consMultiplier[$res];
|
||||
$netRates[$res] = $effectiveProduce[$res] - $effectiveConsume[$res];
|
||||
}
|
||||
|
||||
$breakdown = [
|
||||
'produce' => [
|
||||
'base' => $baseProduce,
|
||||
'planet' => $planetProduceBonus,
|
||||
'race' => $raceProduceBonus,
|
||||
'effects' => $bonusEffectsProduce,
|
||||
'result' => $effectiveProduce,
|
||||
],
|
||||
'consume' => [
|
||||
'base' => $baseConsume,
|
||||
'race' => $raceConsumeBonus,
|
||||
'effects' => $bonusEffectsConsume,
|
||||
'result' => $effectiveConsume,
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'net_rates' => $netRates,
|
||||
'breakdown' => $breakdown,
|
||||
'caps' => $caps,
|
||||
];
|
||||
}
|
||||
|
||||
public static function multiplyBonuses(array $bonuses): float
|
||||
{
|
||||
$mult = 1.0;
|
||||
foreach ($bonuses as $bonus) {
|
||||
$mult *= (1.0 + (float)$bonus);
|
||||
}
|
||||
return $mult;
|
||||
}
|
||||
|
||||
private function getRaceForPlanetUser(int $userId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT race_key FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $userId]);
|
||||
$race = $stmt->fetchColumn();
|
||||
return $race ? (string)$race : 'human';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Permissions\Middleware;
|
||||
|
||||
use App\Module\Permissions\Service\PermissionService;
|
||||
use App\Shared\Http\JsonResponder;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Psr7\Response;
|
||||
|
||||
final class RequirePermission implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private PermissionService $permissions,
|
||||
private string $permissionKey
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$user = $request->getAttribute('user');
|
||||
if (!is_array($user) || !isset($user['id'])) {
|
||||
return JsonResponder::withJson(new Response(), [
|
||||
'error' => 'auth_required',
|
||||
'message' => 'Authentifizierung erforderlich.'
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (!$this->permissions->can((int)$user['id'], $this->permissionKey)) {
|
||||
return JsonResponder::withJson(new Response(), [
|
||||
'error' => 'forbidden',
|
||||
'message' => 'Keine Berechtigung für diese Aktion.',
|
||||
'permission' => $this->permissionKey,
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
public static function for(PermissionService $permissions, string $permissionKey): self
|
||||
{
|
||||
return new self($permissions, $permissionKey);
|
||||
}
|
||||
}
|
||||
37
server/src/Module/Permissions/Permissions.php
Normal file
37
server/src/Module/Permissions/Permissions.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Permissions;
|
||||
|
||||
final class Permissions
|
||||
{
|
||||
/**
|
||||
* @return array<int,array<string,string>>
|
||||
*/
|
||||
public static function definitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'key' => 'planet.admin.generate',
|
||||
'module' => 'planet',
|
||||
'description' => 'Universum/Planeten generieren',
|
||||
],
|
||||
[
|
||||
'key' => 'planet.admin.regen',
|
||||
'module' => 'planet',
|
||||
'description' => 'Planeten neu generieren',
|
||||
],
|
||||
[
|
||||
'key' => 'planet.public.view',
|
||||
'module' => 'planet',
|
||||
'description' => 'Planetenstatus ansehen',
|
||||
],
|
||||
[
|
||||
'key' => 'blueprints.admin.add',
|
||||
'module' => 'blueprints',
|
||||
'description' => 'Blueprints administrativ hinzufügen',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
server/src/Module/Permissions/Service/PermissionService.php
Normal file
44
server/src/Module/Permissions/Service/PermissionService.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Permissions\Service;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class PermissionService
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function can(int $userId, string $permissionKey): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT uo.effect
|
||||
FROM user_permission_overrides uo
|
||||
JOIN permissions p ON p.id = uo.permission_id
|
||||
WHERE uo.user_id = :user_id AND p.key = :key
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['user_id' => $userId, 'key' => $permissionKey]);
|
||||
$override = $stmt->fetchColumn();
|
||||
if ($override === 'deny') {
|
||||
return false;
|
||||
}
|
||||
if ($override === 'allow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = :user_id AND p.key = :key
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['user_id' => $userId, 'key' => $permissionKey]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\PlanetGenerator\Controller;
|
||||
|
||||
use App\Shared\Http\JsonResponder;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class AdminPlanetController
|
||||
{
|
||||
public function generate(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
return JsonResponder::withJson($response, [
|
||||
'status' => 'ok',
|
||||
'message' => 'Stub: Universum generieren (v0.1).'
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
server/src/Module/PlanetGenerator/Routes.php
Normal file
23
server/src/Module/PlanetGenerator/Routes.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\PlanetGenerator;
|
||||
|
||||
use App\Module\Permissions\Middleware\RequirePermission;
|
||||
use App\Module\Permissions\Service\PermissionService;
|
||||
use App\Module\PlanetGenerator\Controller\AdminPlanetController;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\Interfaces\RouteCollectorProxyInterface;
|
||||
|
||||
final class Routes
|
||||
{
|
||||
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
|
||||
{
|
||||
$controller = $container->get(AdminPlanetController::class);
|
||||
$permissions = $container->get(PermissionService::class);
|
||||
|
||||
$group->post('/admin/universe/generate', [$controller, 'generate'])
|
||||
->add(RequirePermission::for($permissions, 'planet.admin.generate'));
|
||||
}
|
||||
}
|
||||
147
server/src/Module/PlanetGenerator/Service/PlanetGenerator.php
Normal file
147
server/src/Module/PlanetGenerator/Service/PlanetGenerator.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\PlanetGenerator\Service;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
|
||||
final class PlanetGenerator
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private array $config;
|
||||
|
||||
public function __construct(ConfigLoader $configLoader)
|
||||
{
|
||||
$this->config = $configLoader->planetClasses();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{class_key:string,tier_key:string,modifiers:array<string,float>,score:float,temperature_c:int}
|
||||
*/
|
||||
public function generate(string $classKey, string $tierKey, ?int $seed = null): array
|
||||
{
|
||||
$classes = $this->config['classes'] ?? [];
|
||||
if (!isset($classes[$classKey])) {
|
||||
throw new \RuntimeException("Planetklasse nicht gefunden: {$classKey}");
|
||||
}
|
||||
$tiers = $this->config['tiers'] ?? [];
|
||||
if (!isset($tiers[$tierKey])) {
|
||||
throw new \RuntimeException("Tier nicht gefunden: {$tierKey}");
|
||||
}
|
||||
|
||||
$resources = $this->config['resources'] ?? [];
|
||||
$weights = $this->config['weights'] ?? [];
|
||||
$bounds = $this->config['global_bounds'] ?? ['min' => -1.0, 'max' => 1.0];
|
||||
|
||||
$modifiers = [];
|
||||
foreach ($resources as $res) {
|
||||
$modifiers[$res] = 0.0;
|
||||
}
|
||||
|
||||
$seed = $seed ?? 0;
|
||||
$rand = new \Random\Randomizer(new \Random\Engine\Mt19937($seed));
|
||||
|
||||
$constraints = $classes[$classKey]['constraints'] ?? [];
|
||||
foreach ($constraints as $res => $constraint) {
|
||||
if (!array_key_exists($res, $modifiers) || !is_array($constraint)) {
|
||||
continue;
|
||||
}
|
||||
$min = $constraint['min'] ?? null;
|
||||
$max = $constraint['max'] ?? null;
|
||||
if ($min !== null && $max !== null) {
|
||||
$modifiers[$res] = $this->randomFloat((float)$min, (float)$max, $rand);
|
||||
} elseif ($min !== null) {
|
||||
$modifiers[$res] = (float)$min;
|
||||
} elseif ($max !== null) {
|
||||
$modifiers[$res] = (float)$max;
|
||||
}
|
||||
}
|
||||
|
||||
$target = (float)$tiers[$tierKey]['target_score'];
|
||||
$epsilon = (float)$tiers[$tierKey]['epsilon'];
|
||||
$score = $this->calculateScore($modifiers);
|
||||
$delta = $target - $score;
|
||||
|
||||
$adjustable = array_diff($resources, array_keys($constraints));
|
||||
$iterations = 0;
|
||||
while (abs($delta) > $epsilon && $iterations < 12 && $adjustable) {
|
||||
$sumWeights = 0.0;
|
||||
foreach ($adjustable as $res) {
|
||||
$sumWeights += (float)($weights[$res] ?? 0.0);
|
||||
}
|
||||
if ($sumWeights === 0.0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$achieved = 0.0;
|
||||
foreach ($adjustable as $res) {
|
||||
$step = $delta / $sumWeights;
|
||||
$before = $modifiers[$res];
|
||||
$after = $this->clamp($before + $step, (float)$bounds['min'], (float)$bounds['max']);
|
||||
$modifiers[$res] = $after;
|
||||
$achieved += ((float)$weights[$res] ?? 0.0) * ($after - $before);
|
||||
}
|
||||
|
||||
$delta -= $achieved;
|
||||
$adjustable = array_values(array_filter($adjustable, function (string $res) use ($modifiers, $bounds): bool {
|
||||
$min = (float)$bounds['min'];
|
||||
$max = (float)$bounds['max'];
|
||||
return $modifiers[$res] > $min && $modifiers[$res] < $max;
|
||||
}));
|
||||
$iterations++;
|
||||
}
|
||||
|
||||
$score = $this->calculateScore($modifiers);
|
||||
$temperatureC = $this->rollTemperature($classes[$classKey]['temperature_range_c'] ?? null, $rand);
|
||||
return [
|
||||
'class_key' => $classKey,
|
||||
'tier_key' => $tierKey,
|
||||
'modifiers' => $modifiers,
|
||||
'score' => $score,
|
||||
'temperature_c' => $temperatureC,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,float> $modifiers
|
||||
*/
|
||||
public function calculateScore(array $modifiers): float
|
||||
{
|
||||
$weights = $this->config['weights'] ?? [];
|
||||
$sum = 0.0;
|
||||
foreach ($modifiers as $res => $value) {
|
||||
$sum += ((float)($weights[$res] ?? 0.0)) * (float)$value;
|
||||
}
|
||||
return $sum;
|
||||
}
|
||||
|
||||
private function clamp(float $value, float $min, float $max): float
|
||||
{
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private function randomFloat(float $min, float $max, ?\Random\Randomizer $rand): float
|
||||
{
|
||||
$int = $rand->getInt(0, 1_000_000);
|
||||
$ratio = $int / 1_000_000;
|
||||
return $min + ($max - $min) * $ratio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|float>|null $range
|
||||
*/
|
||||
private function rollTemperature($range, \Random\Randomizer $rand): int
|
||||
{
|
||||
if (!is_array($range) || count($range) !== 2) {
|
||||
return 0;
|
||||
}
|
||||
[$min, $max] = array_values($range);
|
||||
$minInt = (int)round((float)$min);
|
||||
$maxInt = (int)round((float)$max);
|
||||
if ($minInt > $maxInt) {
|
||||
return 0;
|
||||
}
|
||||
return $rand->getInt($minInt, $maxInt);
|
||||
}
|
||||
}
|
||||
25
server/src/Shared/Clock/FixedTimeProvider.php
Normal file
25
server/src/Shared/Clock/FixedTimeProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Clock;
|
||||
|
||||
final class FixedTimeProvider implements TimeProvider
|
||||
{
|
||||
private \DateTimeImmutable $now;
|
||||
|
||||
public function __construct(\DateTimeImmutable $now)
|
||||
{
|
||||
$this->now = $now;
|
||||
}
|
||||
|
||||
public function now(): \DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
|
||||
public function setNow(\DateTimeImmutable $now): void
|
||||
{
|
||||
$this->now = $now;
|
||||
}
|
||||
}
|
||||
13
server/src/Shared/Clock/SystemTimeProvider.php
Normal file
13
server/src/Shared/Clock/SystemTimeProvider.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Clock;
|
||||
|
||||
final class SystemTimeProvider implements TimeProvider
|
||||
{
|
||||
public function now(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('now');
|
||||
}
|
||||
}
|
||||
10
server/src/Shared/Clock/TimeProvider.php
Normal file
10
server/src/Shared/Clock/TimeProvider.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Clock;
|
||||
|
||||
interface TimeProvider
|
||||
{
|
||||
public function now(): \DateTimeImmutable;
|
||||
}
|
||||
22
server/src/Shared/Http/JsonResponder.php
Normal file
22
server/src/Shared/Http/JsonResponder.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Http;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class JsonResponder
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public static function withJson(ResponseInterface $response, array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$response->getBody()->write($payload === false ? '{}' : $payload);
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($status);
|
||||
}
|
||||
}
|
||||
89
server/tests/Integration/BuildStartTest.php
Normal file
89
server/tests/Integration/BuildStartTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use App\Shared\Clock\FixedTimeProvider;
|
||||
use App\Tests\Support\TestAppFactory;
|
||||
use App\Tests\Support\TestDatabase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
final class BuildStartTest extends TestCase
|
||||
{
|
||||
public function testBuildStartAndFinalize(): void
|
||||
{
|
||||
$pdo = TestDatabase::create();
|
||||
TestDatabase::reset($pdo);
|
||||
$seed = TestDatabase::seedMinimal($pdo);
|
||||
$userId = (int)$seed['user_id'];
|
||||
|
||||
$resources = [
|
||||
'metal' => 1000.0,
|
||||
'alloy' => 0.0,
|
||||
'crystals' => 1000.0,
|
||||
'energy' => 0.0,
|
||||
'credits' => 0.0,
|
||||
'population' => 0.0,
|
||||
'water' => 0.0,
|
||||
'deuterium' => 0.0,
|
||||
'food' => 0.0,
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
|
||||
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'user_id' => $userId,
|
||||
'name' => 'Testworld',
|
||||
'class_key' => 'temperate',
|
||||
'planet_seed' => 7,
|
||||
'temperature_c' => 18,
|
||||
'modifiers' => json_encode(['metal' => 0.0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'last_update' => '2026-02-03 00:00:00',
|
||||
]);
|
||||
$planetId = (int)$stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO planet_buildings (planet_id, building_key, count, level)
|
||||
VALUES (:planet_id, :building_key, :count, 0)'
|
||||
);
|
||||
$stmt->execute(['planet_id' => $planetId, 'building_key' => 'build_center', 'count' => 1]);
|
||||
|
||||
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
|
||||
$app = TestAppFactory::create($pdo, $time);
|
||||
|
||||
$factory = new ServerRequestFactory();
|
||||
$request = $factory->createServerRequest('POST', '/build/start')
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('X-User-Id', (string)$userId);
|
||||
|
||||
$request->getBody()->write(json_encode([
|
||||
'building_key' => 'ore_mine',
|
||||
'amount' => 1,
|
||||
'planet_id' => $planetId,
|
||||
]));
|
||||
$request->getBody()->rewind();
|
||||
|
||||
$response = $app->handle($request);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
|
||||
$body = json_decode((string)$response->getBody(), true);
|
||||
self::assertSame(880.0, $body['resources']['metal']);
|
||||
self::assertSame(940.0, $body['resources']['crystals']);
|
||||
|
||||
$time->setNow(new \DateTimeImmutable('2026-02-03 00:02:00'));
|
||||
$jobsRequest = $factory->createServerRequest('GET', '/build/jobs')
|
||||
->withHeader('X-User-Id', (string)$userId);
|
||||
|
||||
$jobsResponse = $app->handle($jobsRequest);
|
||||
self::assertSame(200, $jobsResponse->getStatusCode());
|
||||
|
||||
$count = (int)$pdo->query("SELECT count FROM planet_buildings WHERE planet_id = {$planetId} AND building_key = 'ore_mine'")->fetchColumn();
|
||||
self::assertSame(1, $count);
|
||||
}
|
||||
}
|
||||
62
server/tests/Integration/PermissionDenyTest.php
Normal file
62
server/tests/Integration/PermissionDenyTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use App\Shared\Clock\FixedTimeProvider;
|
||||
use App\Tests\Support\TestAppFactory;
|
||||
use App\Tests\Support\TestDatabase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
|
||||
final class PermissionDenyTest extends TestCase
|
||||
{
|
||||
public function testDenyOverrideBlocksPermission(): void
|
||||
{
|
||||
$pdo = TestDatabase::create();
|
||||
TestDatabase::reset($pdo);
|
||||
$seed = TestDatabase::seedMinimal($pdo);
|
||||
$userId = (int)$seed['user_id'];
|
||||
|
||||
$resources = [
|
||||
'metal' => 100.0,
|
||||
'alloy' => 0.0,
|
||||
'crystals' => 50.0,
|
||||
'energy' => 0.0,
|
||||
'credits' => 0.0,
|
||||
'population' => 0.0,
|
||||
'water' => 0.0,
|
||||
'deuterium' => 0.0,
|
||||
'food' => 0.0,
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at)
|
||||
VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'user_id' => $userId,
|
||||
'name' => 'Denied',
|
||||
'class_key' => 'temperate',
|
||||
'planet_seed' => 9,
|
||||
'temperature_c' => 12,
|
||||
'modifiers' => json_encode(['metal' => 0.0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'last_update' => '2026-02-03 00:00:00',
|
||||
]);
|
||||
|
||||
$permissionId = (int)$pdo->query("SELECT id FROM permissions WHERE key = 'planet.public.view'")->fetchColumn();
|
||||
$pdo->exec("INSERT INTO user_permission_overrides (user_id, permission_id, effect) VALUES ({$userId}, {$permissionId}, 'deny')");
|
||||
|
||||
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
|
||||
$app = TestAppFactory::create($pdo, $time);
|
||||
|
||||
$factory = new ServerRequestFactory();
|
||||
$request = $factory->createServerRequest('GET', '/state')
|
||||
->withHeader('X-User-Id', (string)$userId);
|
||||
|
||||
$response = $app->handle($request);
|
||||
self::assertSame(403, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
27
server/tests/Support/TestAppFactory.php
Normal file
27
server/tests/Support/TestAppFactory.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Bootstrap;
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Config\ConfigValidator;
|
||||
use App\Shared\Clock\TimeProvider;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
final class TestAppFactory
|
||||
{
|
||||
public static function create(\PDO $pdo, TimeProvider $timeProvider): App
|
||||
{
|
||||
$repoRoot = dirname(__DIR__, 3);
|
||||
$container = Bootstrap::buildContainer([
|
||||
\PDO::class => $pdo,
|
||||
TimeProvider::class => $timeProvider,
|
||||
ConfigLoader::class => new ConfigLoader(new ConfigValidator(), $repoRoot . '/config'),
|
||||
]);
|
||||
|
||||
return Bootstrap::createApp($container);
|
||||
}
|
||||
}
|
||||
62
server/tests/Support/TestDatabase.php
Normal file
62
server/tests/Support/TestDatabase.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use App\Database\ConnectionFactory;
|
||||
use PDO;
|
||||
|
||||
final class TestDatabase
|
||||
{
|
||||
public static function create(): PDO
|
||||
{
|
||||
$dbName = getenv('DB_TEST_NAME') ?: (getenv('DB_NAME') ?: 'galaxyforge');
|
||||
return ConnectionFactory::create($dbName);
|
||||
}
|
||||
|
||||
public static function reset(PDO $pdo): void
|
||||
{
|
||||
$tables = [
|
||||
'user_permission_overrides',
|
||||
'user_roles',
|
||||
'role_permissions',
|
||||
'permissions',
|
||||
'roles',
|
||||
'build_jobs',
|
||||
'planet_buildings',
|
||||
'planets',
|
||||
'users',
|
||||
];
|
||||
foreach ($tables as $table) {
|
||||
$pdo->exec("DROP TABLE IF EXISTS {$table} CASCADE");
|
||||
}
|
||||
|
||||
$migration = __DIR__ . '/../../db/migrations/001_init.sql';
|
||||
$sql = file_get_contents($migration);
|
||||
if ($sql === false) {
|
||||
throw new \RuntimeException('Migration nicht lesbar.');
|
||||
}
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
public static function seedMinimal(PDO $pdo): array
|
||||
{
|
||||
$pdo->exec("INSERT INTO roles (key, name) VALUES ('player', 'Spieler')");
|
||||
$pdo->exec("INSERT INTO roles (key, name) VALUES ('admin', 'Admin')");
|
||||
|
||||
$pdo->exec("INSERT INTO permissions (key, module, description) VALUES ('planet.public.view', 'planet', 'Planet ansehen')");
|
||||
$pdo->exec("INSERT INTO permissions (key, module, description) VALUES ('planet.admin.generate', 'planet', 'Planeten generieren')");
|
||||
|
||||
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM roles r JOIN permissions p ON r.key = 'player' AND p.key = 'planet.public.view'");
|
||||
|
||||
$pdo->exec("INSERT INTO users (username, race_key) VALUES ('tester', 'human')");
|
||||
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'tester'")->fetchColumn();
|
||||
|
||||
$pdo->exec("INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT {$userId}, r.id FROM roles r WHERE r.key = 'player'");
|
||||
|
||||
return ['user_id' => $userId];
|
||||
}
|
||||
}
|
||||
17
server/tests/Unit/MultiplierTest.php
Normal file
17
server/tests/Unit/MultiplierTest.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Module\Economy\Service\EconomyService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MultiplierTest extends TestCase
|
||||
{
|
||||
public function testMultiplierChain(): void
|
||||
{
|
||||
$result = EconomyService::multiplyBonuses([0.10, -0.15, 0.10, 0.02]);
|
||||
self::assertEquals(1.04907, $result, '', 0.0001);
|
||||
}
|
||||
}
|
||||
34
server/tests/Unit/PlanetGeneratorTest.php
Normal file
34
server/tests/Unit/PlanetGeneratorTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Config\ConfigValidator;
|
||||
use App\Module\PlanetGenerator\Service\PlanetGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PlanetGeneratorTest extends TestCase
|
||||
{
|
||||
public function testIceConstraintsAndScore(): void
|
||||
{
|
||||
$loader = new ConfigLoader(new ConfigValidator(), dirname(__DIR__, 3) . '/config');
|
||||
$generator = new PlanetGenerator($loader);
|
||||
|
||||
$result = $generator->generate('ice', 'normal', 1234);
|
||||
$mods = $result['modifiers'];
|
||||
|
||||
self::assertGreaterThanOrEqual(0.5, $mods['water']);
|
||||
self::assertLessThanOrEqual(-0.6, $mods['energy']);
|
||||
self::assertGreaterThanOrEqual(-80, $result['temperature_c']);
|
||||
self::assertLessThanOrEqual(-10, $result['temperature_c']);
|
||||
|
||||
$config = $loader->planetClasses();
|
||||
$target = (float)$config['tiers']['normal']['target_score'];
|
||||
$epsilon = (float)$config['tiers']['normal']['epsilon'];
|
||||
$score = $generator->calculateScore($mods);
|
||||
|
||||
self::assertEquals($target, $score, '', $epsilon);
|
||||
}
|
||||
}
|
||||
30
server/tests/Unit/QueueSlotsTest.php
Normal file
30
server/tests/Unit/QueueSlotsTest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Config\ConfigLoader;
|
||||
use App\Config\ConfigValidator;
|
||||
use App\Module\Blueprints\Service\BlueprintService;
|
||||
use App\Module\BuildQueue\Service\BuildQueueService;
|
||||
use App\Shared\Clock\FixedTimeProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class QueueSlotsTest extends TestCase
|
||||
{
|
||||
public function testQueueSlotsGrowWithBuildCenter(): void
|
||||
{
|
||||
$pdo = new \PDO('sqlite::memory:');
|
||||
$loader = new ConfigLoader(new ConfigValidator(), dirname(__DIR__, 3) . '/config');
|
||||
$blueprints = new BlueprintService($loader);
|
||||
$service = new BuildQueueService($pdo, $blueprints, new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00')));
|
||||
|
||||
$buildings = [
|
||||
'build_center' => ['count' => 2, 'level' => 0],
|
||||
];
|
||||
|
||||
$slots = $service->calculateQueueSlots($buildings, 0);
|
||||
self::assertSame(2, $slots);
|
||||
}
|
||||
}
|
||||
20
server/tests/bootstrap.php
Normal file
20
server/tests/bootstrap.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$root = dirname(__DIR__, 1);
|
||||
$repoRoot = dirname($root, 1);
|
||||
|
||||
if (file_exists($repoRoot . '/.env')) {
|
||||
$dotenv = Dotenv\Dotenv::createImmutable($repoRoot);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
if (!getenv('APP_ENV')) {
|
||||
putenv('APP_ENV=test');
|
||||
}
|
||||
if (!getenv('DEV_MODE')) {
|
||||
putenv('DEV_MODE=1');
|
||||
}
|
||||
Reference in New Issue
Block a user