This commit is contained in:
2026-02-03 09:18:15 +01:00
parent 13efe9406c
commit dc427490a5
42 changed files with 5104 additions and 65 deletions

10
config/avatars.json Normal file
View File

@@ -0,0 +1,10 @@
{
"avatars": [
{"key": "avatar-01", "label": "Nova", "image": "/assets/avatars/avatar-01.svg"},
{"key": "avatar-02", "label": "Vektor", "image": "/assets/avatars/avatar-02.svg"},
{"key": "avatar-03", "label": "Ion", "image": "/assets/avatars/avatar-03.svg"},
{"key": "avatar-04", "label": "Astra", "image": "/assets/avatars/avatar-04.svg"},
{"key": "avatar-05", "label": "Pulse", "image": "/assets/avatars/avatar-05.svg"},
{"key": "avatar-06", "label": "Zenit", "image": "/assets/avatars/avatar-06.svg"}
]
}

View File

@@ -2,6 +2,7 @@
"races": { "races": {
"human": { "human": {
"name": "Mensch", "name": "Mensch",
"description": "Ausgewogene Spezies mit leichtem Kreditbonus.",
"modifiers": { "modifiers": {
"produce": { "produce": {
"credits": 0.02 "credits": 0.02
@@ -11,6 +12,7 @@
}, },
"robot": { "robot": {
"name": "Roboter", "name": "Roboter",
"description": "Effiziente Maschinen mit Fokus auf Metallproduktion.",
"modifiers": { "modifiers": {
"produce": { "produce": {
"metal": 0.05 "metal": 0.05

3022
server/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
race_key TEXT NOT NULL DEFAULT 'human', race_key TEXT NOT NULL DEFAULT 'human',
title TEXT NOT NULL DEFAULT '',
avatar_key TEXT NOT NULL DEFAULT 'default',
created_at TIMESTAMP NOT NULL DEFAULT NOW() created_at TIMESTAMP NOT NULL DEFAULT NOW()
); );

View File

@@ -47,8 +47,19 @@ try {
WHERE r.key = 'admin' WHERE r.key = 'admin'
ON CONFLICT DO NOTHING"); ON CONFLICT DO NOTHING");
$stmt = $pdo->prepare("INSERT INTO users (username, race_key) VALUES (:username, :race_key) ON CONFLICT (username) DO NOTHING"); $stmt = $pdo->prepare(
$stmt->execute(['username' => 'dev', 'race_key' => 'human']); "INSERT INTO users (username, email, password_hash, race_key, title, avatar_key)
VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key)
ON CONFLICT (username) DO NOTHING"
);
$stmt->execute([
'username' => 'dev',
'email' => 'dev@example.test',
'password_hash' => password_hash('change-me', PASSWORD_DEFAULT),
'race_key' => 'human',
'title' => 'Pionier',
'avatar_key' => 'avatar-01',
]);
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'dev'")->fetchColumn(); $userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'dev'")->fetchColumn();

View File

@@ -9,6 +9,7 @@ use App\Config\ConfigValidator;
use App\Database\ConnectionFactory; use App\Database\ConnectionFactory;
use App\Module\Auth\Middleware\AuthContextMiddleware; use App\Module\Auth\Middleware\AuthContextMiddleware;
use App\Module\Auth\Service\AuthService; use App\Module\Auth\Service\AuthService;
use App\Module\Auth\Routes as AuthRoutes;
use App\Module\BuildQueue\Routes as BuildQueueRoutes; use App\Module\BuildQueue\Routes as BuildQueueRoutes;
use App\Module\Economy\Routes as EconomyRoutes; use App\Module\Economy\Routes as EconomyRoutes;
use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes; use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes;
@@ -60,6 +61,7 @@ final class Bootstrap
$app->addBodyParsingMiddleware(); $app->addBodyParsingMiddleware();
$app->group('', function (RouteCollectorProxyInterface $group) use ($container) { $app->group('', function (RouteCollectorProxyInterface $group) use ($container) {
AuthRoutes::register($group, $container);
EconomyRoutes::register($group, $container); EconomyRoutes::register($group, $container);
BuildQueueRoutes::register($group, $container); BuildQueueRoutes::register($group, $container);
PlanetGeneratorRoutes::register($group, $container); PlanetGeneratorRoutes::register($group, $container);

View File

@@ -48,6 +48,16 @@ final class ConfigLoader
}); });
} }
/**
* @return array<string,mixed>
*/
public function avatars(): array
{
return $this->load('avatars.json', function (array $config): void {
$this->validator->validateAvatars($config, 'avatars.json');
});
}
/** /**
* @param callable(array<string,mixed>):void $validate * @param callable(array<string,mixed>):void $validate
* @return array<string,mixed> * @return array<string,mixed>

View File

@@ -177,6 +177,33 @@ final class ConfigValidator
} }
} }
/**
* @param array<string,mixed> $config
*/
public function validateAvatars(array $config, string $file): void
{
$errors = [];
if (!isset($config['avatars']) || !is_array($config['avatars'])) {
$errors[] = "'avatars' muss ein Array sein";
}
if (isset($config['avatars']) && is_array($config['avatars'])) {
foreach ($config['avatars'] as $idx => $avatar) {
if (!is_array($avatar)) {
$errors[] = "Avatar #{$idx} muss ein Objekt sein";
continue;
}
foreach (['key', 'label', 'image'] as $req) {
if (empty($avatar[$req]) || !is_string($avatar[$req])) {
$errors[] = "Avatar #{$idx}: '{$req}' fehlt";
}
}
}
}
if ($errors) {
throw new ConfigValidationException($file, $errors);
}
}
/** /**
* @return string[] * @return string[]
*/ */

View File

@@ -0,0 +1,475 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Controller;
use App\Config\ConfigLoader;
use App\Module\PlanetGenerator\Service\PlanetGenerator;
use App\Shared\Http\JsonResponder;
use PDO;
use PDOException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
final class AuthController
{
private const PASSWORD_MIN_LENGTH = 8;
private const TITLE_MIN_LENGTH = 2;
private const TITLE_MAX_LENGTH = 40;
private const USERNAME_MIN_LENGTH = 3;
private const USERNAME_MAX_LENGTH = 20;
public function __construct(
private PDO $pdo,
private ConfigLoader $configLoader,
private PlanetGenerator $planetGenerator
) {
}
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $this->parseBody($request);
$identifier = trim((string)($body['username_or_email'] ?? ''));
$password = (string)($body['password'] ?? '');
if ($identifier === '' || $password === '') {
return JsonResponder::withJson($response, [
'error' => 'invalid_input',
'message' => 'Username/E-Mail und Passwort sind Pflichtfelder.',
], 400);
}
$user = $this->findUserByIdentifier($identifier);
if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) {
return JsonResponder::withJson(new Response(), [
'error' => 'invalid_credentials',
'message' => 'Login fehlgeschlagen.',
], 401);
}
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
]);
}
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$this->startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
return JsonResponder::withJson($response, ['ok' => true]);
}
public function me(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);
}
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
]);
}
public function registerStep1(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $this->parseBody($request);
$raceKey = trim((string)($body['race_key'] ?? ''));
$races = $this->configLoader->races();
$raceConfig = $races['races'][$raceKey] ?? null;
if ($raceKey === '' || !is_array($raceConfig)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_race',
'message' => 'Ungültige Rasse.',
], 422);
}
$draft = $this->getDraft();
$draft['race_key'] = $raceKey;
$this->saveDraft($draft);
return JsonResponder::withJson($response, [
'draft' => $this->sanitizeDraft($draft),
]);
}
public function registerStep2(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$draft = $this->getDraft();
if (empty($draft['race_key'])) {
return JsonResponder::withJson($response, [
'error' => 'draft_missing',
'message' => 'Bitte zuerst eine Rasse wählen.',
], 409);
}
$body = $this->parseBody($request);
$avatarKey = trim((string)($body['avatar_key'] ?? ''));
$title = $this->sanitizeTitle((string)($body['title'] ?? ''));
if ($avatarKey === '' || !$this->avatarExists($avatarKey)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_avatar',
'message' => 'Ungültiger Avatar.',
], 422);
}
if (!$this->isValidTitle($title)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_title',
'message' => 'Titel muss zwischen 2 und 40 Zeichen lang sein.',
], 422);
}
$draft['avatar_key'] = $avatarKey;
$draft['title'] = $title;
$this->saveDraft($draft);
return JsonResponder::withJson($response, [
'draft' => $this->sanitizeDraft($draft),
]);
}
public function registerStep3(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$draft = $this->getDraft();
if (empty($draft['race_key']) || empty($draft['avatar_key']) || empty($draft['title'])) {
return JsonResponder::withJson($response, [
'error' => 'draft_incomplete',
'message' => 'Bitte Registrierungsschritte 1 und 2 abschließen.',
], 409);
}
$body = $this->parseBody($request);
$username = trim((string)($body['username'] ?? ''));
$email = trim((string)($body['email'] ?? ''));
$password = (string)($body['password'] ?? '');
if (!$this->isValidUsername($username)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_username',
'message' => 'Username muss zwischen 3 und 20 Zeichen lang sein.',
], 422);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return JsonResponder::withJson($response, [
'error' => 'invalid_email',
'message' => 'Ungültige E-Mail-Adresse.',
], 422);
}
if ($this->length($password) < self::PASSWORD_MIN_LENGTH) {
return JsonResponder::withJson($response, [
'error' => 'invalid_password',
'message' => 'Passwort zu kurz (mindestens 8 Zeichen).',
], 422);
}
$existing = $this->findUserByUsernameOrEmail($username, $email);
if ($existing['username'] ?? false) {
return JsonResponder::withJson($response, [
'error' => 'username_taken',
'message' => 'Username bereits vergeben.',
], 409);
}
if ($existing['email'] ?? false) {
return JsonResponder::withJson($response, [
'error' => 'email_taken',
'message' => 'E-Mail bereits registriert.',
], 409);
}
try {
$this->pdo->beginTransaction();
$stmt = $this->pdo->prepare(
'INSERT INTO users (username, email, password_hash, race_key, title, avatar_key)
VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key)
RETURNING *'
);
$stmt->execute([
'username' => $username,
'email' => $this->lower($email),
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'race_key' => $draft['race_key'],
'title' => $draft['title'],
'avatar_key' => $draft['avatar_key'],
]);
$user = $stmt->fetch();
$userId = (int)($user['id'] ?? 0);
if ($userId <= 0) {
throw new \\RuntimeException('User-ID fehlt.');
}
$this->assignRole($userId, 'player');
$this->createStarterPlanet($userId);
$this->pdo->commit();
} catch (PDOException $e) {
$this->pdo->rollBack();
if ($e->getCode() === '23505') {
return JsonResponder::withJson($response, [
'error' => 'duplicate',
'message' => 'Username oder E-Mail bereits registriert.',
], 409);
}
return JsonResponder::withJson($response, [
'error' => 'registration_failed',
'message' => 'Registrierung fehlgeschlagen.',
], 500);
} catch (\\Throwable $e) {
$this->pdo->rollBack();
return JsonResponder::withJson($response, [
'error' => 'registration_failed',
'message' => 'Registrierung fehlgeschlagen.',
], 500);
}
$this->clearDraft();
$this->loginUser((int)$user['id']);
return JsonResponder::withJson($response, [
'user' => $this->buildUserSummary($user),
], 201);
}
/**
* @return array<string,mixed>
*/
private function parseBody(ServerRequestInterface $request): array
{
$body = $request->getParsedBody();
if (!is_array($body)) {
return [];
}
return $body;
}
/**
* @param array<string,mixed> $draft
* @return array<string,mixed>
*/
private function sanitizeDraft(array $draft): array
{
return [
'race_key' => $draft['race_key'] ?? null,
'avatar_key' => $draft['avatar_key'] ?? null,
'title' => $draft['title'] ?? null,
];
}
private function findUserByIdentifier(string $identifier): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :id OR LOWER(email) = :email');
$stmt->execute([
'id' => $identifier,
'email' => $this->lower($identifier),
]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* @return array{username:bool,email:bool}
*/
private function findUserByUsernameOrEmail(string $username, string $email): array
{
$stmt = $this->pdo->prepare('SELECT username, email FROM users WHERE username = :username OR email = :email');
$stmt->execute([
'username' => $username,
'email' => $this->lower($email),
]);
$rows = $stmt->fetchAll();
$result = ['username' => false, 'email' => false];
foreach ($rows as $row) {
if (isset($row['username']) && $row['username'] === $username) {
$result['username'] = true;
}
if (isset($row['email']) && $this->lower((string)$row['email']) === $this->lower($email)) {
$result['email'] = true;
}
}
return $result;
}
/**
* @param array<string,mixed> $user
* @return array<string,mixed>
*/
private function buildUserSummary(array $user): array
{
return [
'id' => (int)($user['id'] ?? 0),
'username' => (string)($user['username'] ?? ''),
'email' => (string)($user['email'] ?? ''),
'race_key' => (string)($user['race_key'] ?? ''),
'title' => (string)($user['title'] ?? ''),
'avatar_key' => (string)($user['avatar_key'] ?? ''),
];
}
private function avatarExists(string $avatarKey): bool
{
$config = $this->configLoader->avatars();
$avatars = $config['avatars'] ?? [];
foreach ($avatars as $avatar) {
if (is_array($avatar) && ($avatar['key'] ?? null) === $avatarKey) {
return true;
}
}
return false;
}
private function isValidTitle(string $title): bool
{
$len = $this->length($title);
return $len >= self::TITLE_MIN_LENGTH && $len <= self::TITLE_MAX_LENGTH;
}
private function sanitizeTitle(string $title): string
{
$clean = trim(strip_tags($title));
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean;
}
private function isValidUsername(string $username): bool
{
$len = $this->length($username);
if ($len < self::USERNAME_MIN_LENGTH || $len > self::USERNAME_MAX_LENGTH) {
return false;
}
return (bool)preg_match('/^[A-Za-z0-9_\-]+$/', $username);
}
private function length(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value);
}
return strlen($value);
}
private function lower(string $value): string
{
if (function_exists('mb_strtolower')) {
return mb_strtolower($value);
}
return strtolower($value);
}
private function startSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => $secure,
'cookie_path' => '/',
]);
}
private function loginUser(int $userId): void
{
$this->startSession();
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
}
/**
* @return array<string,mixed>
*/
private function getDraft(): array
{
$this->startSession();
$draft = $_SESSION['reg_draft'] ?? [];
return is_array($draft) ? $draft : [];
}
/**
* @param array<string,mixed> $draft
*/
private function saveDraft(array $draft): void
{
$this->startSession();
$_SESSION['reg_draft'] = $draft;
}
private function clearDraft(): void
{
$this->startSession();
unset($_SESSION['reg_draft']);
}
private function assignRole(int $userId, string $roleKey): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO user_roles (user_id, role_id)
SELECT :user_id, r.id FROM roles r WHERE r.key = :role_key
ON CONFLICT DO NOTHING'
);
$stmt->execute([
'user_id' => $userId,
'role_key' => $roleKey,
]);
}
private function createStarterPlanet(int $userId): void
{
$config = $this->configLoader->planetClasses();
$resources = [];
foreach (($config['resources'] ?? []) as $res) {
$resources[$res] = 500.0;
}
$seed = random_int(10, 999999);
$generated = $this->planetGenerator->generate('temperate', 'normal', $seed);
$stmt = $this->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' => 'Heimatwelt',
'class_key' => $generated['class_key'],
'planet_seed' => $seed,
'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 = $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 NOTHING'
);
$stmt->execute([
'planet_id' => $planetId,
'building_key' => 'build_center',
'count' => 1,
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth\Controller;
use App\Config\ConfigLoader;
use App\Shared\Http\JsonResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class MetaController
{
public function __construct(private ConfigLoader $configLoader)
{
}
public function races(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$config = $this->configLoader->races();
$races = $config['races'] ?? [];
$items = [];
foreach ($races as $key => $race) {
if (!is_array($race)) {
continue;
}
$items[] = [
'key' => (string)$key,
'name' => (string)($race['name'] ?? $key),
'description' => (string)($race['description'] ?? ''),
'modifier_summary' => $this->buildModifierSummary($race['modifiers'] ?? []),
'modifiers' => $race['modifiers'] ?? [],
];
}
return JsonResponder::withJson($response, ['races' => $items]);
}
public function avatars(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$config = $this->configLoader->avatars();
$avatars = $config['avatars'] ?? [];
return JsonResponder::withJson($response, ['avatars' => $avatars]);
}
/**
* @param array<string,mixed> $modifiers
* @return string[]
*/
private function buildModifierSummary(array $modifiers): array
{
$summary = [];
$labels = [
'produce' => 'Produktion',
'consume' => 'Verbrauch',
];
$resourceNames = [
'metal' => 'Metall',
'alloy' => 'Legierung',
'crystals' => 'Kristall',
'energy' => 'Energie',
'credits' => 'Credits',
'population' => 'Bevölkerung',
'water' => 'Wasser',
'deuterium' => 'Deuterium',
'food' => 'Nahrung',
];
foreach ($labels as $section => $label) {
$entries = $modifiers[$section] ?? null;
if (!is_array($entries)) {
continue;
}
$parts = [];
foreach ($entries as $res => $val) {
if (!is_numeric($val) || (float)$val == 0.0) {
continue;
}
$percent = (float)$val * 100;
$sign = $percent >= 0 ? '+' : '';
$name = $resourceNames[$res] ?? (string)$res;
$parts[] = sprintf('%s %s%s%%', $name, $sign, $this->formatPercent($percent));
}
if ($parts) {
$summary[] = $label . ': ' . implode(', ', $parts);
}
}
return $summary;
}
private function formatPercent(float $value): string
{
$formatted = number_format($value, 1, ',', '');
if (str_ends_with($formatted, ',0')) {
return substr($formatted, 0, -2);
}
return $formatted;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Auth;
use App\Module\Auth\Controller\AuthController;
use App\Module\Auth\Controller\MetaController;
use Psr\Container\ContainerInterface;
use Slim\Interfaces\RouteCollectorProxyInterface;
final class Routes
{
public static function register(RouteCollectorProxyInterface $group, ContainerInterface $container): void
{
$auth = $container->get(AuthController::class);
$meta = $container->get(MetaController::class);
$group->post('/auth/login', [$auth, 'login']);
$group->post('/auth/logout', [$auth, 'logout']);
$group->get('/me', [$auth, 'me']);
$group->post('/auth/register/step1', [$auth, 'registerStep1']);
$group->post('/auth/register/step2', [$auth, 'registerStep2']);
$group->post('/auth/register/step3', [$auth, 'registerStep3']);
$group->get('/meta/races', [$meta, 'races']);
$group->get('/meta/avatars', [$meta, 'avatars']);
}
}

View File

@@ -18,11 +18,17 @@ final class AuthService
*/ */
public function resolveUser(ServerRequestInterface $request): ?array public function resolveUser(ServerRequestInterface $request): ?array
{ {
$this->ensureSession();
$id = $this->extractUserId($request); $id = $this->extractUserId($request);
if ($id !== null) { if ($id !== null) {
return $this->findUserById($id); return $this->findUserById($id);
} }
$sessionUserId = $this->getSessionUserId();
if ($sessionUserId !== null) {
return $this->findUserById($sessionUserId);
}
if ((int)(getenv('DEV_MODE') ?: 0) === 1) { if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
$devUserId = getenv('DEV_USER_ID'); $devUserId = getenv('DEV_USER_ID');
if ($devUserId !== false && is_numeric($devUserId)) { if ($devUserId !== false && is_numeric($devUserId)) {
@@ -54,6 +60,32 @@ final class AuthService
return null; return null;
} }
private function getSessionUserId(): ?int
{
if (!isset($_SESSION['user_id'])) {
return null;
}
$val = $_SESSION['user_id'];
if (is_numeric($val)) {
return (int)$val;
}
return null;
}
private function ensureSession(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => $secure,
'cookie_path' => '/',
]);
}
/** /**
* @return array<string,mixed>|null * @return array<string,mixed>|null
*/ */

View File

@@ -0,0 +1,182 @@
<?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 AuthFlowTest extends TestCase
{
protected function setUp(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
}
public function testRegistrationFlowCreatesUserAndLogsIn(): void
{
$pdo = TestDatabase::create();
TestDatabase::reset($pdo);
TestDatabase::seedMinimal($pdo);
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
$app = TestAppFactory::create($pdo, $time);
$factory = new ServerRequestFactory();
$cookie = null;
$step1 = $this->jsonRequest($factory, 'POST', '/auth/register/step1', [
'race_key' => 'human',
], $cookie);
$response1 = $app->handle($step1);
self::assertSame(200, $response1->getStatusCode());
$cookie = $this->extractCookie($response1, $cookie);
$body1 = json_decode((string)$response1->getBody(), true);
self::assertSame('human', $body1['draft']['race_key']);
$step2 = $this->jsonRequest($factory, 'POST', '/auth/register/step2', [
'avatar_key' => 'avatar-01',
'title' => 'Pionier',
], $cookie);
$response2 = $app->handle($step2);
self::assertSame(200, $response2->getStatusCode());
$cookie = $this->extractCookie($response2, $cookie);
$body2 = json_decode((string)$response2->getBody(), true);
self::assertSame('avatar-01', $body2['draft']['avatar_key']);
$step3 = $this->jsonRequest($factory, 'POST', '/auth/register/step3', [
'username' => 'neo',
'email' => 'neo@example.test',
'password' => 'change-me',
], $cookie);
$response3 = $app->handle($step3);
self::assertSame(201, $response3->getStatusCode());
$cookie = $this->extractCookie($response3, $cookie);
$meRequest = $factory->createServerRequest('GET', '/me');
if ($cookie) {
$meRequest = $meRequest->withHeader('Cookie', $cookie);
}
$meResponse = $app->handle($meRequest);
self::assertSame(200, $meResponse->getStatusCode());
$meBody = json_decode((string)$meResponse->getBody(), true);
self::assertSame('neo', $meBody['user']['username']);
self::assertSame('human', $meBody['user']['race_key']);
$draftCheck = $this->jsonRequest($factory, 'POST', '/auth/register/step2', [
'avatar_key' => 'avatar-01',
'title' => 'Test',
], $cookie);
$draftResponse = $app->handle($draftCheck);
self::assertSame(409, $draftResponse->getStatusCode());
}
public function testLoginSuccessAndFailure(): void
{
$pdo = TestDatabase::create();
TestDatabase::reset($pdo);
TestDatabase::seedMinimal($pdo);
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
$app = TestAppFactory::create($pdo, $time);
$factory = new ServerRequestFactory();
$badLogin = $this->jsonRequest($factory, 'POST', '/auth/login', [
'username_or_email' => 'tester',
'password' => 'wrong-pass',
]);
$badResponse = $app->handle($badLogin);
self::assertSame(401, $badResponse->getStatusCode());
$goodLogin = $this->jsonRequest($factory, 'POST', '/auth/login', [
'username_or_email' => 'tester',
'password' => 'change-me',
]);
$goodResponse = $app->handle($goodLogin);
self::assertSame(200, $goodResponse->getStatusCode());
$cookie = $this->extractCookie($goodResponse, null);
$meRequest = $factory->createServerRequest('GET', '/me');
if ($cookie) {
$meRequest = $meRequest->withHeader('Cookie', $cookie);
}
$meResponse = $app->handle($meRequest);
self::assertSame(200, $meResponse->getStatusCode());
}
public function testRegistrationRejectsDuplicates(): void
{
$pdo = TestDatabase::create();
TestDatabase::reset($pdo);
TestDatabase::seedMinimal($pdo);
$time = new FixedTimeProvider(new \DateTimeImmutable('2026-02-03 00:00:00'));
$app = TestAppFactory::create($pdo, $time);
$factory = new ServerRequestFactory();
$cookie = null;
$step1 = $this->jsonRequest($factory, 'POST', '/auth/register/step1', [
'race_key' => 'human',
], $cookie);
$response1 = $app->handle($step1);
$cookie = $this->extractCookie($response1, $cookie);
$step2 = $this->jsonRequest($factory, 'POST', '/auth/register/step2', [
'avatar_key' => 'avatar-01',
'title' => 'Pilot',
], $cookie);
$response2 = $app->handle($step2);
$cookie = $this->extractCookie($response2, $cookie);
$dupUsername = $this->jsonRequest($factory, 'POST', '/auth/register/step3', [
'username' => 'tester',
'email' => 'unique@example.test',
'password' => 'change-me',
], $cookie);
$dupResponse = $app->handle($dupUsername);
self::assertSame(409, $dupResponse->getStatusCode());
$dupEmail = $this->jsonRequest($factory, 'POST', '/auth/register/step3', [
'username' => 'unique-user',
'email' => 'tester@example.test',
'password' => 'change-me',
], $cookie);
$dupEmailResponse = $app->handle($dupEmail);
self::assertSame(409, $dupEmailResponse->getStatusCode());
}
/**
* @param array<string,mixed> $payload
*/
private function jsonRequest(ServerRequestFactory $factory, string $method, string $path, array $payload = [], ?string $cookie = null): \Psr\Http\Message\ServerRequestInterface
{
$request = $factory->createServerRequest($method, $path)
->withHeader('Content-Type', 'application/json');
if ($cookie) {
$request = $request->withHeader('Cookie', $cookie);
}
if ($payload !== []) {
$request->getBody()->write(json_encode($payload));
$request->getBody()->rewind();
}
return $request;
}
private function extractCookie(\Psr\Http\Message\ResponseInterface $response, ?string $fallback): ?string
{
$setCookie = $response->getHeaderLine('Set-Cookie');
if ($setCookie === '') {
return $fallback;
}
$parts = explode(';', $setCookie);
return trim($parts[0]);
}
}

View File

@@ -51,7 +51,18 @@ final class TestDatabase
$pdo->exec("INSERT INTO role_permissions (role_id, permission_id) $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'"); 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')"); $stmt = $pdo->prepare(
"INSERT INTO users (username, email, password_hash, race_key, title, avatar_key)
VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key)"
);
$stmt->execute([
'username' => 'tester',
'email' => 'tester@example.test',
'password_hash' => password_hash('change-me', PASSWORD_DEFAULT),
'race_key' => 'human',
'title' => 'Tester',
'avatar_key' => 'avatar-01',
]);
$userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'tester'")->fetchColumn(); $userId = (int)$pdo->query("SELECT id FROM users WHERE username = 'tester'")->fetchColumn();
$pdo->exec("INSERT INTO user_roles (user_id, role_id) $pdo->exec("INSERT INTO user_roles (user_id, role_id)

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#28f0ff"/>
<stop offset="1" stop-color="#ff3df2"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg1)"/>
<circle cx="64" cy="54" r="26" fill="rgba(0,0,0,0.35)"/>
<path d="M28 108c8-18 24-28 36-28h0c12 0 28 10 36 28" fill="rgba(0,0,0,0.35)"/>
<circle cx="64" cy="54" r="22" fill="rgba(255,255,255,0.85)"/>
<rect x="48" y="42" width="32" height="8" rx="4" fill="#0d1b26"/>
<rect x="52" y="58" width="24" height="6" rx="3" fill="#0d1b26"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ffb347"/>
<stop offset="1" stop-color="#ff5f6d"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg2)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.3)"/>
<path d="M24 108c6-16 22-26 40-26h0c18 0 34 10 40 26" fill="rgba(0,0,0,0.3)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.86)"/>
<path d="M44 56h40" stroke="#2a0d0d" stroke-width="6" stroke-linecap="round"/>
<circle cx="54" cy="48" r="5" fill="#2a0d0d"/>
<circle cx="74" cy="48" r="5" fill="#2a0d0d"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3dffb5"/>
<stop offset="1" stop-color="#2b8cff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg3)"/>
<circle cx="64" cy="50" r="26" fill="rgba(0,0,0,0.28)"/>
<path d="M22 108c10-18 26-28 42-28h0c16 0 32 10 42 28" fill="rgba(0,0,0,0.28)"/>
<circle cx="64" cy="50" r="22" fill="rgba(255,255,255,0.88)"/>
<rect x="46" y="44" width="36" height="10" rx="5" fill="#092022"/>
<rect x="48" y="60" width="32" height="6" rx="3" fill="#092022"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6dd5fa"/>
<stop offset="1" stop-color="#5b8cff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg4)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.3)"/>
<path d="M26 108c8-18 24-28 38-28h0c14 0 30 10 38 28" fill="rgba(0,0,0,0.3)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.86)"/>
<path d="M46 56h36" stroke="#0b1b2e" stroke-width="6" stroke-linecap="round"/>
<path d="M52 44h24" stroke="#0b1b2e" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg5" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff6b6b"/>
<stop offset="1" stop-color="#f7b733"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg5)"/>
<circle cx="64" cy="50" r="26" fill="rgba(0,0,0,0.32)"/>
<path d="M22 108c10-16 26-26 42-26h0c16 0 32 10 42 26" fill="rgba(0,0,0,0.32)"/>
<circle cx="64" cy="50" r="22" fill="rgba(255,255,255,0.86)"/>
<circle cx="54" cy="50" r="5" fill="#2b0d0d"/>
<circle cx="74" cy="50" r="5" fill="#2b0d0d"/>
<rect x="50" y="62" width="28" height="6" rx="3" fill="#2b0d0d"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00f5d4"/>
<stop offset="1" stop-color="#00b4d8"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg6)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.28)"/>
<path d="M24 108c8-18 24-28 40-28h0c16 0 32 10 40 28" fill="rgba(0,0,0,0.28)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.88)"/>
<rect x="46" y="46" width="36" height="8" rx="4" fill="#072127"/>
<path d="M52 60h24" stroke="#072127" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View File

