summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Weipert <git@mail.dweipert.de>2024-01-02 20:42:01 +0100
committerDaniel Weipert <git@mail.dweipert.de>2024-01-05 12:33:59 +0100
commitb21316248572cb27ed1f504529ad6680a473022e (patch)
treef8a2f81258cae3b1d2429fb7df5a3287954b683a
parentf621d95f89ded05a2e916c5ee363bfe75ea37482 (diff)
gemini
-rw-r--r--.env.example1
-rw-r--r--.gitignore3
-rw-r--r--Justfile2
-rw-r--r--Readme.md16
-rw-r--r--bin/db.php17
-rwxr-xr-xbin/gemini.sh11
-rw-r--r--composer.json8
-rw-r--r--composer.lock121
-rw-r--r--docker-compose.yml25
-rw-r--r--shell.nix10
-rw-r--r--src/App.php32
-rw-r--r--src/DB.php3
-rw-r--r--src/EventRunner.php2
-rw-r--r--src/View.php12
-rw-r--r--src/gemini/Controller/Building.php44
-rw-r--r--src/gemini/Controller/Unit.php60
-rw-r--r--src/gemini/Controller/User.php118
-rw-r--r--src/gemini/Controller/Village.php167
-rw-r--r--src/gemini/Gemini.php120
-rw-r--r--src/http/Controller/Building.php (renamed from src/Controller/Building.php)4
-rw-r--r--src/http/Controller/Event.php (renamed from src/Controller/Event.php)4
-rw-r--r--src/http/Controller/Login.php (renamed from src/Controller/Login.php)6
-rw-r--r--src/http/Controller/Map.php (renamed from src/Controller/Map.php)2
-rw-r--r--src/http/Controller/Unit.php (renamed from src/Controller/Unit.php)4
-rw-r--r--src/http/Controller/Village.php (renamed from src/Controller/Village.php)4
-rw-r--r--src/http/Http.php37
-rw-r--r--src/http/Router.php (renamed from src/Router.php)4
-rw-r--r--src/http/Support/RouteLoader.php (renamed from src/Support/RouteLoader.php)2
-rw-r--r--views/gemini/error.twig3
-rw-r--r--views/gemini/storage.twig16
-rw-r--r--views/gemini/village.twig136
-rw-r--r--views/gemini/villages.twig3
-rw-r--r--views/http/base.twig (renamed from views/base.twig)0
-rw-r--r--views/http/components/timer.twig (renamed from views/components/timer.twig)0
-rw-r--r--views/http/error.twig (renamed from views/error.twig)0
-rw-r--r--views/http/login.twig (renamed from views/login.twig)2
-rw-r--r--views/http/map.twig (renamed from views/map.twig)0
-rw-r--r--views/http/root.twig (renamed from views/root.twig)0
-rw-r--r--views/http/village.twig (renamed from views/village.twig)0
-rw-r--r--views/http/villages.twig (renamed from views/villages.twig)0
40 files changed, 916 insertions, 83 deletions
diff --git a/.env.example b/.env.example
index 6cd359f..9281323 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
APP_ENV=
+APP_HOST=
DB_NAME=
DB_USER=
diff --git a/.gitignore b/.gitignore
index c87432b..7276513 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@
config.yml
/vendor/
+
+/cert.pem
+/key.rsa
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..2207542
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,2 @@
+gemini:
+ find $(pwd) -type f -name '*.php' -o -name '*.twig' | GEMINI=true DB_HOST=localhost entr -r php public/index.php
diff --git a/Readme.md b/Readme.md
index 415be1a..f6016ae 100644
--- a/Readme.md
+++ b/Readme.md
@@ -3,3 +3,19 @@
https://openmoji.org
https://game-icons.net
https://lospec.com/palette-list/poisson-23
+
+
+## Development
+
+http
+```
+docker compose up
+```
+
+gemini
+```
+docker compose up # = DB
+
+openssl req -x509 -newkey rsa:4096 -keyout key.rsa -out cert.pem -days 3650 -nodes -subj "/CN=localhost"
+find $(pwd) -type f -name '*.php' -o -name '*.twig' | GEMINI_HOSTNAME=localhost DB_HOST=localhost entr -r php public/index.php
+```
diff --git a/bin/db.php b/bin/db.php
index 84cb85c..8047b42 100644
--- a/bin/db.php
+++ b/bin/db.php
@@ -196,6 +196,23 @@ DB::query(<<<SQL
SQL);
DB::query(<<<SQL
+ create table if not exists "users_gemini" (
+ "id" bigserial primary key,
+
+ "certificate" character varying(255) not null,
+
+ "user_id" bigint not null,
+ constraint "relation_user"
+ foreign key ("user_id") references users("id") on delete cascade,
+
+ "created_at" timestamp(0) not null default current_timestamp,
+ "updated_at" timestamp(0) not null default current_timestamp,
+
+ unique ("certificate")
+ );
+SQL);
+
+DB::query(<<<SQL
create table if not exists "user_settings" (
"id" bigserial primary key,
diff --git a/bin/gemini.sh b/bin/gemini.sh
new file mode 100755
index 0000000..5c2a845
--- /dev/null
+++ b/bin/gemini.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+if ! type entr > /dev/null; then
+ apk add entr
+fi
+
+openssl req -x509 -newkey rsa:4096 -keyout key.rsa -out cert.pem -days 3650 -nodes -subj "/CN=localhost"
+
+# php $(pwd)/public/index.php
+#find $(pwd) -type f -name '*.php' -o -name '*.twig'
+find $(pwd) -type f \( -name "*.php" -o -name "*.twig" \) -not \( -path "*/vendor/*" \) | entr -rn php $(pwd)/public/index.php
diff --git a/composer.json b/composer.json
index c86117f..dcdb864 100644
--- a/composer.json
+++ b/composer.json
@@ -8,12 +8,20 @@
],
"require": {
"php": "^8.0",
+ "dweipert/gemini-foundation": "dev-main",
"symfony/config": "^6.3",
"symfony/dotenv": "^6.3",
"symfony/http-foundation": "^6.3",
"symfony/routing": "^6.3",
"twig/twig": "^3.7"
},
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://git.dweipert.de/gemini-foundation"
+ }
+ ],
+ "minimum-stability": "dev",
"autoload": {
"psr-4": {
"App\\": "src/"
diff --git a/composer.lock b/composer.lock
index 8bc246d..2524e07 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,11 +4,37 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ee17ea11ec110fee294c21a2ef86e5cc",
+ "content-hash": "ccb607ba0346d76a58c48da66d90212d",
"packages": [
{
+ "name": "dweipert/gemini-foundation",
+ "version": "dev-main",
+ "source": {
+ "type": "git",
+ "url": "https://git.dweipert.de/gemini-foundation",
+ "reference": "3776ab80f23c07943d2228f2975e515c494f930a"
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GeminiFoundation\\": "src/"
+ },
+ "files": [
+ "src/functions.php"
+ ]
+ },
+ "authors": [
+ {
+ "name": "Daniel Weipert",
+ "email": "code@drogueronin.de"
+ }
+ ],
+ "time": "2024-01-02T19:39:07+00:00"
+ },
+ {
"name": "symfony/config",
- "version": "v6.4.0",
+ "version": "6.4.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
@@ -63,7 +89,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/config/tree/v6.4.0"
+ "source": "https://github.com/symfony/config/tree/6.4"
},
"funding": [
{
@@ -83,7 +109,7 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.4.0",
+ "version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
@@ -98,6 +124,7 @@
"require": {
"php": ">=8.1"
},
+ "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
@@ -150,16 +177,16 @@
},
{
"name": "symfony/dotenv",
- "version": "v6.4.0",
+ "version": "6.4.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/dotenv.git",
- "reference": "d0d584a91422ddaa2c94317200d4c4e5b935555f"
+ "reference": "835f8d2d1022934ac038519de40b88158798c96f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/dotenv/zipball/d0d584a91422ddaa2c94317200d4c4e5b935555f",
- "reference": "d0d584a91422ddaa2c94317200d4c4e5b935555f",
+ "url": "https://api.github.com/repos/symfony/dotenv/zipball/835f8d2d1022934ac038519de40b88158798c96f",
+ "reference": "835f8d2d1022934ac038519de40b88158798c96f",
"shasum": ""
},
"require": {
@@ -204,7 +231,7 @@
"environment"
],
"support": {
- "source": "https://github.com/symfony/dotenv/tree/v6.4.0"
+ "source": "https://github.com/symfony/dotenv/tree/6.4"
},
"funding": [
{
@@ -220,20 +247,20 @@
"type": "tidelift"
}
],
- "time": "2023-10-26T18:19:48+00:00"
+ "time": "2023-12-28T19:16:56+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v7.0.0",
+ "version": "7.1.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "7da8ea2362a283771478c5f7729cfcb43a76b8b7"
+ "reference": "2d3a1adb578bd4e783b5f1cbfc4e2c12ae05b466"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/7da8ea2362a283771478c5f7729cfcb43a76b8b7",
- "reference": "7da8ea2362a283771478c5f7729cfcb43a76b8b7",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/2d3a1adb578bd4e783b5f1cbfc4e2c12ae05b466",
+ "reference": "2d3a1adb578bd4e783b5f1cbfc4e2c12ae05b466",
"shasum": ""
},
"require": {
@@ -267,7 +294,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v7.0.0"
+ "source": "https://github.com/symfony/filesystem/tree/7.1"
},
"funding": [
{
@@ -283,20 +310,20 @@
"type": "tidelift"
}
],
- "time": "2023-07-27T06:33:22+00:00"
+ "time": "2023-12-19T09:30:49+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v6.4.0",
+ "version": "6.4.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "44a6d39a9cc11e154547d882d5aac1e014440771"
+ "reference": "172d807f9ef3fc3fbed8377cc57c20d389269271"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/44a6d39a9cc11e154547d882d5aac1e014440771",
- "reference": "44a6d39a9cc11e154547d882d5aac1e014440771",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/172d807f9ef3fc3fbed8377cc57c20d389269271",
+ "reference": "172d807f9ef3fc3fbed8377cc57c20d389269271",
"shasum": ""
},
"require": {
@@ -344,7 +371,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v6.4.0"
+ "source": "https://github.com/symfony/http-foundation/tree/6.4"
},
"funding": [
{
@@ -360,11 +387,11 @@
"type": "tidelift"
}
],
- "time": "2023-11-20T16:41:16+00:00"
+ "time": "2023-12-27T22:16:42+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.28.0",
+ "version": "1.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -385,6 +412,7 @@
"suggest": {
"ext-ctype": "For best performance"
},
+ "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
@@ -446,7 +474,7 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.28.0",
+ "version": "1.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -467,6 +495,7 @@
"suggest": {
"ext-mbstring": "For best performance"
},
+ "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
@@ -529,7 +558,7 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.28.0",
+ "version": "1.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
@@ -544,6 +573,7 @@
"require": {
"php": ">=7.1"
},
+ "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
@@ -612,7 +642,7 @@
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.28.0",
+ "version": "1.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
@@ -628,6 +658,7 @@
"php": ">=7.1",
"symfony/polyfill-php80": "^1.14"
},
+ "default-branch": true,
"type": "library",
"extra": {
"branch-alias": {
@@ -692,16 +723,16 @@
},
{
"name": "symfony/routing",
- "version": "v6.4.1",
+ "version": "6.4.x-dev",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "0c95c164fdba18b12523b75e64199ca3503e6d40"
+ "reference": "98eab13a07fddc85766f1756129c69f207ffbc21"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/0c95c164fdba18b12523b75e64199ca3503e6d40",
- "reference": "0c95c164fdba18b12523b75e64199ca3503e6d40",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/98eab13a07fddc85766f1756129c69f207ffbc21",
+ "reference": "98eab13a07fddc85766f1756129c69f207ffbc21",
"shasum": ""
},
"require": {
@@ -755,7 +786,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v6.4.1"
+ "source": "https://github.com/symfony/routing/tree/6.4"
},
"funding": [
{
@@ -771,24 +802,25 @@
"type": "tidelift"
}
],
- "time": "2023-12-01T14:54:37+00:00"
+ "time": "2023-12-29T15:34:34+00:00"
},
{
"name": "twig/twig",
- "version": "v3.8.0",
+ "version": "3.x-dev",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
- "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d"
+ "reference": "b4c3c1c448583e9ffa24933675c01f4be3435b41"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/twigphp/Twig/zipball/9d15f0ac07f44dc4217883ec6ae02fd555c6f71d",
- "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/b4c3c1c448583e9ffa24933675c01f4be3435b41",
+ "reference": "b4c3c1c448583e9ffa24933675c01f4be3435b41",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3",
"symfony/polyfill-php80": "^1.22"
@@ -797,8 +829,15 @@
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.3|^7.0"
},
+ "default-branch": true,
"type": "library",
"autoload": {
+ "files": [
+ "src/Resources/core.php",
+ "src/Resources/debug.php",
+ "src/Resources/escaper.php",
+ "src/Resources/string_loader.php"
+ ],
"psr-4": {
"Twig\\": "src/"
}
@@ -831,7 +870,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
- "source": "https://github.com/twigphp/Twig/tree/v3.8.0"
+ "source": "https://github.com/twigphp/Twig/tree/3.x"
},
"funding": [
{
@@ -843,13 +882,15 @@
"type": "tidelift"
}
],
- "time": "2023-11-21T18:54:41+00:00"
+ "time": "2024-01-01T14:44:03+00:00"
}
],
"packages-dev": [],
"aliases": [],
- "minimum-stability": "stable",
- "stability-flags": [],
+ "minimum-stability": "dev",
+ "stability-flags": {
+ "dweipert/gemini-foundation": 20
+ },
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
diff --git a/docker-compose.yml b/docker-compose.yml
index 6d5f1c1..154d35f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,15 +1,11 @@
version: "3"
services:
- app:
- build:
- context: docker/php
- volumes:
- - "./:/var/www/html"
-
db:
image: postgres
restart: unless-stopped
+ ports:
+ - "5432:5432"
environment:
- "POSTGRES_DB=${DB_NAME}"
- "POSTGRES_USER=${DB_USER}"
@@ -17,6 +13,12 @@ services:
volumes:
- "db:/var/lib/postgresql/data"
+ app:
+ build:
+ context: docker/php
+ volumes:
+ - "./:/var/www/html"
+
web:
build:
context: docker/nginx
@@ -25,6 +27,17 @@ services:
volumes:
- "./:/var/www/html"
+ # gemini:
+ # build:
+ # context: docker/php
+ # ports:
+ # - "1965:1965"
+ # environment:
+ # - "GEMINI_HOSTNAME=0.0.0.0"
+ # volumes:
+ # - "./:/var/www/html"
+ # command: "./bin/gemini.sh"
+
adminer:
image: adminer
ports:
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..358ee54
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,10 @@
+with (import <nixpkgs> {});
+mkShell {
+ buildInputs = [
+ php
+ phpPackages.composer
+ nodejs
+ just
+ entr
+ ];
+}
diff --git a/src/App.php b/src/App.php
index 7524d38..9516a06 100644
--- a/src/App.php
+++ b/src/App.php
@@ -2,34 +2,32 @@
namespace App;
-use Symfony\Component\HttpFoundation\Request;
+use App\gemini\Gemini;
+use App\http\Http;
class App
{
+ private $appRunner;
+
public function __construct() {
if ($_ENV['APP_ENV'] === 'development') {
error_reporting(E_ALL);
}
- // Session
- session_start();
-
- // DB
- DB::init();
-
- // Router
- Router::init(Request::createFromGlobals());
-
- // View
- View::init();
-
- // Events
- new EventRunner();
+ if (isset($_ENV['GEMINI'])) {
+ $this->appRunner = new Gemini([
+ 'file' => dirname(__DIR__) . '/cert.pem',
+ 'key' => dirname(__DIR__) . '/key.rsa',
+ 'passphrase' => '',
+ ], $_ENV['APP_HOST']);
+ }
+ else {
+ $this->appRunner = new Http();
+ }
}
public function run(): void
{
- $response = Router::execute();
- $response->send();
+ $this->appRunner->run();
}
}
diff --git a/src/DB.php b/src/DB.php
index 82a81c4..f91e934 100644
--- a/src/DB.php
+++ b/src/DB.php
@@ -9,11 +9,12 @@ class DB {
{
$driver = $_ENV['DB_DRIVER'] ?? 'pgsql';
$host = $_ENV['DB_HOST'] ?? 'db';
+ $port = $_ENV['DB_PORT'] ?? 5432;
$dbname = $_ENV['DB_NAME'];
$user = $_ENV['DB_USER'];
$password = $_ENV['DB_PASSWORD'];
- self::$connection = new \PDO("pgsql:host=$host;dbname=$dbname", $user, $password);
+ self::$connection = new \PDO("pgsql:host=$host;port=$port;dbname=$dbname", $user, $password);
}
/**
diff --git a/src/EventRunner.php b/src/EventRunner.php
index 7dd773d..2f2fd8b 100644
--- a/src/EventRunner.php
+++ b/src/EventRunner.php
@@ -82,7 +82,7 @@ class EventRunner
}
$diff = (new \DateTime())->diff($lastTick);
- $tickMultiplier = $diff->i;
+ $tickMultiplier = $diff->i + 60*$diff->h + 60*24*$diff->d;
if ($tickMultiplier > 0) {
$villages = DB::fetch(Village::class, 'select id,wood,clay,iron,food from villages');
diff --git a/src/View.php b/src/View.php
index 7862b12..1c29a67 100644
--- a/src/View.php
+++ b/src/View.php
@@ -9,11 +9,14 @@ use Twig\TwigFilter;
class View
{
- private static Environment $twig;
+ public static Environment $twig;
public static function init(): void
{
- $loader = new FilesystemLoader(dirname(__DIR__) . '/views');
+ $loader = new FilesystemLoader(
+ dirname(__DIR__) . '/views/' .
+ (isset($_ENV['GEMINI']) ? 'gemini' : 'http')
+ );
self::$twig = new Environment($loader, [
'debug' => $_ENV['APP_ENV'] === 'development',
]);
@@ -24,8 +27,11 @@ class View
self::$twig->addFilter(new TwigFilter('buildTime', function ($buildTime) {
return @sprintf('%02d:%02d:%02d', $buildTime / 3600, ($buildTime / 60) % 60, $buildTime % 60);
}));
+ }
- self::$twig->addGlobal('session', $_SESSION);
+ public static function addGlobal(string $name, mixed $value): void
+ {
+ self::$twig->addGlobal($name, $value);
}
/**
diff --git a/src/gemini/Controller/Building.php b/src/gemini/Controller/Building.php
new file mode 100644
index 0000000..9827d00
--- /dev/null
+++ b/src/gemini/Controller/Building.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace App\gemini\Controller;
+
+use App\Model\Building as Model;
+use App\Model\Event;
+use App\Model\Event\UpgradeBuilding;
+use App\Model\Village;
+use GeminiFoundation\Request;
+use GeminiFoundation\Response;
+use GeminiFoundation\Status;
+
+class Building
+{
+ //#[Route(path: '/village/{x}/{y}/building/{type}/level-up', methods: ['POST'])]
+ public function levelUp(Request $request): Response
+ {
+ $village = Village::getByCoordinates($request->get('x'), $request->get('y'));
+ $building = Model::getByVillage($village->id, $request->get('type')) ?? Model::getEmpty($village->id, $request->get('type'));
+
+ // resources
+ foreach ($building->getResourceRequirements() as $resourceType => $resourceValue) {
+ $village->{$resourceType} -= $resourceValue;
+ }
+ $village->updateResources();
+
+ // event
+ $event = new Event();
+ $event->time = (new \DateTime())->add(\DateInterval::createFromDateString(
+ $building->getBuildTimeForLevel($building->getEffectiveLevel() + 1) . ' seconds'
+ ));
+ $event->villageId = $building->villageId;
+ $upgradeBuildingEvent = new UpgradeBuilding();
+ $upgradeBuildingEvent->event = $event;
+ $upgradeBuildingEvent->type = $building->type;
+ $upgradeBuildingEvent->dbInsert();
+
+
+ return new Response(
+ statusCode: Status::REDIRECT_TEMPORARY, # correct response code?
+ meta: "/village/{$village->x}/{$village->y}"
+ );
+ }
+}
diff --git a/src/gemini/Controller/Unit.php b/src/gemini/Controller/Unit.php
new file mode 100644
index 0000000..c04079a
--- /dev/null
+++ b/src/gemini/Controller/Unit.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace App\gemini\Controller;
+
+use App\Model\Event;
+use App\Model\Event\TrainUnits;
+use App\Model\Unit as Model;
+use App\Model\Village;
+use GeminiFoundation\Request;
+use GeminiFoundation\Response;
+use GeminiFoundation\Status;
+
+class Unit
+{
+ // #[Route(path: '/village/{x}/{y}/unit/{type}/create', methods: ['POST'])]
+ public function train(Request $request): Response
+ {
+ if (empty($request->get('input'))) {
+ return new Response(statusCode: Status::INPUT, meta: 'Amount');
+ }
+
+ $village = Village::getByCoordinates($request->get('x'), $request->get('y'));
+
+ /**@var Model $unit*/
+ $unit = new (Model::resolveType($request->get('type')))();
+ $unit->type = $request->get('type');
+ $unit->homeVillageId = $village->id;
+
+ $amount = intval($request->get('input'));
+
+ if (! Village::canTrain($village, $unit, $amount)) {
+ return new Response(
+ statusCode: Status::REDIRECT_TEMPORARY,
+ meta: "/village/{$village->x}/{$village->y}"
+ );
+ }
+
+ // resources
+ foreach (Model::getResourceRequirements($unit, $amount) as $resourceType => $resourceValue) {
+ $village->{$resourceType} -= $resourceValue;
+ }
+ $village->updateResources();
+
+ // event
+ $event = new Event();
+ $event->time = (new \DateTime())->add(\DateInterval::createFromDateString($unit->getBuildTime($amount) . ' seconds'));
+ $event->villageId = $village->id;
+ $trainUnitsEvent = new TrainUnits();
+ $trainUnitsEvent->event = $event;
+ $trainUnitsEvent->type = $request->get('type');
+ $trainUnitsEvent->amount = $amount;
+ $trainUnitsEvent->dbInsert();
+
+
+ return new Response(
+ statusCode: Status::REDIRECT_TEMPORARY,
+ meta: "/village/{$village->x}/{$village->y}"
+ );
+ }
+}
diff --git a/src/gemini/Controller/User.php b/src/gemini/Controller/User.php
new file mode 100644
index 0000000..a870b88
--- /dev/null
+++ b/src/gemini/Controller/User.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\gemini\Controller;
+
+use App\DB;
+use GeminiFoundation\Request;
+
+class User
+{
+ public function get(Request $request): array|bool
+ {
+ if ($request->getClientCertificate() === null) {
+ return false;
+ }
+
+ return DB::query(
+ <<<SQL
+ select users.id, username, email from users
+ join users_gemini on users.id = users_gemini.user_id
+ where users_gemini.certificate=:fingerprint
+ SQL,
+ ['fingerprint' => $request->getClientCertificate()->getFingerprint()]
+ )->fetch();
+ }
+
+ public function create(Request $request): array|bool
+ {
+ DB::query(
+ 'insert into users (username, password, email) values (:username, :fingerprint, :email)',
+ [
+ 'username' => md5($request->getClientCertificate()->getFingerprint()),
+ 'fingerprint' => $request->getClientCertificate()->getFingerprint(),
+ 'email' => '(no email)',
+ ]
+ );
+ $userId = DB::query('select id from users where password=:password', ['password' => $request->getClientCertificate()->getFingerprint()])->fetchColumn();
+
+ DB::query(
+ 'insert into users_gemini (certificate, user_id) values (:fingerprint, :userId)',
+ ['fingerprint' => $request->getClientCertificate()->getFingerprint(), 'userId' => $userId]
+ );
+
+ // also insert new village at random free coordinates
+ DB::query(
+ 'insert into villages (name, x, y, wood, clay, iron, food, satisfaction) values (:name, :x, :y, :wood, :clay, :iron, :food, :satisfaction)',
+ [
+ 'name' => substr(md5(rand()), 0, 6),
+ 'x' => rand(0, 100),
+ 'y' => rand(0, 100),
+ 'wood' => 500,
+ 'clay' => 500,
+ 'iron' => 500,
+ 'food' => 500,
+ 'satisfaction' => 100,
+ ]
+ );
+ $villageId = DB::query('select id from villages order by id desc limit 1')->fetchColumn();
+
+ DB::query(
+ 'insert into user_villages (user_id, village_id) values (:userId, :villageId)',
+ ['userId' => $userId, 'villageId' => $villageId]
+ );
+
+ // insert base buildings
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'TownHall', 'villageId' => $villageId]
+ );
+
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'Storage', 'villageId' => $villageId]
+ );
+ DB::query(
+ 'insert into village_storage_config (wood, clay, iron, food, village_id) values (:wood, :clay, :iron, :food, :villageId)',
+ ['wood' => 25, 'clay' => 25, 'iron' => 25, 'food' => 25, 'villageId' => $villageId]
+ );
+
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'WoodCutter', 'villageId' => $villageId]
+ );
+ DB::query(
+ 'insert into village_units (amount, type, is_traveling, home_village_id, residence_village_id) values (:amount, :type, false, :villageId, :villageId)',
+ ['amount' => 1, 'type' => 'WoodCutter', 'villageId' => $villageId]
+ );
+
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'ClayPit', 'villageId' => $villageId]
+ );
+ DB::query(
+ 'insert into village_units (amount, type, is_traveling, home_village_id, residence_village_id) values (:amount, :type, false, :villageId, :villageId)',
+ ['amount' => 1, 'type' => 'PitWorker', 'villageId' => $villageId]
+ );
+
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'IronMine', 'villageId' => $villageId]
+ );
+ DB::query(
+ 'insert into village_units (amount, type, is_traveling, home_village_id, residence_village_id) values (:amount, :type, false, :villageId, :villageId)',
+ ['amount' => 1, 'type' => 'Miner', 'villageId' => $villageId]
+ );
+
+ DB::query(
+ 'insert into village_buildings (level, type, village_id) values (:level, :type, :villageId)',
+ ['level' => 1, 'type' => 'Farm', 'villageId' => $villageId]
+ );
+ DB::query(
+ 'insert into village_units (amount, type, is_traveling, home_village_id, residence_village_id) values (:amount, :type, false, :villageId, :villageId)',
+ ['amount' => 1, 'type' => 'Farmer', 'villageId' => $villageId]
+ );
+
+
+ return $this->get($request);
+ }
+}
diff --git a/src/gemini/Controller/Village.php b/src/gemini/Controller/Village.php
new file mode 100644
index 0000000..9b27561
--- /dev/null
+++ b/src/gemini/Controller/Village.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace App\gemini\Controller;
+
+use App\DB;
+use App\Guard;
+use App\Model\Event\SendUnits;
+use App\Model\Event\TrainUnits;
+use App\Model\Event\UpgradeBuilding;
+use App\Model\Village as Model;
+use App\View;
+use GeminiFoundation\Request;
+use GeminiFoundation\Response;
+use GeminiFoundation\Status;
+
+class Village
+{
+ // #[Route(path: '/villages', methods: ['GET'])]
+ public function list(): Response
+ {
+ $villages = DB::fetch(
+ Model::class,
+ <<<SQL
+ select * from villages
+ join user_villages on villages.id = user_villages.village_id
+ where user_villages.user_id=:id
+ SQL,
+ ['id' => $_SESSION['user']['id']]
+ );
+
+
+ return new Response(body: View::render('villages.twig', [
+ 'villages' => $villages,
+ ]));
+ }
+
+ //#[Route(path: '/village/{x}/{y}', methods: ['GET'])]
+ public function show(Request $request): Response
+ {
+ $village = Model::getByCoordinates($request->get('x'), $request->get('y'));
+
+ if (! Guard::ownsVillage($village->id)) {
+ return new Response(body: View::render('error.twig', ['message' => 'Insufficient permission']));
+ }
+
+ $events = [];
+
+ $eventsBuilding = DB::query(
+ <<<SQL
+ select * from events_upgrade_building as event
+ left join events on event.event_id = events.id
+ where events.village_id=:id
+ SQL, ['id' => $village->id]
+ )->fetchAll();
+
+ foreach ($eventsBuilding as $row) {
+ $events['UpgradeBuilding'][$row['type']][] = DB::convertToModel(UpgradeBuilding::class, $row);
+ }
+
+ $eventsUnits = DB::query(
+ <<<SQL
+ select * from events_train_units as event
+ left join events on event.event_id = events.id
+ where village_id=:id
+ SQL, ['id' => $village->id]
+ )->fetchAll();
+
+ foreach ($eventsUnits as $row) {
+ $events['TrainUnits'][] = DB::convertToModel(TrainUnits::class, $row);
+ }
+
+ $eventsUnitsSendOwn = DB::query(
+ <<<SQL
+ select * from events_send_units as event
+ left join events on event.event_id = events.id
+ where village_id=:id
+ SQL, ['id' => $village->id]
+ )->fetchAll();
+
+ $eventsUnitsSendOther = DB::query(
+ <<<SQL
+ select * from events_send_units as event
+ left join events on event.event_id = events.id
+ where (destination=:id or source=:id) and village_id!=:id and is_canceled=false
+ SQL, ['id' => $village->id]
+ )->fetchAll();
+
+ foreach ([...$eventsUnitsSendOwn, ...$eventsUnitsSendOther] as $row) {
+ $events['SendUnits'][] = DB::convertToModel(SendUnits::class, $row);;
+ }
+
+ $buildings = [];
+ foreach (Model::getBuildings($village->id, true) as $building) {
+ $buildings[$building->type] = $building;
+ }
+
+
+ return new Response(body: View::render('village.twig', [
+ 'village' => $village,
+ 'events' => $events,
+ 'buildings' => $buildings,
+ 'villages' => DB::fetch(Model::class, "select * from villages where id!=:id", ['id' => $village->id]),
+ ]));
+ }
+
+ // #[Route(path: '/village/{x}/{y}/storage/config', methods: ['POST'])]
+ public function storageConfig(Request $request): Response
+ {
+ $village = Model::getByCoordinates($request->get('x'), $request->get('y'));
+ $type = $request->get('type');
+
+ if (empty($type)) {
+ return new Response(body: View::render('storage.twig', [
+ 'village' => $village,
+ ]));
+ }
+
+ if (empty($request->get('input'))) {
+ return new Response(statusCode: Status::INPUT, meta: "$type percent?");
+ }
+
+ $input = intval($request->get('input'));
+
+ // calculate to max 100%
+ $allTypes = ['wood', 'clay', 'iron', 'food'];
+ $allOtherTypes = array_diff($allTypes, [$type]);
+
+ $storageConfig = $village->getStorageConfig($village->id);
+
+ $values = [];
+ foreach ($allTypes as $resourceType) {
+ $values[$resourceType] = $storageConfig->$resourceType;
+ }
+ $values[$type] = $input;
+
+ $total = 0;
+ foreach ($values as $value) {
+ $total += $value;
+ }
+
+ foreach ($values as $resourceType => $value) {
+ $values[$resourceType] = round(($value / $total) * 100);
+ }
+
+ if ($values[$type] !== $input) {
+ $values[$type] = $input;
+ }
+
+ $newTotal = array_sum($values);
+ $values['food'] += (100 - $newTotal);
+
+ DB::query(
+ <<<SQL
+ update village_storage_config
+ set wood=:wood, clay=:clay, iron=:iron, food=:food
+ where village_id=:id
+ SQL,
+ ['wood' => $values['wood'], 'clay' => $values['clay'], 'iron' => $values['iron'], 'food' => $values['food'], 'id' => $village->id]
+ );
+
+
+ return new Response(
+ statusCode: Status::REDIRECT_TEMPORARY,
+ meta: "/village/{$village->x}/{$village->y}/storage/config"
+ );
+ }
+}
diff --git a/src/gemini/Gemini.php b/src/gemini/Gemini.php
new file mode 100644
index 0000000..41b8716
--- /dev/null
+++ b/src/gemini/Gemini.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace App\gemini;
+
+use App\DB;
+use App\EventRunner;
+use App\View;
+use App\gemini\Controller\Building;
+use App\gemini\Controller\Unit;
+use App\gemini\Controller\User;
+use App\gemini\Controller\Village;
+use GeminiFoundation\Request;
+use GeminiFoundation\Response;
+use GeminiFoundation\Server;
+use GeminiFoundation\Status;
+
+class Gemini
+{
+ private array $certificate;
+ private string $hostname;
+
+ public function __construct(array $certificate, string $hostname)
+ {
+ $this->certificate = $certificate;
+ $this->hostname = $hostname;
+
+ global $_SESSION;
+ $_SESSION = [];
+
+ DB::init();
+
+ View::init();
+ View::addGlobal('session', $_SESSION);
+ }
+
+ public function run(): void
+ {
+ $server = new Server($this->certificate, $this->hostname, [
+ 'client_certificate_support_workaround' => true,
+ ]);
+
+ $server->onRequest(function (Response $response, Request $request) {
+ new EventRunner();
+
+ // auth
+ if ($request->getClientCertificate() === null) {
+ return new Response(
+ statusCode: Status::CLIENT_CERTIFICATE_REQUIRED,
+ meta: 'Attach a client certificate to log in'
+ );
+ }
+
+ $userController = new User();
+ $user = $userController->get($request);
+ if (empty($user)) {
+ $user = $userController->create($request);
+ }
+
+ global $_SESSION;
+ $_SESSION['user'] = [
+ 'id' => $user['id'],
+ 'username' => $user['username'],
+ ];
+ View::addGlobal('session', $_SESSION);
+
+
+ // routes
+ if ($request->getPath() == '/villages') {
+ $villageController = new Village();
+ $response = $villageController->list($request);
+ }
+
+ else if (preg_match('@village/(\d+)/(\d+)/storage/config/?(\w+)?@', $request->getPath(), $routeMatch)) {
+ $request
+ ->set('x', $routeMatch[1])
+ ->set('y', $routeMatch[2]);
+
+ if (isset($routeMatch[3])) {
+ $request->set('type', $routeMatch[3]);
+ }
+
+ $villageController = new Village();
+ $response = $villageController->storageConfig($request);
+ }
+
+ else if (preg_match('@village/(\d+)/(\d+)/building/(\w+)/level-up@', $request->getPath(), $routeMatch)) {
+ $request
+ ->set('x', $routeMatch[1])
+ ->set('y', $routeMatch[2])
+ ->set('type', $routeMatch[3]);
+
+ $buildingController = new Building();
+ $response = $buildingController->levelUp($request);
+ }
+
+ else if (preg_match('@village/(\d+)/(\d+)/unit/(\w+)/create@', $request->getPath(), $routeMatch)) {
+ $request
+ ->set('x', $routeMatch[1])
+ ->set('y', $routeMatch[2])
+ ->set('type', $routeMatch[3]);
+
+ $unitController = new Unit();
+ $response = $unitController->train($request);
+ }
+
+ else if (preg_match('@village/(\d+)/(\d+)@', $request->getPath(), $routeMatch)) {
+ $request
+ ->set('x', $routeMatch[1])
+ ->set('y', $routeMatch[2]);
+
+ $villageController = new Village();
+ $response = $villageController->show($request);
+ }
+
+ return $response;
+ });
+
+ $server->listen();
+ }
+}
diff --git a/src/Controller/Building.php b/src/http/Controller/Building.php
index e141113..4a59f0e 100644
--- a/src/Controller/Building.php
+++ b/src/http/Controller/Building.php
@@ -1,12 +1,12 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\Model\Building as Model;
use App\Model\Event;
use App\Model\Event\UpgradeBuilding;
use App\Model\Village;
-use App\Router;
+use App\http\Router;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
diff --git a/src/Controller/Event.php b/src/http/Controller/Event.php
index 1fd304d..070d449 100644
--- a/src/Controller/Event.php
+++ b/src/http/Controller/Event.php
@@ -1,12 +1,12 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\DB;
use App\Model\Event as Model;
use App\Model\Event\SendUnits;
use App\Model\Village;
-use App\Router;
+use App\http\Router;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
diff --git a/src/Controller/Login.php b/src/http/Controller/Login.php
index 0f360ae..8c04d85 100644
--- a/src/Controller/Login.php
+++ b/src/http/Controller/Login.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\DB;
use App\View;
@@ -21,11 +21,13 @@ class Login
public function login(Request $request): Response
{
$email = $request->get('email');
- $user = DB::query('select id,username,password from users where email=:email', ['email' => $email])->fetch();
+ $user = DB::query('select id,username,password from users where email=:email or username=:email', ['email' => $email])->fetch();
if (empty($user)) {
$password = password_hash($request->get('password'), PASSWORD_DEFAULT);
DB::query('insert into users (username, password, email) values (:username, :password, :email)', ['username' => $email, 'password' => $password, 'email' => $email]);
+
+ // TODO: also insert new village at random free coordinates
} else {
$password = $user['password'];
}
diff --git a/src/Controller/Map.php b/src/http/Controller/Map.php
index 6967470..69d23e1 100644
--- a/src/Controller/Map.php
+++ b/src/http/Controller/Map.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\DB;
use App\View;
diff --git a/src/Controller/Unit.php b/src/http/Controller/Unit.php
index 0508249..c314cda 100644
--- a/src/Controller/Unit.php
+++ b/src/http/Controller/Unit.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\DB;
use App\Model\Event\SendUnits;
@@ -8,7 +8,7 @@ use App\Model\Event\TrainUnits;
use App\Model\Unit as Model;
use App\Model\Event;
use App\Model\Village;
-use App\Router;
+use App\http\Router;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
diff --git a/src/Controller/Village.php b/src/http/Controller/Village.php
index c678779..cd38442 100644
--- a/src/Controller/Village.php
+++ b/src/http/Controller/Village.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Controller;
+namespace App\http\Controller;
use App\DB;
use App\Guard;
@@ -8,7 +8,7 @@ use App\Model\Event\SendUnits;
use App\Model\Event\TrainUnits;
use App\Model\Event\UpgradeBuilding;
use App\Model\Village as Model;
-use App\Router;
+use App\http\Router;
use App\View;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
diff --git a/src/http/Http.php b/src/http/Http.php
new file mode 100644
index 0000000..1867eb4
--- /dev/null
+++ b/src/http/Http.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\http;
+
+use App\DB;
+use App\EventRunner;
+use App\http\Router;
+use App\View;
+use Symfony\Component\HttpFoundation\Request;
+
+class Http
+{
+ public function __construct()
+ {
+ // Session
+ session_start();
+
+ // DB
+ DB::init();
+
+ // Router
+ Router::init(Request::createFromGlobals());
+
+ // View
+ View::init();
+ View::addGlobal('session', $_SESSION);
+
+ // Events
+ new EventRunner();
+ }
+
+ public function run(): void
+ {
+ $response = Router::execute();
+ $response->send();
+ }
+}
diff --git a/src/Router.php b/src/http/Router.php
index 8b24000..db75f81 100644
--- a/src/Router.php
+++ b/src/http/Router.php
@@ -1,8 +1,8 @@
<?php
-namespace App;
+namespace App\http;
-use App\Support\RouteLoader;
+use App\http\Support\RouteLoader;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
diff --git a/src/Support/RouteLoader.php b/src/http/Support/RouteLoader.php
index ba124c5..b0e74cb 100644
--- a/src/Support/RouteLoader.php
+++ b/src/http/Support/RouteLoader.php
@@ -1,6 +1,6 @@
<?php
-namespace App\Support;
+namespace App\http\Support;
use Symfony\Component\Routing\Loader\AnnotationClassLoader;
use Symfony\Component\Routing\Route;
diff --git a/views/gemini/error.twig b/views/gemini/error.twig
new file mode 100644
index 0000000..67aff6a
--- /dev/null
+++ b/views/gemini/error.twig
@@ -0,0 +1,3 @@
+=> /villages Overview
+
+Error: {{ message }}
diff --git a/views/gemini/storage.twig b/views/gemini/storage.twig
new file mode 100644
index 0000000..c9ecd4f
--- /dev/null
+++ b/views/gemini/storage.twig
@@ -0,0 +1,16 @@
+=> /village/{{ village.x }}/{{ village.y }} Back
+
+# Configure Storage
+
+=> /village/{{ village.x }}/{{ village.y }}/storage/config/wood {{ village.getStorageConfig(village.id).wood }}% Wood
+=> /village/{{ village.x }}/{{ village.y }}/storage/config/clay {{ village.getStorageConfig(village.id).clay }}% Clay
+=> /village/{{ village.x }}/{{ village.y }}/storage/config/iron {{ village.getStorageConfig(village.id).iron }}% Iron
+=> /village/{{ village.x }}/{{ village.y }}/storage/config/food {{ village.getStorageConfig(village.id).food }}% Food
+
+# Resources
+
+Wood: {{ village.wood }} / {{ village.getStorage(village.id).getResourceCapacity('wood') }} - Increment: {{ village.getBuilding(village.id, 'WoodCutter').getResourceIncrementor() }}
+Clay: {{ village.clay }} / {{ village.getStorage(village.id).getResourceCapacity('clay') }} - Increment: {{ village.getBuilding(village.id, 'ClayPit').getResourceIncrementor() }}
+Iron: {{ village.iron }} / {{ village.getStorage(village.id).getResourceCapacity('iron') }} - Increment: {{ village.getBuilding(village.id, 'IronMine').getResourceIncrementor() }}
+Food: {{ village.food }} / {{ village.getStorage(village.id).getResourceCapacity('food') }} - Increment: {{ village.getBuilding(village.id, 'Farm').getResourceIncrementor() }}
+Storage Capacity: {{ village.getStorage(village.id).getCapacity() }}
diff --git a/views/gemini/village.twig b/views/gemini/village.twig
new file mode 100644
index 0000000..199f53a
--- /dev/null
+++ b/views/gemini/village.twig
@@ -0,0 +1,136 @@
+=> /villages Overview
+
+
+# {{ village.name }} - {{ village.x }} x {{ village.y }}
+=> /map/{{ village.x }}/{{ village.y }} Map - {{ village.x }} x {{ village.y }}
+Satisfaction: {{ village.satisfaction }}
+
+
+# Resources
+
+Wood: {{ village.wood }} / {{ village.getStorage(village.id).getResourceCapacity('wood') }} - Increment: {{ village.getBuilding(village.id, 'WoodCutter').getResourceIncrementor() }}
+Clay: {{ village.clay }} / {{ village.getStorage(village.id).getResourceCapacity('clay') }} - Increment: {{ village.getBuilding(village.id, 'ClayPit').getResourceIncrementor() }}
+Iron: {{ village.iron }} / {{ village.getStorage(village.id).getResourceCapacity('iron') }} - Increment: {{ village.getBuilding(village.id, 'IronMine').getResourceIncrementor() }}
+Food: {{ village.food }} / {{ village.getStorage(village.id).getResourceCapacity('food') }} - Increment: {{ village.getBuilding(village.id, 'Farm').getResourceIncrementor() }}
+Storage Capacity: {{ village.getStorage(village.id).getCapacity() }}
+=> /village/{{ village.x }}/{{ village.y }}/storage/config Configure Storage
+
+
+# Events
+
+{% if events['UpgradeBuilding'] %}
+## Upgrade Building
+{% for typeEvents in events['UpgradeBuilding'] %}
+{% for event in typeEvents %}
+### {{ event.type }}
+Finished: {{ event.event.time | date('c') }}
+=> /village/{{ village.x }}/{{ village.y }}/building/UpgradeBuilding/build/cancel Cancel
+{% endfor %}
+{% endfor %}
+{% endif %}
+
+{% if events['TrainUnits'] %}
+## Train Units
+{% for event in events['TrainUnits'] %}
+### {{ event.type }}
+Amount: {{ event.amount }}
+Finished: {{ event.event.time | date('c') }}
+=> /village/{{ village.x }}/{{ village.y }}/unit/train/cancel Cancel
+{% endfor %}
+{% endif %}
+
+{% if events['SendUnits'] %}
+## Send Units
+{% for event in events['SendUnits'] %}
+### {{ event.unit }} - {{ event.type }}
+Amount: {{ event.amount }}
+Source: {{ village.get(event.source).name }}
+Destination: {{ village.get(event.destination).name }}
+Finished: {{ event.event.time | date('c') }}
+{% if event.isCanceled %}
+Canceled
+{% else %}
+{% if event.event.villageId == village.id %}
+=> /event/{{ event.event.id }}/cancel Cancel
+{% endif %}
+{% endif %}
+{% endfor %}
+{% endif %}
+
+
+# Buildings
+
+{% for building in buildings %}
+## {{ building.type }}
+Level: {{ building.level | default(0) }}
+Build Time: {{ building.getBuildTimeForLevel(building.getEffectiveLevel() + 1) | buildTime }}
+Resources:
+* Wood: {{ building.getResourceRequirementsForLevel(building.getEffectiveLevel() + 1)['wood'] }}
+* Clay: {{ building.getResourceRequirementsForLevel(building.getEffectiveLevel() + 1)['clay'] }}
+* Iron: {{ building.getResourceRequirementsForLevel(building.getEffectiveLevel() + 1)['iron'] }}
+=> /village/{{ village.x }}/{{ village.y }}/building/{{ building.type }}/manage Manage
+{% if village.canBuild(village, building) %}
+=> /village/{{ village.x }}/{{ village.y }}/building/{{ building.type }}/level-up Level up
+{% endif %}
+{% endfor %}
+
+
+# Units At Home
+
+{% for unit in village.getUnits(village.id, 1, 3) %}
+## {{ unit.type }}
+Amount: {{ unit.amount }}
+{% if not unit.isTraveling %}
+Build Time: {{ unit.getBuildTime(1) | buildTime }}
+Resources:
+* Wood: {{ unit.getResourceRequirements(unit, 1)['wood'] }}
+* Clay: {{ unit.getResourceRequirements(unit, 1)['clay'] }}
+* Iron: {{ unit.getResourceRequirements(unit, 1)['iron'] }}
+* Food: {{ unit.getResourceRequirements(unit, 1)['food'] ?? 0 }}
+{% if village.canTrain(village, unit, 1) %}
+=> /village/{{ village.x }}/{{ village.y }}/unit/{{ unit.type }}/create Train
+{% endif %}
+{% else %}
+~traveling~
+{% endif %}
+{% endfor %}
+
+
+# Units Supporting
+
+{% for unit in village.getUnits(village.id, 2) | merge(village.getUnits(village.id, 3)) %}
+## {{ unit.type }}
+Amount: {{ unit.amount }}
+Origin: {{ village.get(unit.homeVillageId).name }}
+Location: {{ not unit.isTraveling ? village.get(unit.residenceVillageId).name : '~traveling~' }}
+Travel Time: {{ unit.getTravelTime(unit, village.getDistance(unit.getHomeVillage().x, unit.getHomeVillage().x, unit.getResidenceVillage().x, unit.getResidenceVillage().y)) | buildTime }}
+{% if not unit.isTraveling %}
+{% if unit.homeVillageId == village.id %}
+=> /village/{{ village.x }}/{{ village.y }}/unit/{{ unit.type }}/location/{{ unit.getResidenceVillage().x }}/{{ unit.getResidenceVillage().y }}/recall Recall Home
+{% else %}
+=> /village/{{ village.x }}/{{ village.y }}/unit/{{ unit.type }}/location/{{ unit.getHomeVillage().x }}/{{ unit.getHomeVillage().y }}/send-back Send Back
+{% endif %}
+{% endif %}
+{% endfor %}
+
+
+# Send Units
+
+TODO
+* list possible units
+* list possible villages
+* send INPUT request for amount
+
+
+# Send Resources
+
+TODO
+* list possible resource types
+* list possible villages
+* send INPUT request for amount
+
+
+Logged in as {{ session.user.username }}
+=> /logout Logout
+
+Server Time: {{ 'now' | date('c') }}
diff --git a/views/gemini/villages.twig b/views/gemini/villages.twig
new file mode 100644
index 0000000..1c8eb82
--- /dev/null
+++ b/views/gemini/villages.twig
@@ -0,0 +1,3 @@
+{% for village in villages %}
+=> /village/{{ village.x }}/{{ village.y }} {{ village.name }}
+{% endfor %}
diff --git a/views/base.twig b/views/http/base.twig
index 15ddafd..15ddafd 100644
--- a/views/base.twig
+++ b/views/http/base.twig
diff --git a/views/components/timer.twig b/views/http/components/timer.twig
index ccb31a7..ccb31a7 100644
--- a/views/components/timer.twig
+++ b/views/http/components/timer.twig
diff --git a/views/error.twig b/views/http/error.twig
index f9d70cd..f9d70cd 100644
--- a/views/error.twig
+++ b/views/http/error.twig
diff --git a/views/login.twig b/views/http/login.twig
index 095f53e..723d5d2 100644
--- a/views/login.twig
+++ b/views/http/login.twig
@@ -5,7 +5,7 @@
<form action="/login" method="post">
<label>
E-Mail:
- <input type="email" name="email">
+ <input type="text" name="email" placeholder="or Username">
</label>
<label>
Password:
diff --git a/views/map.twig b/views/http/map.twig
index 29f0294..29f0294 100644
--- a/views/map.twig
+++ b/views/http/map.twig
diff --git a/views/root.twig b/views/http/root.twig
index 35399c0..35399c0 100644
--- a/views/root.twig
+++ b/views/http/root.twig
diff --git a/views/village.twig b/views/http/village.twig
index c72018c..c72018c 100644
--- a/views/village.twig
+++ b/views/http/village.twig
diff --git a/views/villages.twig b/views/http/villages.twig
index bdb87be..bdb87be 100644
--- a/views/villages.twig
+++ b/views/http/villages.twig