uploads
10
config/avatars.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"races": {
|
||||
"human": {
|
||||
"name": "Mensch",
|
||||
"description": "Ausgewogene Spezies mit leichtem Kreditbonus.",
|
||||
"modifiers": {
|
||||
"produce": {
|
||||
"credits": 0.02
|
||||
@@ -11,6 +12,7 @@
|
||||
},
|
||||
"robot": {
|
||||
"name": "Roboter",
|
||||
"description": "Effiziente Maschinen mit Fokus auf Metallproduktion.",
|
||||
"modifiers": {
|
||||
"produce": {
|
||||
"metal": 0.05
|
||||
|
||||
3022
server/composer.lock
generated
Normal file
@@ -1,7 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
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()
|
||||
);
|
||||
|
||||
|
||||
@@ -47,8 +47,19 @@ try {
|
||||
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']);
|
||||
$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)
|
||||
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();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Config\ConfigValidator;
|
||||
use App\Database\ConnectionFactory;
|
||||
use App\Module\Auth\Middleware\AuthContextMiddleware;
|
||||
use App\Module\Auth\Service\AuthService;
|
||||
use App\Module\Auth\Routes as AuthRoutes;
|
||||
use App\Module\BuildQueue\Routes as BuildQueueRoutes;
|
||||
use App\Module\Economy\Routes as EconomyRoutes;
|
||||
use App\Module\PlanetGenerator\Routes as PlanetGeneratorRoutes;
|
||||
@@ -60,6 +61,7 @@ final class Bootstrap
|
||||
$app->addBodyParsingMiddleware();
|
||||
|
||||
$app->group('', function (RouteCollectorProxyInterface $group) use ($container) {
|
||||
AuthRoutes::register($group, $container);
|
||||
EconomyRoutes::register($group, $container);
|
||||
BuildQueueRoutes::register($group, $container);
|
||||
PlanetGeneratorRoutes::register($group, $container);
|
||||
|
||||
@@ -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
|
||||
* @return array<string,mixed>
|
||||
|
||||
@@ -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[]
|
||||
*/
|
||||
|
||||
475
server/src/Module/Auth/Controller/AuthController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
server/src/Module/Auth/Controller/MetaController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
server/src/Module/Auth/Routes.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,17 @@ final class AuthService
|
||||
*/
|
||||
public function resolveUser(ServerRequestInterface $request): ?array
|
||||
{
|
||||
$this->ensureSession();
|
||||
$id = $this->extractUserId($request);
|
||||
if ($id !== null) {
|
||||
return $this->findUserById($id);
|
||||
}
|
||||
|
||||
$sessionUserId = $this->getSessionUserId();
|
||||
if ($sessionUserId !== null) {
|
||||
return $this->findUserById($sessionUserId);
|
||||
}
|
||||
|
||||
if ((int)(getenv('DEV_MODE') ?: 0) === 1) {
|
||||
$devUserId = getenv('DEV_USER_ID');
|
||||
if ($devUserId !== false && is_numeric($devUserId)) {
|
||||
@@ -54,6 +60,32 @@ final class AuthService
|
||||
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
|
||||
*/
|
||||
|
||||
182
server/tests/Integration/AuthFlowTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,18 @@ final class TestDatabase
|
||||
$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')");
|
||||
$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();
|
||||
|
||||
$pdo->exec("INSERT INTO user_roles (user_id, role_id)
|
||||
|
||||
14
web/desktop/public/assets/avatars/avatar-01.svg
Normal 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 |
15
web/desktop/public/assets/avatars/avatar-02.svg
Normal 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 |
14
web/desktop/public/assets/avatars/avatar-03.svg
Normal 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 |
14
web/desktop/public/assets/avatars/avatar-04.svg
Normal 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 |
15
web/desktop/public/assets/avatars/avatar-05.svg
Normal 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 |
14
web/desktop/public/assets/avatars/avatar-06.svg
Normal 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 |
@@ -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; }
|
||||
|
||||
/* 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{
|
||||
position: sticky;
|
||||
|
||||
@@ -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){
|
||||
const num = Number.isFinite(val) ? val : 0;
|
||||
return Math.round(num).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
"Metall": "metal",
|
||||
"Kristall": "crystals",
|
||||
"Deuterium": "deuterium",
|
||||
"Energie": "energy"
|
||||
};
|
||||
|
||||
function updateResourceBar(state){
|
||||
const stats = document.querySelectorAll(".resource-row .stat");
|
||||
stats.forEach((stat)=>{
|
||||
const label = stat.querySelector(".stat-k")?.textContent?.trim();
|
||||
const key = resourceMap[label];
|
||||
const values = state?.resources || {};
|
||||
document.querySelectorAll("[data-resource-value]").forEach((valueEl)=>{
|
||||
const key = valueEl.dataset.resourceValue;
|
||||
if(!key) return;
|
||||
const value = state?.resources?.[key];
|
||||
const value = values[key];
|
||||
if(typeof value !== "number") return;
|
||||
const valueEl = stat.querySelector(".stat-v");
|
||||
if(!valueEl) return;
|
||||
const dot = valueEl.querySelector(".dot");
|
||||
const display = key === "energy" ? Math.round(value) : Math.floor(value);
|
||||
const prefix = key === "energy" && display >= 0 ? "+" : "";
|
||||
@@ -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(){
|
||||
try{
|
||||
const res = await fetch("/api/state");
|
||||
if(!res.ok) return null;
|
||||
return await res.json();
|
||||
const res = await fetch("/api/state", { credentials: "same-origin" });
|
||||
if(res.status === 401) return { status: 401 };
|
||||
if(!res.ok) return { status: res.status };
|
||||
const data = await res.json();
|
||||
return { status: 200, data };
|
||||
}catch(e){
|
||||
return null;
|
||||
return { status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshState(){
|
||||
const state = await fetchState();
|
||||
if(state) updateResourceBar(state);
|
||||
const result = await fetchState();
|
||||
if(result.status === 200){
|
||||
showGameView();
|
||||
updateResourceBar(result.data);
|
||||
updateQueuePanel(result.data);
|
||||
ensureBuildButton();
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
if(result.status === 401){
|
||||
showAuthView();
|
||||
showAuthPanel("login");
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBuildButton(){
|
||||
@@ -198,8 +371,130 @@
|
||||
}
|
||||
|
||||
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();
|
||||
ensureBuildButton();
|
||||
setInterval(refreshState, 30000);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -103,7 +103,14 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
<div class="space-bg" aria-hidden="true"></div>
|
||||
|
||||
<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 -->
|
||||
<aside class="sidebar" aria-label="Seitenleiste">
|
||||
@@ -132,6 +139,7 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
🔔 <span class="badge" id="notifBadge">3</span>
|
||||
</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>
|
||||
|
||||
@@ -211,6 +219,14 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
</div>
|
||||
|
||||
<?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>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
23
web/desktop/src/partials/auth-login.php
Normal 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>
|
||||
16
web/desktop/src/partials/auth-register-step1.php
Normal 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>
|
||||
22
web/desktop/src/partials/auth-register-step2.php
Normal 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">2–40 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>
|
||||
30
web/desktop/src/partials/auth-register-step3.php
Normal 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>
|
||||
@@ -5,24 +5,49 @@
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="metal">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="crystals">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="deuterium">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="energy">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
14
web/mobile/public/assets/avatars/avatar-01.svg
Normal 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 |
15
web/mobile/public/assets/avatars/avatar-02.svg
Normal 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 |
14
web/mobile/public/assets/avatars/avatar-03.svg
Normal 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 |
14
web/mobile/public/assets/avatars/avatar-04.svg
Normal 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 |
15
web/mobile/public/assets/avatars/avatar-05.svg
Normal 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 |
14
web/mobile/public/assets/avatars/avatar-06.svg
Normal 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 |
@@ -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; }
|
||||
|
||||
/* 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{
|
||||
position: sticky;
|
||||
|
||||
@@ -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){
|
||||
const num = Number.isFinite(val) ? val : 0;
|
||||
return Math.round(num).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
const resourceMap = {
|
||||
"Metall": "metal",
|
||||
"Kristall": "crystals",
|
||||
"Deuterium": "deuterium",
|
||||
"Energie": "energy"
|
||||
};
|
||||
|
||||
function updateResourceBar(state){
|
||||
const stats = document.querySelectorAll(".resource-row .stat");
|
||||
stats.forEach((stat)=>{
|
||||
const label = stat.querySelector(".stat-k")?.textContent?.trim();
|
||||
const key = resourceMap[label];
|
||||
const values = state?.resources || {};
|
||||
document.querySelectorAll("[data-resource-value]").forEach((valueEl)=>{
|
||||
const key = valueEl.dataset.resourceValue;
|
||||
if(!key) return;
|
||||
const value = state?.resources?.[key];
|
||||
const value = values[key];
|
||||
if(typeof value !== "number") return;
|
||||
const valueEl = stat.querySelector(".stat-v");
|
||||
if(!valueEl) return;
|
||||
const dot = valueEl.querySelector(".dot");
|
||||
const display = key === "energy" ? Math.round(value) : Math.floor(value);
|
||||
const prefix = key === "energy" && display >= 0 ? "+" : "";
|
||||
@@ -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(){
|
||||
try{
|
||||
const res = await fetch("/api/state");
|
||||
if(!res.ok) return null;
|
||||
return await res.json();
|
||||
const res = await fetch("/api/state", { credentials: "same-origin" });
|
||||
if(res.status === 401) return { status: 401 };
|
||||
if(!res.ok) return { status: res.status };
|
||||
const data = await res.json();
|
||||
return { status: 200, data };
|
||||
}catch(e){
|
||||
return null;
|
||||
return { status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshState(){
|
||||
const state = await fetchState();
|
||||
if(state) updateResourceBar(state);
|
||||
const result = await fetchState();
|
||||
if(result.status === 200){
|
||||
showGameView();
|
||||
updateResourceBar(result.data);
|
||||
updateQueuePanel(result.data);
|
||||
ensureBuildButton();
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
if(result.status === 401){
|
||||
showAuthView();
|
||||
showAuthPanel("login");
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBuildButton(){
|
||||
@@ -198,8 +371,130 @@
|
||||
}
|
||||
|
||||
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();
|
||||
ensureBuildButton();
|
||||
setInterval(refreshState, 30000);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -103,7 +103,14 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
<div class="space-bg" aria-hidden="true"></div>
|
||||
|
||||
<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 -->
|
||||
<aside class="sidebar" aria-label="Seitenleiste">
|
||||
@@ -132,6 +139,7 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
🔔 <span class="badge" id="notifBadge">3</span>
|
||||
</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>
|
||||
|
||||
@@ -211,6 +219,14 @@ $partialsPath = __DIR__ . '/../src/partials';
|
||||
</div>
|
||||
|
||||
<?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>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
23
web/mobile/src/partials/auth-login.php
Normal 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>
|
||||
16
web/mobile/src/partials/auth-register-step1.php
Normal 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>
|
||||
22
web/mobile/src/partials/auth-register-step2.php
Normal 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">2–40 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>
|
||||
30
web/mobile/src/partials/auth-register-step3.php
Normal 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>
|
||||
@@ -5,24 +5,49 @@
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="metal">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="crystals">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="deuterium">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat" data-resource="energy">
|
||||
<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>
|
||||
</div>
|
||||
|
||||