@@ -416,6 +416,53 @@ button:active, .btn:active, input[type="submit"]:active{ transform: translateY(0
.actions{ display:flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; } .actions{ display:flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
/* Auth */
.auth-view{ display:flex; flex-direction:column; gap: 16px; }
.auth-card{ max-width: 720px; margin: 0 auto; width: 100%; }
.form-row{ margin-top: 12px; }
.input{
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(145,220,255,.22);
background: rgba(0,0,0,.28);
color: var(--text);
}
.input:focus{ outline: 2px solid rgba(66,245,255,.35); }
.auth-grid{
display:grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-top: 12px;
}
.auth-choice{
border: 1px solid rgba(145,220,255,.18);
background: rgba(0,0,0,.18);
border-radius: 14px;
padding: 12px;
color: var(--text);
text-align: left;
display:flex;
flex-direction:column;
gap: 6px;
cursor:pointer;
}
.auth-choice:hover{ border-color: rgba(66,245,255,.45); box-shadow: var(--glow-cyan); transform: translateY(-1px); }
.auth-choice.is-selected{
border-color: rgba(66,245,255,.55);
box-shadow: var(--glow-cyan);
background: linear-gradient(180deg, rgba(66,245,255,.16), rgba(91,124,255,.10));
}
.auth-choice-title{ font-family: var(--font-sci); letter-spacing: .6px; }
.auth-choice-meta{ color: var(--muted); font-size: .85rem; }
.auth-message{ min-height: 18px; margin-top: 8px; }
.auth-avatar{
width: 100%;
border-radius: 12px;
border: 1px solid rgba(145,220,255,.18);
background: rgba(0,0,0,.22);
}
/* Resourcebar sticky */ /* Resourcebar sticky */
.resourcebar{ .resourcebar{
position: sticky; position: sticky;

View File

@@ -119,28 +119,31 @@
}); });
}); });
const authView = document.getElementById("authView");
const gameView = document.getElementById("gameView");
let stateTimer = null;
const authPanels = {
login: document.getElementById("authLogin"),
"register-step1": document.getElementById("authRegisterStep1"),
"register-step2": document.getElementById("authRegisterStep2"),
"register-step3": document.getElementById("authRegisterStep3")
};
let racesCache = null;
let avatarsCache = null;
const regDraft = { race_key: null, avatar_key: null };
function formatNumber(val){ function formatNumber(val){
const num = Number.isFinite(val) ? val : 0; const num = Number.isFinite(val) ? val : 0;
return Math.round(num).toLocaleString("de-DE"); return Math.round(num).toLocaleString("de-DE");
} }
const resourceMap = {
"Metall": "metal",
"Kristall": "crystals",
"Deuterium": "deuterium",
"Energie": "energy"
};
function updateResourceBar(state){ function updateResourceBar(state){
const stats = document.querySelectorAll(".resource-row .stat"); const values = state?.resources || {};
stats.forEach((stat)=>{ document.querySelectorAll("[data-resource-value]").forEach((valueEl)=>{
const label = stat.querySelector(".stat-k")?.textContent?.trim(); const key = valueEl.dataset.resourceValue;
const key = resourceMap[label];
if(!key) return; if(!key) return;
const value = state?.resources?.[key]; const value = values[key];
if(typeof value !== "number") return; if(typeof value !== "number") return;
const valueEl = stat.querySelector(".stat-v");
if(!valueEl) return;
const dot = valueEl.querySelector(".dot"); const dot = valueEl.querySelector(".dot");
const display = key === "energy" ? Math.round(value) : Math.floor(value); const display = key === "energy" ? Math.round(value) : Math.floor(value);
const prefix = key === "energy" && display >= 0 ? "+" : ""; const prefix = key === "energy" && display >= 0 ? "+" : "";
@@ -150,19 +153,189 @@
}); });
} }
function updateQueuePanel(state){
const slotsEl = document.getElementById("queueSlots");
if(slotsEl){
const slots = Number.isFinite(state?.queue_slots) ? state.queue_slots : null;
slotsEl.textContent = slots === null ? "" : String(slots);
}
const list = document.getElementById("queueList");
if(!list) return;
list.textContent = "";
const jobs = Array.isArray(state?.active_build_jobs) ? state.active_build_jobs : [];
if(jobs.length === 0){
const li = document.createElement("li");
li.className = "muted";
li.textContent = "Keine aktiven Baujobs.";
list.appendChild(li);
return;
}
jobs.forEach((job)=>{
const li = document.createElement("li");
const slotIndex = Number.isFinite(Number(job?.slot_index)) ? Number(job.slot_index) + 1 : null;
const slotLabel = slotIndex ? `Slot ${slotIndex}: ` : "";
const key = job?.building_key ? String(job.building_key) : "Unbekannt";
const finish = job?.finish_at ? ` · fertig ${job.finish_at}` : "";
li.textContent = `${slotLabel}${key}${finish}`;
list.appendChild(li);
});
}
function showAuthView(){
if(authView) authView.removeAttribute("hidden");
if(gameView) gameView.setAttribute("hidden", "");
}
function showGameView(){
if(authView) authView.setAttribute("hidden", "");
if(gameView) gameView.removeAttribute("hidden");
}
function showAuthPanel(key){
Object.values(authPanels).forEach((panel)=>{
if(panel) panel.setAttribute("hidden", "");
});
const panel = authPanels[key];
if(panel) panel.removeAttribute("hidden");
}
function setMessage(el, message){
if(!el) return;
el.textContent = message || "";
}
async function fetchJson(url, options){
try{
const res = await fetch(url, options);
const data = await res.json().catch(()=> ({}));
return { ok: res.ok, status: res.status, data };
}catch(e){
return { ok: false, status: 0, data: {} };
}
}
async function loadRaces(){
if(racesCache) return racesCache;
const result = await fetchJson("/api/meta/races");
if(result.ok){
racesCache = result.data.races || [];
}else{
racesCache = [];
}
return racesCache;
}
async function loadAvatars(){
if(avatarsCache) return avatarsCache;
const result = await fetchJson("/api/meta/avatars");
if(result.ok){
avatarsCache = result.data.avatars || [];
}else{
avatarsCache = [];
}
return avatarsCache;
}
function renderRaces(races){
const list = document.getElementById("raceList");
if(!list) return;
list.textContent = "";
if(!Array.isArray(races) || races.length === 0){
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Keine Rassen verfügbar.";
list.appendChild(empty);
return;
}
races.forEach((race)=>{
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auth-choice";
btn.dataset.raceKey = race.key;
if(regDraft.race_key === race.key) btn.classList.add("is-selected");
const summary = Array.isArray(race.modifier_summary) ? race.modifier_summary : [];
btn.innerHTML = `
<div class="auth-choice-title">${escapeHtml(race.name || race.key)}</div>
<div class="auth-choice-meta">${escapeHtml(race.description || "")}</div>
${summary.length ? `<div class="auth-choice-meta">${summary.map(s=>escapeHtml(s)).join("<br>")}</div>` : ""}
`;
btn.addEventListener("click", ()=>{
regDraft.race_key = race.key;
list.querySelectorAll(".auth-choice").forEach(el=>el.classList.remove("is-selected"));
btn.classList.add("is-selected");
});
list.appendChild(btn);
});
}
function renderAvatars(avatars){
const list = document.getElementById("avatarList");
if(!list) return;
list.textContent = "";
if(!Array.isArray(avatars) || avatars.length === 0){
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Keine Avatare verfügbar.";
list.appendChild(empty);
return;
}
avatars.forEach((avatar)=>{
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auth-choice";
btn.dataset.avatarKey = avatar.key;
if(regDraft.avatar_key === avatar.key) btn.classList.add("is-selected");
btn.innerHTML = `
<img class="auth-avatar" src="${escapeHtml(avatar.image || "")}" alt="${escapeHtml(avatar.label || avatar.key)}">
<div class="auth-choice-title">${escapeHtml(avatar.label || avatar.key)}</div>
`;
btn.addEventListener("click", ()=>{
regDraft.avatar_key = avatar.key;
list.querySelectorAll(".auth-choice").forEach(el=>el.classList.remove("is-selected"));
btn.classList.add("is-selected");
});
list.appendChild(btn);
});
}
function startPolling(){
if(stateTimer) return;
stateTimer = setInterval(refreshState, 15000);
}
function stopPolling(){
if(!stateTimer) return;
clearInterval(stateTimer);
stateTimer = null;
}
async function fetchState(){ async function fetchState(){
try{ try{
const res = await fetch("/api/state"); const res = await fetch("/api/state", { credentials: "same-origin" });
if(!res.ok) return null; if(res.status === 401) return { status: 401 };
return await res.json(); if(!res.ok) return { status: res.status };
const data = await res.json();
return { status: 200, data };
}catch(e){ }catch(e){
return null; return { status: 0 };
} }
} }
async function refreshState(){ async function refreshState(){
const state = await fetchState(); const result = await fetchState();
if(state) updateResourceBar(state); if(result.status === 200){
showGameView();
updateResourceBar(result.data);
updateQueuePanel(result.data);
ensureBuildButton();
startPolling();
return;
}
if(result.status === 401){
showAuthView();
showAuthPanel("login");
stopPolling();
}
} }
function ensureBuildButton(){ function ensureBuildButton(){
@@ -198,8 +371,130 @@
} }
document.addEventListener("DOMContentLoaded", ()=>{ document.addEventListener("DOMContentLoaded", ()=>{
document.querySelectorAll("[data-auth-switch]").forEach((btn)=>{
btn.addEventListener("click", async ()=>{
const target = btn.dataset.authSwitch;
if(!target) return;
showAuthView();
showAuthPanel(target);
if(target === "register-step1"){
const races = await loadRaces();
renderRaces(races);
}
if(target === "register-step2"){
const avatars = await loadAvatars();
renderAvatars(avatars);
}
});
});
const loginForm = document.getElementById("loginForm");
if(loginForm){
loginForm.addEventListener("submit", async (e)=>{
e.preventDefault();
const identifier = document.getElementById("loginIdentifier")?.value?.trim() || "";
const password = document.getElementById("loginPassword")?.value || "";
const message = document.getElementById("loginMessage");
setMessage(message, "");
const result = await fetchJson("/api/auth/login", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ username_or_email: identifier, password })
});
if(result.ok){
showGameView();
refreshState();
}else{
setMessage(message, result.data?.message || "Login fehlgeschlagen.");
}
});
}
const raceNext = document.getElementById("raceNext");
if(raceNext){
raceNext.addEventListener("click", async ()=>{
const message = document.getElementById("raceMessage");
setMessage(message, "");
if(!regDraft.race_key){
setMessage(message, "Bitte eine Rasse wählen.");
return;
}
const result = await fetchJson("/api/auth/register/step1", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ race_key: regDraft.race_key })
});
if(result.ok){
showAuthPanel("register-step2");
const avatars = await loadAvatars();
renderAvatars(avatars);
}else{
setMessage(message, result.data?.message || "Schritt 1 fehlgeschlagen.");
}
});
}
const avatarNext = document.getElementById("avatarNext");
if(avatarNext){
avatarNext.addEventListener("click", async ()=>{
const title = document.getElementById("regTitle")?.value?.trim() || "";
const message = document.getElementById("avatarMessage");
setMessage(message, "");
if(!regDraft.avatar_key){
setMessage(message, "Bitte einen Avatar wählen.");
return;
}
if(title.length < 2){
setMessage(message, "Titel ist zu kurz.");
return;
}
const result = await fetchJson("/api/auth/register/step2", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ avatar_key: regDraft.avatar_key, title })
});
if(result.ok){
showAuthPanel("register-step3");
}else{
setMessage(message, result.data?.message || "Schritt 2 fehlgeschlagen.");
}
});
}
const registerForm = document.getElementById("registerForm");
if(registerForm){
registerForm.addEventListener("submit", async (e)=>{
e.preventDefault();
const username = document.getElementById("regUsername")?.value?.trim() || "";
const email = document.getElementById("regEmail")?.value?.trim() || "";
const password = document.getElementById("regPassword")?.value || "";
const message = document.getElementById("registerMessage");
setMessage(message, "");
const result = await fetchJson("/api/auth/register/step3", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ username, email, password })
});
if(result.ok){
showGameView();
refreshState();
}else{
setMessage(message, result.data?.message || "Registrierung fehlgeschlagen.");
}
});
}
const logoutBtn = document.getElementById("logoutBtn");
if(logoutBtn){
logoutBtn.addEventListener("click", async ()=>{
await fetchJson("/api/auth/logout", { method: "POST" });
stopPolling();
showAuthView();
showAuthPanel("login");
});
}
showAuthPanel("login");
refreshState(); refreshState();
ensureBuildButton();
setInterval(refreshState, 30000);
}); });
})(); })();

View File

@@ -103,7 +103,14 @@ $partialsPath = __DIR__ . '/../src/partials';
<div class="space-bg" aria-hidden="true"></div> <div class="space-bg" aria-hidden="true"></div>
<div class="container"> <div class="container">
<div class="app"> <div class="auth-view" id="authView" hidden>
<?php include $partialsPath . '/auth-login.php'; ?>
<?php include $partialsPath . '/auth-register-step1.php'; ?>
<?php include $partialsPath . '/auth-register-step2.php'; ?>
<?php include $partialsPath . '/auth-register-step3.php'; ?>
</div>
<div class="app" id="gameView">
<!-- LINKS: Sidebar --> <!-- LINKS: Sidebar -->
<aside class="sidebar" aria-label="Seitenleiste"> <aside class="sidebar" aria-label="Seitenleiste">
@@ -132,6 +139,7 @@ $partialsPath = __DIR__ . '/../src/partials';
🔔 <span class="badge" id="notifBadge">3</span> 🔔 <span class="badge" id="notifBadge">3</span>
</button> </button>
<button class="btn btn-primary" type="button" onclick="toast('success','Beacon','Signal empfangen ✅')">Test Toast</button> <button class="btn btn-primary" type="button" onclick="toast('success','Beacon','Signal empfangen ✅')">Test Toast</button>
<button class="btn" type="button" id="logoutBtn">Logout</button>
</div> </div>
</div> </div>
@@ -211,6 +219,14 @@ $partialsPath = __DIR__ . '/../src/partials';
</div> </div>
<?php include $partialsPath . '/site.php'; ?> <?php include $partialsPath . '/site.php'; ?>
<section class="card inner panel" id="queuePanel" style="margin-top:14px;">
<h2 class="h2">Bauqueue (Live)</h2>
<div class="muted">Slots: <span id="queueSlots">0</span></div>
<ul id="queueList">
<li class="muted">Keine aktiven Baujobs.</li>
</ul>
</section>
</main> </main>
<!-- Footer --> <!-- Footer -->

View File

@@ -0,0 +1,23 @@
<section class="card panel auth-card" id="authLogin">
<div class="panel-title">LOGIN</div>
<p class="muted">Melde dich an, um deine Kolonie zu laden.</p>
<form id="loginForm">
<div class="form-row">
<label class="label" for="loginIdentifier">Username oder E-Mail</label>
<input class="input" id="loginIdentifier" name="username_or_email" type="text" autocomplete="username" required>
</div>
<div class="form-row">
<label class="label" for="loginPassword">Passwort</label>
<input class="input" id="loginPassword" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Login</button>
<button class="btn" type="button" data-auth-switch="register-step1">Registrieren</button>
</div>
<div class="auth-message muted tiny" id="loginMessage" aria-live="polite"></div>
</form>
</section>

View File

@@ -0,0 +1,16 @@
<section class="card panel auth-card" id="authRegisterStep1" hidden>
<div class="panel-title">REGISTRIERUNG 1/3</div>
<h2 class="h2">Wähle deine Rasse</h2>
<p class="muted">Rasse bestimmt Startboni. Du kannst später über Forschungen nachsteuern.</p>
<div class="auth-grid" id="raceList">
<div class="muted">Lade Rassen ...</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="login">Zurück</button>
<button class="btn btn-primary" type="button" id="raceNext">Weiter</button>
</div>
<div class="auth-message muted tiny" id="raceMessage" aria-live="polite"></div>
</section>

View File

@@ -0,0 +1,22 @@
<section class="card panel auth-card" id="authRegisterStep2" hidden>
<div class="panel-title">REGISTRIERUNG 2/3</div>
<h2 class="h2">Avatar & Titel</h2>
<p class="muted">Wähle einen Avatar und gib deinem Captain einen Titel.</p>
<div class="auth-grid avatar-grid" id="avatarList">
<div class="muted">Lade Avatare ...</div>
</div>
<div class="form-row">
<label class="label" for="regTitle">Titel</label>
<input class="input" id="regTitle" name="title" type="text" maxlength="40" placeholder="z.B. Pionier, Architekt, Navigator" required>
<div class="muted tiny">240 Zeichen.</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="register-step1">Zurück</button>
<button class="btn btn-primary" type="button" id="avatarNext">Weiter</button>
</div>
<div class="auth-message muted tiny" id="avatarMessage" aria-live="polite"></div>
</section>

View File

@@ -0,0 +1,30 @@
<section class="card panel auth-card" id="authRegisterStep3" hidden>
<div class="panel-title">REGISTRIERUNG 3/3</div>
<h2 class="h2">Account erstellen</h2>
<p class="muted">Wähle deinen Accountnamen und sichere dein Profil.</p>
<form id="registerForm">
<div class="form-row">
<label class="label" for="regUsername">Username</label>
<input class="input" id="regUsername" name="username" type="text" autocomplete="username" required>
</div>
<div class="form-row">
<label class="label" for="regEmail">E-Mail</label>
<input class="input" id="regEmail" name="email" type="email" autocomplete="email" required>
</div>
<div class="form-row">
<label class="label" for="regPassword">Passwort</label>
<input class="input" id="regPassword" name="password" type="password" autocomplete="new-password" required>
<div class="muted tiny">Mindestens 8 Zeichen.</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="register-step2">Zurück</button>
<button class="btn btn-primary" type="submit">Account erstellen</button>
</div>
<div class="auth-message muted tiny" id="registerMessage" aria-live="polite"></div>
</form>
</section>

View File

@@ -5,24 +5,49 @@
</div> </div>
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat" data-resource="metal">
<div class="stat-k">Metall</div> <div class="stat-k">Metall</div>
<div class="stat-v"><span class="dot dot-cyan"></span> 12.340</div> <div class="stat-v" id="res-metal" data-resource-value="metal"><span class="dot dot-cyan"></span> 12.340</div>
<div class="stat-bar"><span style="width:72%"></span></div> <div class="stat-bar"><span style="width:72%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="crystals">
<div class="stat-k">Kristall</div> <div class="stat-k">Kristall</div>
<div class="stat-v"><span class="dot dot-pink"></span> 6.120</div> <div class="stat-v" id="res-crystals" data-resource-value="crystals"><span class="dot dot-pink"></span> 6.120</div>
<div class="stat-bar"><span style="width:44%"></span></div> <div class="stat-bar"><span style="width:44%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="deuterium">
<div class="stat-k">Deuterium</div> <div class="stat-k">Deuterium</div>
<div class="stat-v"><span class="dot dot-green"></span> 3.880</div> <div class="stat-v" id="res-deuterium" data-resource-value="deuterium"><span class="dot dot-green"></span> 3.880</div>
<div class="stat-bar"><span style="width:18%"></span></div> <div class="stat-bar"><span style="width:18%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="energy">
<div class="stat-k">Energie</div> <div class="stat-k">Energie</div>
<div class="stat-v"><span class="dot dot-warn"></span> +120</div> <div class="stat-v" id="res-energy" data-resource-value="energy"><span class="dot dot-warn"></span> +120</div>
<div class="stat-bar"><span style="width:60%"></span></div>
</div>
<div class="stat" data-resource="alloy">
<div class="stat-k">Legierung</div>
<div class="stat-v" id="res-alloy" data-resource-value="alloy"><span class="dot dot-cyan"></span> 0</div>
<div class="stat-bar"><span style="width:40%"></span></div>
</div>
<div class="stat" data-resource="credits">
<div class="stat-k">Credits</div>
<div class="stat-v" id="res-credits" data-resource-value="credits"><span class="dot dot-pink"></span> 0</div>
<div class="stat-bar"><span style="width:35%"></span></div>
</div>
<div class="stat" data-resource="population">
<div class="stat-k">Bevölkerung</div>
<div class="stat-v" id="res-population" data-resource-value="population"><span class="dot dot-green"></span> 0</div>
<div class="stat-bar"><span style="width:55%"></span></div>
</div>
<div class="stat" data-resource="water">
<div class="stat-k">Wasser</div>
<div class="stat-v" id="res-water" data-resource-value="water"><span class="dot dot-cyan"></span> 0</div>
<div class="stat-bar"><span style="width:30%"></span></div>
</div>
<div class="stat" data-resource="food">
<div class="stat-k">Nahrung</div>
<div class="stat-v" id="res-food" data-resource-value="food"><span class="dot dot-green"></span> 0</div>
<div class="stat-bar"><span style="width:60%"></span></div> <div class="stat-bar"><span style="width:60%"></span></div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#28f0ff"/>
<stop offset="1" stop-color="#ff3df2"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg1)"/>
<circle cx="64" cy="54" r="26" fill="rgba(0,0,0,0.35)"/>
<path d="M28 108c8-18 24-28 36-28h0c12 0 28 10 36 28" fill="rgba(0,0,0,0.35)"/>
<circle cx="64" cy="54" r="22" fill="rgba(255,255,255,0.85)"/>
<rect x="48" y="42" width="32" height="8" rx="4" fill="#0d1b26"/>
<rect x="52" y="58" width="24" height="6" rx="3" fill="#0d1b26"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ffb347"/>
<stop offset="1" stop-color="#ff5f6d"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg2)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.3)"/>
<path d="M24 108c6-16 22-26 40-26h0c18 0 34 10 40 26" fill="rgba(0,0,0,0.3)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.86)"/>
<path d="M44 56h40" stroke="#2a0d0d" stroke-width="6" stroke-linecap="round"/>
<circle cx="54" cy="48" r="5" fill="#2a0d0d"/>
<circle cx="74" cy="48" r="5" fill="#2a0d0d"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3dffb5"/>
<stop offset="1" stop-color="#2b8cff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg3)"/>
<circle cx="64" cy="50" r="26" fill="rgba(0,0,0,0.28)"/>
<path d="M22 108c10-18 26-28 42-28h0c16 0 32 10 42 28" fill="rgba(0,0,0,0.28)"/>
<circle cx="64" cy="50" r="22" fill="rgba(255,255,255,0.88)"/>
<rect x="46" y="44" width="36" height="10" rx="5" fill="#092022"/>
<rect x="48" y="60" width="32" height="6" rx="3" fill="#092022"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6dd5fa"/>
<stop offset="1" stop-color="#5b8cff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg4)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.3)"/>
<path d="M26 108c8-18 24-28 38-28h0c14 0 30 10 38 28" fill="rgba(0,0,0,0.3)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.86)"/>
<path d="M46 56h36" stroke="#0b1b2e" stroke-width="6" stroke-linecap="round"/>
<path d="M52 44h24" stroke="#0b1b2e" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg5" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff6b6b"/>
<stop offset="1" stop-color="#f7b733"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg5)"/>
<circle cx="64" cy="50" r="26" fill="rgba(0,0,0,0.32)"/>
<path d="M22 108c10-16 26-26 42-26h0c16 0 32 10 42 26" fill="rgba(0,0,0,0.32)"/>
<circle cx="64" cy="50" r="22" fill="rgba(255,255,255,0.86)"/>
<circle cx="54" cy="50" r="5" fill="#2b0d0d"/>
<circle cx="74" cy="50" r="5" fill="#2b0d0d"/>
<rect x="50" y="62" width="28" height="6" rx="3" fill="#2b0d0d"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00f5d4"/>
<stop offset="1" stop-color="#00b4d8"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="20" fill="url(#bg6)"/>
<circle cx="64" cy="52" r="26" fill="rgba(0,0,0,0.28)"/>
<path d="M24 108c8-18 24-28 40-28h0c16 0 32 10 40 28" fill="rgba(0,0,0,0.28)"/>
<circle cx="64" cy="52" r="22" fill="rgba(255,255,255,0.88)"/>
<rect x="46" y="46" width="36" height="8" rx="4" fill="#072127"/>
<path d="M52 60h24" stroke="#072127" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View File

@@ -416,6 +416,53 @@ button:active, .btn:active, input[type="submit"]:active{ transform: translateY(0
.actions{ display:flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; } .actions{ display:flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
/* Auth */
.auth-view{ display:flex; flex-direction:column; gap: 16px; }
.auth-card{ max-width: 720px; margin: 0 auto; width: 100%; }
.form-row{ margin-top: 12px; }
.input{
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(145,220,255,.22);
background: rgba(0,0,0,.28);
color: var(--text);
}
.input:focus{ outline: 2px solid rgba(66,245,255,.35); }
.auth-grid{
display:grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
margin-top: 12px;
}
.auth-choice{
border: 1px solid rgba(145,220,255,.18);
background: rgba(0,0,0,.18);
border-radius: 14px;
padding: 12px;
color: var(--text);
text-align: left;
display:flex;
flex-direction:column;
gap: 6px;
cursor:pointer;
}
.auth-choice:hover{ border-color: rgba(66,245,255,.45); box-shadow: var(--glow-cyan); transform: translateY(-1px); }
.auth-choice.is-selected{
border-color: rgba(66,245,255,.55);
box-shadow: var(--glow-cyan);
background: linear-gradient(180deg, rgba(66,245,255,.16), rgba(91,124,255,.10));
}
.auth-choice-title{ font-family: var(--font-sci); letter-spacing: .6px; }
.auth-choice-meta{ color: var(--muted); font-size: .85rem; }
.auth-message{ min-height: 18px; margin-top: 8px; }
.auth-avatar{
width: 100%;
border-radius: 12px;
border: 1px solid rgba(145,220,255,.18);
background: rgba(0,0,0,.22);
}
/* Resourcebar sticky */ /* Resourcebar sticky */
.resourcebar{ .resourcebar{
position: sticky; position: sticky;

View File

@@ -119,28 +119,31 @@
}); });
}); });
const authView = document.getElementById("authView");
const gameView = document.getElementById("gameView");
let stateTimer = null;
const authPanels = {
login: document.getElementById("authLogin"),
"register-step1": document.getElementById("authRegisterStep1"),
"register-step2": document.getElementById("authRegisterStep2"),
"register-step3": document.getElementById("authRegisterStep3")
};
let racesCache = null;
let avatarsCache = null;
const regDraft = { race_key: null, avatar_key: null };
function formatNumber(val){ function formatNumber(val){
const num = Number.isFinite(val) ? val : 0; const num = Number.isFinite(val) ? val : 0;
return Math.round(num).toLocaleString("de-DE"); return Math.round(num).toLocaleString("de-DE");
} }
const resourceMap = {
"Metall": "metal",
"Kristall": "crystals",
"Deuterium": "deuterium",
"Energie": "energy"
};
function updateResourceBar(state){ function updateResourceBar(state){
const stats = document.querySelectorAll(".resource-row .stat"); const values = state?.resources || {};
stats.forEach((stat)=>{ document.querySelectorAll("[data-resource-value]").forEach((valueEl)=>{
const label = stat.querySelector(".stat-k")?.textContent?.trim(); const key = valueEl.dataset.resourceValue;
const key = resourceMap[label];
if(!key) return; if(!key) return;
const value = state?.resources?.[key]; const value = values[key];
if(typeof value !== "number") return; if(typeof value !== "number") return;
const valueEl = stat.querySelector(".stat-v");
if(!valueEl) return;
const dot = valueEl.querySelector(".dot"); const dot = valueEl.querySelector(".dot");
const display = key === "energy" ? Math.round(value) : Math.floor(value); const display = key === "energy" ? Math.round(value) : Math.floor(value);
const prefix = key === "energy" && display >= 0 ? "+" : ""; const prefix = key === "energy" && display >= 0 ? "+" : "";
@@ -150,19 +153,189 @@
}); });
} }
function updateQueuePanel(state){
const slotsEl = document.getElementById("queueSlots");
if(slotsEl){
const slots = Number.isFinite(state?.queue_slots) ? state.queue_slots : null;
slotsEl.textContent = slots === null ? "" : String(slots);
}
const list = document.getElementById("queueList");
if(!list) return;
list.textContent = "";
const jobs = Array.isArray(state?.active_build_jobs) ? state.active_build_jobs : [];
if(jobs.length === 0){
const li = document.createElement("li");
li.className = "muted";
li.textContent = "Keine aktiven Baujobs.";
list.appendChild(li);
return;
}
jobs.forEach((job)=>{
const li = document.createElement("li");
const slotIndex = Number.isFinite(Number(job?.slot_index)) ? Number(job.slot_index) + 1 : null;
const slotLabel = slotIndex ? `Slot ${slotIndex}: ` : "";
const key = job?.building_key ? String(job.building_key) : "Unbekannt";
const finish = job?.finish_at ? ` · fertig ${job.finish_at}` : "";
li.textContent = `${slotLabel}${key}${finish}`;
list.appendChild(li);
});
}
function showAuthView(){
if(authView) authView.removeAttribute("hidden");
if(gameView) gameView.setAttribute("hidden", "");
}
function showGameView(){
if(authView) authView.setAttribute("hidden", "");
if(gameView) gameView.removeAttribute("hidden");
}
function showAuthPanel(key){
Object.values(authPanels).forEach((panel)=>{
if(panel) panel.setAttribute("hidden", "");
});
const panel = authPanels[key];
if(panel) panel.removeAttribute("hidden");
}
function setMessage(el, message){
if(!el) return;
el.textContent = message || "";
}
async function fetchJson(url, options){
try{
const res = await fetch(url, options);
const data = await res.json().catch(()=> ({}));
return { ok: res.ok, status: res.status, data };
}catch(e){
return { ok: false, status: 0, data: {} };
}
}
async function loadRaces(){
if(racesCache) return racesCache;
const result = await fetchJson("/api/meta/races");
if(result.ok){
racesCache = result.data.races || [];
}else{
racesCache = [];
}
return racesCache;
}
async function loadAvatars(){
if(avatarsCache) return avatarsCache;
const result = await fetchJson("/api/meta/avatars");
if(result.ok){
avatarsCache = result.data.avatars || [];
}else{
avatarsCache = [];
}
return avatarsCache;
}
function renderRaces(races){
const list = document.getElementById("raceList");
if(!list) return;
list.textContent = "";
if(!Array.isArray(races) || races.length === 0){
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Keine Rassen verfügbar.";
list.appendChild(empty);
return;
}
races.forEach((race)=>{
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auth-choice";
btn.dataset.raceKey = race.key;
if(regDraft.race_key === race.key) btn.classList.add("is-selected");
const summary = Array.isArray(race.modifier_summary) ? race.modifier_summary : [];
btn.innerHTML = `
<div class="auth-choice-title">${escapeHtml(race.name || race.key)}</div>
<div class="auth-choice-meta">${escapeHtml(race.description || "")}</div>
${summary.length ? `<div class="auth-choice-meta">${summary.map(s=>escapeHtml(s)).join("<br>")}</div>` : ""}
`;
btn.addEventListener("click", ()=>{
regDraft.race_key = race.key;
list.querySelectorAll(".auth-choice").forEach(el=>el.classList.remove("is-selected"));
btn.classList.add("is-selected");
});
list.appendChild(btn);
});
}
function renderAvatars(avatars){
const list = document.getElementById("avatarList");
if(!list) return;
list.textContent = "";
if(!Array.isArray(avatars) || avatars.length === 0){
const empty = document.createElement("div");
empty.className = "muted";
empty.textContent = "Keine Avatare verfügbar.";
list.appendChild(empty);
return;
}
avatars.forEach((avatar)=>{
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auth-choice";
btn.dataset.avatarKey = avatar.key;
if(regDraft.avatar_key === avatar.key) btn.classList.add("is-selected");
btn.innerHTML = `
<img class="auth-avatar" src="${escapeHtml(avatar.image || "")}" alt="${escapeHtml(avatar.label || avatar.key)}">
<div class="auth-choice-title">${escapeHtml(avatar.label || avatar.key)}</div>
`;
btn.addEventListener("click", ()=>{
regDraft.avatar_key = avatar.key;
list.querySelectorAll(".auth-choice").forEach(el=>el.classList.remove("is-selected"));
btn.classList.add("is-selected");
});
list.appendChild(btn);
});
}
function startPolling(){
if(stateTimer) return;
stateTimer = setInterval(refreshState, 15000);
}
function stopPolling(){
if(!stateTimer) return;
clearInterval(stateTimer);
stateTimer = null;
}
async function fetchState(){ async function fetchState(){
try{ try{
const res = await fetch("/api/state"); const res = await fetch("/api/state", { credentials: "same-origin" });
if(!res.ok) return null; if(res.status === 401) return { status: 401 };
return await res.json(); if(!res.ok) return { status: res.status };
const data = await res.json();
return { status: 200, data };
}catch(e){ }catch(e){
return null; return { status: 0 };
} }
} }
async function refreshState(){ async function refreshState(){
const state = await fetchState(); const result = await fetchState();
if(state) updateResourceBar(state); if(result.status === 200){
showGameView();
updateResourceBar(result.data);
updateQueuePanel(result.data);
ensureBuildButton();
startPolling();
return;
}
if(result.status === 401){
showAuthView();
showAuthPanel("login");
stopPolling();
}
} }
function ensureBuildButton(){ function ensureBuildButton(){
@@ -198,8 +371,130 @@
} }
document.addEventListener("DOMContentLoaded", ()=>{ document.addEventListener("DOMContentLoaded", ()=>{
document.querySelectorAll("[data-auth-switch]").forEach((btn)=>{
btn.addEventListener("click", async ()=>{
const target = btn.dataset.authSwitch;
if(!target) return;
showAuthView();
showAuthPanel(target);
if(target === "register-step1"){
const races = await loadRaces();
renderRaces(races);
}
if(target === "register-step2"){
const avatars = await loadAvatars();
renderAvatars(avatars);
}
});
});
const loginForm = document.getElementById("loginForm");
if(loginForm){
loginForm.addEventListener("submit", async (e)=>{
e.preventDefault();
const identifier = document.getElementById("loginIdentifier")?.value?.trim() || "";
const password = document.getElementById("loginPassword")?.value || "";
const message = document.getElementById("loginMessage");
setMessage(message, "");
const result = await fetchJson("/api/auth/login", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ username_or_email: identifier, password })
});
if(result.ok){
showGameView();
refreshState();
}else{
setMessage(message, result.data?.message || "Login fehlgeschlagen.");
}
});
}
const raceNext = document.getElementById("raceNext");
if(raceNext){
raceNext.addEventListener("click", async ()=>{
const message = document.getElementById("raceMessage");
setMessage(message, "");
if(!regDraft.race_key){
setMessage(message, "Bitte eine Rasse wählen.");
return;
}
const result = await fetchJson("/api/auth/register/step1", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ race_key: regDraft.race_key })
});
if(result.ok){
showAuthPanel("register-step2");
const avatars = await loadAvatars();
renderAvatars(avatars);
}else{
setMessage(message, result.data?.message || "Schritt 1 fehlgeschlagen.");
}
});
}
const avatarNext = document.getElementById("avatarNext");
if(avatarNext){
avatarNext.addEventListener("click", async ()=>{
const title = document.getElementById("regTitle")?.value?.trim() || "";
const message = document.getElementById("avatarMessage");
setMessage(message, "");
if(!regDraft.avatar_key){
setMessage(message, "Bitte einen Avatar wählen.");
return;
}
if(title.length < 2){
setMessage(message, "Titel ist zu kurz.");
return;
}
const result = await fetchJson("/api/auth/register/step2", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ avatar_key: regDraft.avatar_key, title })
});
if(result.ok){
showAuthPanel("register-step3");
}else{
setMessage(message, result.data?.message || "Schritt 2 fehlgeschlagen.");
}
});
}
const registerForm = document.getElementById("registerForm");
if(registerForm){
registerForm.addEventListener("submit", async (e)=>{
e.preventDefault();
const username = document.getElementById("regUsername")?.value?.trim() || "";
const email = document.getElementById("regEmail")?.value?.trim() || "";
const password = document.getElementById("regPassword")?.value || "";
const message = document.getElementById("registerMessage");
setMessage(message, "");
const result = await fetchJson("/api/auth/register/step3", {
method: "POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify({ username, email, password })
});
if(result.ok){
showGameView();
refreshState();
}else{
setMessage(message, result.data?.message || "Registrierung fehlgeschlagen.");
}
});
}
const logoutBtn = document.getElementById("logoutBtn");
if(logoutBtn){
logoutBtn.addEventListener("click", async ()=>{
await fetchJson("/api/auth/logout", { method: "POST" });
stopPolling();
showAuthView();
showAuthPanel("login");
});
}
showAuthPanel("login");
refreshState(); refreshState();
ensureBuildButton();
setInterval(refreshState, 30000);
}); });
})(); })();

View File

@@ -103,7 +103,14 @@ $partialsPath = __DIR__ . '/../src/partials';
<div class="space-bg" aria-hidden="true"></div> <div class="space-bg" aria-hidden="true"></div>
<div class="container"> <div class="container">
<div class="app"> <div class="auth-view" id="authView" hidden>
<?php include $partialsPath . '/auth-login.php'; ?>
<?php include $partialsPath . '/auth-register-step1.php'; ?>
<?php include $partialsPath . '/auth-register-step2.php'; ?>
<?php include $partialsPath . '/auth-register-step3.php'; ?>
</div>
<div class="app" id="gameView">
<!-- LINKS: Sidebar --> <!-- LINKS: Sidebar -->
<aside class="sidebar" aria-label="Seitenleiste"> <aside class="sidebar" aria-label="Seitenleiste">
@@ -132,6 +139,7 @@ $partialsPath = __DIR__ . '/../src/partials';
🔔 <span class="badge" id="notifBadge">3</span> 🔔 <span class="badge" id="notifBadge">3</span>
</button> </button>
<button class="btn btn-primary" type="button" onclick="toast('success','Beacon','Signal empfangen ✅')">Test Toast</button> <button class="btn btn-primary" type="button" onclick="toast('success','Beacon','Signal empfangen ✅')">Test Toast</button>
<button class="btn" type="button" id="logoutBtn">Logout</button>
</div> </div>
</div> </div>
@@ -211,6 +219,14 @@ $partialsPath = __DIR__ . '/../src/partials';
</div> </div>
<?php include $partialsPath . '/site.php'; ?> <?php include $partialsPath . '/site.php'; ?>
<section class="card inner panel" id="queuePanel" style="margin-top:14px;">
<h2 class="h2">Bauqueue (Live)</h2>
<div class="muted">Slots: <span id="queueSlots">0</span></div>
<ul id="queueList">
<li class="muted">Keine aktiven Baujobs.</li>
</ul>
</section>
</main> </main>
<!-- Footer --> <!-- Footer -->

View File

@@ -0,0 +1,23 @@
<section class="card panel auth-card" id="authLogin">
<div class="panel-title">LOGIN</div>
<p class="muted">Melde dich an, um deine Kolonie zu laden.</p>
<form id="loginForm">
<div class="form-row">
<label class="label" for="loginIdentifier">Username oder E-Mail</label>
<input class="input" id="loginIdentifier" name="username_or_email" type="text" autocomplete="username" required>
</div>
<div class="form-row">
<label class="label" for="loginPassword">Passwort</label>
<input class="input" id="loginPassword" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Login</button>
<button class="btn" type="button" data-auth-switch="register-step1">Registrieren</button>
</div>
<div class="auth-message muted tiny" id="loginMessage" aria-live="polite"></div>
</form>
</section>

View File

@@ -0,0 +1,16 @@
<section class="card panel auth-card" id="authRegisterStep1" hidden>
<div class="panel-title">REGISTRIERUNG 1/3</div>
<h2 class="h2">Wähle deine Rasse</h2>
<p class="muted">Rasse bestimmt Startboni. Du kannst später über Forschungen nachsteuern.</p>
<div class="auth-grid" id="raceList">
<div class="muted">Lade Rassen ...</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="login">Zurück</button>
<button class="btn btn-primary" type="button" id="raceNext">Weiter</button>
</div>
<div class="auth-message muted tiny" id="raceMessage" aria-live="polite"></div>
</section>

View File

@@ -0,0 +1,22 @@
<section class="card panel auth-card" id="authRegisterStep2" hidden>
<div class="panel-title">REGISTRIERUNG 2/3</div>
<h2 class="h2">Avatar & Titel</h2>
<p class="muted">Wähle einen Avatar und gib deinem Captain einen Titel.</p>
<div class="auth-grid avatar-grid" id="avatarList">
<div class="muted">Lade Avatare ...</div>
</div>
<div class="form-row">
<label class="label" for="regTitle">Titel</label>
<input class="input" id="regTitle" name="title" type="text" maxlength="40" placeholder="z.B. Pionier, Architekt, Navigator" required>
<div class="muted tiny">240 Zeichen.</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="register-step1">Zurück</button>
<button class="btn btn-primary" type="button" id="avatarNext">Weiter</button>
</div>
<div class="auth-message muted tiny" id="avatarMessage" aria-live="polite"></div>
</section>

View File

@@ -0,0 +1,30 @@
<section class="card panel auth-card" id="authRegisterStep3" hidden>
<div class="panel-title">REGISTRIERUNG 3/3</div>
<h2 class="h2">Account erstellen</h2>
<p class="muted">Wähle deinen Accountnamen und sichere dein Profil.</p>
<form id="registerForm">
<div class="form-row">
<label class="label" for="regUsername">Username</label>
<input class="input" id="regUsername" name="username" type="text" autocomplete="username" required>
</div>
<div class="form-row">
<label class="label" for="regEmail">E-Mail</label>
<input class="input" id="regEmail" name="email" type="email" autocomplete="email" required>
</div>
<div class="form-row">
<label class="label" for="regPassword">Passwort</label>
<input class="input" id="regPassword" name="password" type="password" autocomplete="new-password" required>
<div class="muted tiny">Mindestens 8 Zeichen.</div>
</div>
<div class="actions">
<button class="btn" type="button" data-auth-switch="register-step2">Zurück</button>
<button class="btn btn-primary" type="submit">Account erstellen</button>
</div>
<div class="auth-message muted tiny" id="registerMessage" aria-live="polite"></div>
</form>
</section>

View File

@@ -5,24 +5,49 @@
</div> </div>
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat" data-resource="metal">
<div class="stat-k">Metall</div> <div class="stat-k">Metall</div>
<div class="stat-v"><span class="dot dot-cyan"></span> 12.340</div> <div class="stat-v" id="res-metal" data-resource-value="metal"><span class="dot dot-cyan"></span> 12.340</div>
<div class="stat-bar"><span style="width:72%"></span></div> <div class="stat-bar"><span style="width:72%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="crystals">
<div class="stat-k">Kristall</div> <div class="stat-k">Kristall</div>
<div class="stat-v"><span class="dot dot-pink"></span> 6.120</div> <div class="stat-v" id="res-crystals" data-resource-value="crystals"><span class="dot dot-pink"></span> 6.120</div>
<div class="stat-bar"><span style="width:44%"></span></div> <div class="stat-bar"><span style="width:44%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="deuterium">
<div class="stat-k">Deuterium</div> <div class="stat-k">Deuterium</div>
<div class="stat-v"><span class="dot dot-green"></span> 3.880</div> <div class="stat-v" id="res-deuterium" data-resource-value="deuterium"><span class="dot dot-green"></span> 3.880</div>
<div class="stat-bar"><span style="width:18%"></span></div> <div class="stat-bar"><span style="width:18%"></span></div>
</div> </div>
<div class="stat"> <div class="stat" data-resource="energy">
<div class="stat-k">Energie</div> <div class="stat-k">Energie</div>
<div class="stat-v"><span class="dot dot-warn"></span> +120</div> <div class="stat-v" id="res-energy" data-resource-value="energy"><span class="dot dot-warn"></span> +120</div>
<div class="stat-bar"><span style="width:60%"></span></div>
</div>
<div class="stat" data-resource="alloy">
<div class="stat-k">Legierung</div>
<div class="stat-v" id="res-alloy" data-resource-value="alloy"><span class="dot dot-cyan"></span> 0</div>
<div class="stat-bar"><span style="width:40%"></span></div>
</div>
<div class="stat" data-resource="credits">
<div class="stat-k">Credits</div>
<div class="stat-v" id="res-credits" data-resource-value="credits"><span class="dot dot-pink"></span> 0</div>
<div class="stat-bar"><span style="width:35%"></span></div>
</div>
<div class="stat" data-resource="population">
<div class="stat-k">Bevölkerung</div>
<div class="stat-v" id="res-population" data-resource-value="population"><span class="dot dot-green"></span> 0</div>
<div class="stat-bar"><span style="width:55%"></span></div>
</div>
<div class="stat" data-resource="water">
<div class="stat-k">Wasser</div>
<div class="stat-v" id="res-water" data-resource-value="water"><span class="dot dot-cyan"></span> 0</div>
<div class="stat-bar"><span style="width:30%"></span></div>
</div>
<div class="stat" data-resource="food">
<div class="stat-k">Nahrung</div>
<div class="stat-v" id="res-food" data-resource-value="food"><span class="dot dot-green"></span> 0</div>
<div class="stat-bar"><span style="width:60%"></span></div> <div class="stat-bar"><span style="width:60%"></span></div>
</div> </div>
</div> </div>