summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Weipert <code@drogueronin.de>2023-09-24 13:40:25 +0200
committerDaniel Weipert <code@drogueronin.de>2023-09-24 13:40:25 +0200
commitfa00b957378a393f8edbfc98ef111d35d18ecb09 (patch)
tree654e7dc5414f7f2795dbe996d3e1570793a5b1b8
initial commit
-rw-r--r--.env.example9
-rw-r--r--.gitignore3
-rw-r--r--Readme.md1
-rw-r--r--bin/villages.php147
-rw-r--r--composer.json22
-rw-r--r--composer.lock859
-rw-r--r--docker-compose.yml36
-rw-r--r--docker/nginx/Dockerfile3
-rw-r--r--docker/nginx/default.conf.template19
-rw-r--r--docker/php/Dockerfile7
-rw-r--r--public/index.php11
-rw-r--r--src/App.php32
-rw-r--r--src/Controller/Building.php49
-rw-r--r--src/Controller/Village.php54
-rw-r--r--src/DB.php78
-rw-r--r--src/EventRunner.php86
-rw-r--r--src/Model.php19
-rw-r--r--src/Model/Building.php102
-rw-r--r--src/Model/Building/ClayPit.php16
-rw-r--r--src/Model/Building/Farm.php31
-rw-r--r--src/Model/Building/IronMine.php16
-rw-r--r--src/Model/Building/ResourceGenerator.php22
-rw-r--r--src/Model/Building/Storage.php28
-rw-r--r--src/Model/Building/TownHall.php17
-rw-r--r--src/Model/Building/WoodCutter.php16
-rw-r--r--src/Model/Event.php32
-rw-r--r--src/Model/Event/TrainUnits.php27
-rw-r--r--src/Model/Event/UpgradeBuilding.php27
-rw-r--r--src/Model/Unit.php77
-rw-r--r--src/Model/Unit/Farmer.php15
-rw-r--r--src/Model/Unit/Miner.php15
-rw-r--r--src/Model/Unit/PitWorker.php15
-rw-r--r--src/Model/Unit/WoodCutter.php15
-rw-r--r--src/Model/User.php7
-rw-r--r--src/Model/Village.php135
-rw-r--r--src/Model/Village/StorageConfig.php18
-rw-r--r--src/Router.php80
-rw-r--r--src/Support/ResourceType.php11
-rw-r--r--src/Support/RouteLoader.php24
-rw-r--r--src/Support/UnitType.php11
-rw-r--r--src/View.php37
-rw-r--r--views/base.twig26
-rw-r--r--views/components/timer.twig25
-rw-r--r--views/root.twig10
-rw-r--r--views/village.twig169
-rw-r--r--views/villages.twig38
46 files changed, 2497 insertions, 0 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0f3508d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+APP_ENV=
+
+DB_NAME=
+DB_USER=
+DB_PASSWORD=
+
+BASE_BUILDING_BUILD_TIME_FACTOR=256
+BASE_UNIT_BUILD_TIME_FACTOR=256
+BASE_RESOURCE_GENERATION_FACTOR=128
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..02166b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.env
+
+/vendor/
diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..68a12c2
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1 @@
+# Coop Game =}
diff --git a/bin/villages.php b/bin/villages.php
new file mode 100644
index 0000000..092a754
--- /dev/null
+++ b/bin/villages.php
@@ -0,0 +1,147 @@
+<?php
+
+use App\DB;
+use Symfony\Component\Dotenv\Dotenv;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$dotenv = new Dotenv();
+$dotenv->load(dirname(__DIR__) . '/.env');
+
+DB::init();
+
+DB::query(<<<SQL
+ create table if not exists "villages" (
+ "id" bigserial primary key,
+
+ "name" character varying(255) not null,
+
+ "x" integer not null,
+ "y" integer not null,
+
+ "wood" bigint not null,
+ "clay" bigint not null,
+ "iron" bigint not null,
+ "food" bigint not null,
+
+ "satisfaction" bigint not null,
+ "created_at" timestamp(0) not null,
+ "updated_at" timestamp(0) not null,
+
+ unique ("x", "y")
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "village_buildings" (
+ "id" bigserial primary key,
+
+ "level" smallint not null,
+ "type" character varying(255) not null,
+
+ "village_id" bigint not null,
+ constraint "relation_village"
+ foreign key ("village_id") references villages("id") on delete cascade,
+
+ "created_at" timestamp(0) not null,
+ "updated_at" timestamp(0) not null,
+
+ unique ("type", "village_id")
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "village_units" (
+ "id" bigserial primary key,
+
+ "amount" bigint not null,
+ "type" character varying(255) not null,
+
+ "is_traveling" boolean not null default false,
+
+ "home_village_id" bigint not null,
+ constraint "relation_village_home"
+ foreign key ("home_village_id") references villages("id") on delete cascade,
+
+ "residence_village_id" bigint not null,
+ constraint "relation_village_residence"
+ foreign key ("residence_village_id") references villages("id") on delete cascade,
+
+ "created_at" timestamp(0) not null,
+ "updated_at" timestamp(0) not null,
+
+ unique ("type", "home_village_id", "residence_village_id")
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "village_storage_config" (
+ "id" bigserial primary key,
+
+ "wood" smallint not null,
+ "clay" smallint not null,
+ "iron" smallint not null,
+ "food" smallint not null,
+
+ "village_id" bigint not null,
+ constraint "relation_village"
+ foreign key ("village_id") references villages("id") on delete cascade,
+
+ "created_at" timestamp(0) not null,
+ "updated_at" timestamp(0) not null
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "events" (
+ "id" bigserial primary key,
+
+ "type" character varying(255) not null,
+ "time" timestamp(0) not null,
+ "payload" jsonb not null,
+
+ "village_id" bigint not null,
+ constraint "relation_village"
+ foreign key ("village_id") references villages("id") on delete cascade,
+
+ "created_at" timestamp(0) not null default current_timestamp,
+ "updated_at" timestamp(0) not null default current_timestamp
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "users" (
+ "id" bigserial primary key,
+
+ "username" character varying(255) not null,
+ "password" character varying(255) not null,
+ "email" character varying(255) not null,
+
+ "created_at" timestamp(0) not null,
+ "updated_at" timestamp(0) not null,
+
+ unique ("username")
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "user_settings" (
+ "id" bigserial primary key,
+
+ "display_name" 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,
+ "updated_at" timestamp(0) not null
+ );
+SQL);
+
+DB::query(<<<SQL
+ create table if not exists "system" (
+ "key" character varying(255) primary key,
+ "value" jsonb
+ );
+SQL);
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..c86117f
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "gemeinwohl-development/cooperative-common-good-game",
+ "authors": [
+ {
+ "name": "Daniel Weipert",
+ "email": "code@drogueronin.de"
+ }
+ ],
+ "require": {
+ "php": "^8.0",
+ "symfony/config": "^6.3",
+ "symfony/dotenv": "^6.3",
+ "symfony/http-foundation": "^6.3",
+ "symfony/routing": "^6.3",
+ "twig/twig": "^3.7"
+ },
+ "autoload": {
+ "psr-4": {
+ "App\\": "src/"
+ }
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..535f42f
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,859 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "ee17ea11ec110fee294c21a2ef86e5cc",
+ "packages": [
+ {
+ "name": "symfony/config",
+ "version": "v6.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/config.git",
+ "reference": "b47ca238b03e7b0d7880ffd1cf06e8d637ca1467"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/config/zipball/b47ca238b03e7b0d7880ffd1cf06e8d637ca1467",
+ "reference": "b47ca238b03e7b0d7880ffd1cf06e8d637ca1467",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/filesystem": "^5.4|^6.0",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "conflict": {
+ "symfony/finder": "<5.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/event-dispatcher": "^5.4|^6.0",
+ "symfony/finder": "^5.4|^6.0",
+ "symfony/messenger": "^5.4|^6.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^5.4|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Config\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "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.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-07-19T20:22:16+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf",
+ "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.4-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-05-23T14:45:45+00:00"
+ },
+ {
+ "name": "symfony/dotenv",
+ "version": "v6.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/dotenv.git",
+ "reference": "ceadb434fe2a6763a03d2d110441745834f3dd1e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/dotenv/zipball/ceadb434fe2a6763a03d2d110441745834f3dd1e",
+ "reference": "ceadb434fe2a6763a03d2d110441745834f3dd1e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "conflict": {
+ "symfony/console": "<5.4",
+ "symfony/process": "<5.4"
+ },
+ "require-dev": {
+ "symfony/console": "^5.4|^6.0",
+ "symfony/process": "^5.4|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Dotenv\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Registers environment variables from a .env file",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "dotenv",
+ "env",
+ "environment"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/dotenv/tree/v6.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-04-21T14:41:17+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v6.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/edd36776956f2a6fcf577edb5b05eb0e3bdc52ae",
+ "reference": "edd36776956f2a6fcf577edb5b05eb0e3bdc52ae",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v6.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-06-01T08:30:39+00:00"
+ },
+ {
+ "name": "symfony/http-foundation",
+ "version": "v6.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-foundation.git",
+ "reference": "cac1556fdfdf6719668181974104e6fcfa60e844"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/cac1556fdfdf6719668181974104e6fcfa60e844",
+ "reference": "cac1556fdfdf6719668181974104e6fcfa60e844",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.1",
+ "symfony/polyfill-php83": "^1.27"
+ },
+ "conflict": {
+ "symfony/cache": "<6.2"
+ },
+ "require-dev": {
+ "doctrine/dbal": "^2.13.1|^3.0",
+ "predis/predis": "^1.1|^2.0",
+ "symfony/cache": "^5.4|^6.0",
+ "symfony/dependency-injection": "^5.4|^6.0",
+ "symfony/expression-language": "^5.4|^6.0",
+ "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4",
+ "symfony/mime": "^5.4|^6.0",
+ "symfony/rate-limiter": "^5.2|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpFoundation\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Defines an object-oriented layer for the HTTP specification",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/http-foundation/tree/v6.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-08-22T08:20:46+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.28.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
+ "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.28-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-01-26T09:26:14+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.28.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "42292d99c55abe617799667f454222c54c60e229"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229",
+ "reference": "42292d99c55abe617799667f454222c54c60e229",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.28-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-07-28T09:04:16+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php80",
+ "version": "v1.28.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php80.git",
+ "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
+ "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.28-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php80\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ion Bazan",
+ "email": "ion.bazan@gmail.com"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-01-26T09:26:14+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.28.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php83.git",
+ "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11",
+ "reference": "b0f46ebbeeeda3e9d2faebdfbf4b4eae9b59fa11",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "symfony/polyfill-php80": "^1.14"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.28-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php83\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.28.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-08-16T06:22:46+00:00"
+ },
+ {
+ "name": "symfony/routing",
+ "version": "v6.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/routing.git",
+ "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/e7243039ab663822ff134fbc46099b5fdfa16f6a",
+ "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "doctrine/annotations": "<1.12",
+ "symfony/config": "<6.2",
+ "symfony/dependency-injection": "<5.4",
+ "symfony/yaml": "<5.4"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^1.12|^2",
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.2",
+ "symfony/dependency-injection": "^5.4|^6.0",
+ "symfony/expression-language": "^5.4|^6.0",
+ "symfony/http-foundation": "^5.4|^6.0",
+ "symfony/yaml": "^5.4|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Routing\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Maps an HTTP request to a set of configuration variables",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "router",
+ "routing",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/routing/tree/v6.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-07-31T07:08:24+00:00"
+ },
+ {
+ "name": "twig/twig",
+ "version": "v3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/twigphp/Twig.git",
+ "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/a0ce373a0ca3bf6c64b9e3e2124aca502ba39554",
+ "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-mbstring": "^1.3"
+ },
+ "require-dev": {
+ "psr/container": "^1.0|^2.0",
+ "symfony/phpunit-bridge": "^5.4.9|^6.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Twig\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com",
+ "homepage": "http://fabien.potencier.org",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Twig Team",
+ "role": "Contributors"
+ },
+ {
+ "name": "Armin Ronacher",
+ "email": "armin.ronacher@active-4.com",
+ "role": "Project Founder"
+ }
+ ],
+ "description": "Twig, the flexible, fast, and secure template language for PHP",
+ "homepage": "https://twig.symfony.com",
+ "keywords": [
+ "templating"
+ ],
+ "support": {
+ "issues": "https://github.com/twigphp/Twig/issues",
+ "source": "https://github.com/twigphp/Twig/tree/v3.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-08-28T11:09:02+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.0"
+ },
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..6d5f1c1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+version: "3"
+
+services:
+ app:
+ build:
+ context: docker/php
+ volumes:
+ - "./:/var/www/html"
+
+ db:
+ image: postgres
+ restart: unless-stopped
+ environment:
+ - "POSTGRES_DB=${DB_NAME}"
+ - "POSTGRES_USER=${DB_USER}"
+ - "POSTGRES_PASSWORD=${DB_PASSWORD}"
+ volumes:
+ - "db:/var/lib/postgresql/data"
+
+ web:
+ build:
+ context: docker/nginx
+ ports:
+ - "8080:80"
+ volumes:
+ - "./:/var/www/html"
+
+ adminer:
+ image: adminer
+ ports:
+ - "8081:8080"
+ environment:
+ - "ADMINER_DEFAULT_SERVER=db"
+
+volumes:
+ db:
diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile
new file mode 100644
index 0000000..cad37d9
--- /dev/null
+++ b/docker/nginx/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx:alpine
+
+ADD default.conf.template /etc/nginx/templates/
diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template
new file mode 100644
index 0000000..e8b82cd
--- /dev/null
+++ b/docker/nginx/default.conf.template
@@ -0,0 +1,19 @@
+server {
+ listen 0.0.0.0:80;
+
+ root /var/www/html/public;
+ index index.php;
+
+ location / {
+ try_files $uri $uri/ /index.php?$args;
+ }
+
+ location ~ \.php$ {
+ include fastcgi_params;
+
+ fastcgi_pass app:9000;
+
+ fastcgi_index index.php;
+ fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
+ }
+}
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile
new file mode 100644
index 0000000..204d790
--- /dev/null
+++ b/docker/php/Dockerfile
@@ -0,0 +1,7 @@
+FROM php:fpm-alpine
+
+RUN : \
+ && apk add libpq-dev icu-dev icu-data-full \
+ && docker-php-ext-install pdo_pgsql intl
+
+WORKDIR /var/www/html
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..18edd19
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,11 @@
+<?php
+
+use App\App;
+use Symfony\Component\Dotenv\Dotenv;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+$dotenv = new Dotenv();
+$dotenv->load(dirname(__DIR__) . '/.env');
+
+(new App())->run();
diff --git a/src/App.php b/src/App.php
new file mode 100644
index 0000000..f1fa97e
--- /dev/null
+++ b/src/App.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace App;
+
+use Symfony\Component\HttpFoundation\Request;
+
+class App
+{
+ public function __construct() {
+ if ($_ENV['APP_ENV'] === 'development') {
+ error_reporting(E_ALL);
+ }
+
+ // DB
+ DB::init();
+
+ // Router
+ Router::init(Request::createFromGlobals());
+
+ // View
+ View::init();
+
+ // Events
+ new EventRunner();
+ }
+
+ public function run(): void
+ {
+ $response = Router::execute();
+ $response->send();
+ }
+}
diff --git a/src/Controller/Building.php b/src/Controller/Building.php
new file mode 100644
index 0000000..d8fe656
--- /dev/null
+++ b/src/Controller/Building.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Controller;
+
+use App\DB;
+use App\Model\Building as Model;
+use App\Model\Event;
+use App\Model\Village;
+use App\Router;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Annotation\Route;
+
+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'));
+
+ // resources
+ foreach ($building->getResourceRequirements() as $resourceType => $resourceValue) {
+ $village->{$resourceType} -= $resourceValue;
+ }
+ $village->updateResources();
+
+ // event
+ $event = new Event();
+ $event->type = 'UpgradeBuilding';
+ $event->time = (new \DateTime())->add(\DateInterval::createFromDateString($building->getBuildTime() . ' seconds'));
+ $event->payload = json_encode([
+ 'id' => $building->id,
+ ]);
+
+ DB::query(
+ 'insert into events (type, time, payload, village_id) VALUES (:type, :time, :payload, :id)',
+ ['type' => $event->type, 'time' => $event->time->format('c'), 'payload' => $event->payload, 'id' => $village->id]
+ );
+
+ return new RedirectResponse(
+ Router::generate(
+ 'village.show',
+ ['x' => $request->get('x'), 'y' => $request->get('y')]
+ )
+ );
+ }
+}
diff --git a/src/Controller/Village.php b/src/Controller/Village.php
new file mode 100644
index 0000000..3854d29
--- /dev/null
+++ b/src/Controller/Village.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Controller;
+
+use App\DB;
+use App\Model\Event\UpgradeBuilding;
+use App\Model\Village as Model;
+use App\View;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Annotation\Route;
+
+class Village
+{
+ #[Route(path: '/villages', methods: ['GET'])]
+ public function list(): Response
+ {
+ $villages = DB::fetch(Model::class, "select * from villages");
+
+ return new Response(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'));
+
+ $results = DB::query(
+ <<<SQL
+ select events.*, village_buildings.type as building from events
+ join village_buildings
+ on village_buildings.id=(events.payload->'id')::bigint
+ where events.village_id=:id and events.type=:type
+ SQL, ['id' => $village->id, 'type' => 'UpgradeBuilding']
+ )->fetchAll();
+
+ $events = [];
+ foreach ($results as $row) {
+ $events[$row['type']][] = [
+ 'event' => DB::convertToModel(UpgradeBuilding::class, $row),
+ 'data' => [
+ 'building' => $row['building'],
+ ],
+ ];
+ }
+
+ return new Response(View::render('village.twig', [
+ 'village' => $village,
+ 'events' => $events,
+ ]));
+ }
+}
diff --git a/src/DB.php b/src/DB.php
new file mode 100644
index 0000000..4e34d32
--- /dev/null
+++ b/src/DB.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace App;
+
+class DB {
+ private static \PDO $connection;
+
+ public static function init(): void
+ {
+ $driver = $_ENV['DB_DRIVER'] ?? 'pgsql';
+ $host = $_ENV['DB_HOST'] ?? 'db';
+ $dbname = $_ENV['DB_NAME'];
+ $user = $_ENV['DB_USER'];
+ $password = $_ENV['DB_PASSWORD'];
+
+ self::$connection = new \PDO("pgsql:host=$host;dbname=$dbname", $user, $password);
+ }
+
+ /**
+ * @param string $query
+ * @param array $params
+ */
+ public static function query(string $query, array $params = []): \PDOStatement|false
+ {
+ /**@var \PDOStatement $statement*/
+ $statement = self::$connection->prepare($query);
+ $statement->execute($params);
+
+ return $statement;
+ }
+
+ /**
+ * @param string $class
+ * @param string $query
+ * @param array $params
+ *
+ * @return array<object>
+ */
+ public static function fetch(string $class, string $query, array $params = []): array
+ {
+ $rows = DB::query($query, $params)->fetchAll(\PDO::FETCH_ASSOC);
+
+ $results = [];
+ foreach ($rows as $row) {
+ $results[] = DB::convertToModel($class, $row);
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param string $class
+ * @param array $row
+ *
+ * @return object
+ */
+ public static function convertToModel(string $class, array $row): object
+ {
+ $object = new $class();
+
+ foreach ($row as $columnKey => $columnValue) {
+ $objectKey = explode('_', $columnKey);
+ $objectKey = $objectKey[0] . implode('', array_map('ucwords', array_slice($objectKey, 1)));
+
+ if (property_exists($object, $objectKey)) {
+ $propertyType = (new \ReflectionProperty($object, $objectKey))->getType();
+
+ if (class_exists($propertyType->getName())) {
+ $object->$objectKey = new ($propertyType->getName())($columnValue);
+ } else {
+ $object->$objectKey = $columnValue;
+ }
+ }
+ }
+
+ return $object;
+ }
+}
diff --git a/src/EventRunner.php b/src/EventRunner.php
new file mode 100644
index 0000000..d2f1589
--- /dev/null
+++ b/src/EventRunner.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace App;
+
+use App\Model\Building;
+use App\Model\Building\ClayPit;
+use App\Model\Building\Farm;
+use App\Model\Building\IronMine;
+use App\Model\Building\ResourceGenerator;
+use App\Model\Building\Storage;
+use App\Model\Building\WoodCutter;
+use App\Model\Event;
+use App\Model\Village;
+
+class EventRunner
+{
+ public function __construct()
+ {
+ $results = DB::query('select * from events where time < now()')->fetchAll();
+
+ foreach ($results as $row) {
+ /**@var Event $event*/
+ $event = DB::convertToModel(Event::resolveType($row['type']), $row);
+ $event();
+
+ DB::query(
+ 'delete from events where id=:id',
+ ['id' => $event->id]
+ );
+ }
+
+
+ // Resources
+
+ $lastTick = json_decode(DB::query('select value from system where key=:key', ['key' => 'last_resource_tick'])->fetchColumn());
+ if ($lastTick) {
+ $lastTick = new \DateTime($lastTick);
+ } else {
+ $lastTick = (new \DateTime())->modify('- 1 min');
+ }
+
+ $diff = (new \DateTime())->diff($lastTick);
+ $tickMultiplier = $diff->i;
+
+ if ($tickMultiplier > 0) {
+ $villages = DB::fetch(Village::class, 'select id,wood,clay,iron,food from villages');
+ foreach ($villages as $village) {
+ /**@var Village $village*/
+
+ /**@var array<int, WoodCutter|ClayPit|IronMine|Farm> $resourceGenerators*/
+ $resourceGenerators = [];
+
+ /**@var Storage $storage*/
+ $storage = null;
+
+ /**@var Building[] $buildings*/
+ $buildings = DB::fetch(ResourceGenerator::class, 'select level,type,village_id from village_buildings where village_id=:id', ['id' => $village->id]);
+ foreach ($buildings as $building) {
+ if ($building->type == 'Storage') {
+ $storage = $building->cast();
+ }
+ else if (in_array($building->type, ['WoodCutter', 'ClayPit', 'IronMine', 'Farm'])) {
+ $resourceGenerators[] = $building->cast();
+ }
+ }
+
+ $resources = [];
+ foreach ($resourceGenerators as $generator) {
+ $village->{$generator->resourceType} = min(
+ $village->{$generator->resourceType} + ($generator->getResourceIncrementor() * $tickMultiplier),
+ $storage->getResourceCapacity($generator->resourceType)
+ );
+ }
+
+ DB::query(
+ 'update villages set wood=:wood, clay=:clay, iron=:iron, food=:food where id=:id',
+ ['wood' => $village->wood, 'clay' => $village->clay, 'iron' => $village->iron, 'food' => $village->food, 'id' => $village->id]
+ );
+ }
+
+ DB::query('delete from system where key=:key', ['key' => 'last_resource_tick']);
+ $value = (new \DateTime((new \DateTime())->format('Y-m-d H:i')))->format('c');
+ DB::query('insert into system (key,value) VALUES (:key,:value)', ['key' => 'last_resource_tick', 'value' => json_encode($value)]);
+ }
+ }
+}
diff --git a/src/Model.php b/src/Model.php
new file mode 100644
index 0000000..5f45ed4
--- /dev/null
+++ b/src/Model.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+class Model
+{
+ public static function castToType(object $original, string $cast): object
+ {
+ $object = new $cast();
+
+ foreach (get_class_vars(get_class($original)) as $property => $_) {
+ if (! empty($original->$property) && empty($object->$property)) {
+ $object->$property = $original->$property;
+ }
+ }
+
+ return $object;
+ }
+}
diff --git a/src/Model/Building.php b/src/Model/Building.php
new file mode 100644
index 0000000..eb166f9
--- /dev/null
+++ b/src/Model/Building.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace App\Model;
+
+use App\DB;
+use App\Model;
+
+class Building
+{
+ public int $id;
+
+ public int $level;
+ public string $type;
+ public int $villageId;
+
+ public \DateTime $createdAt;
+ public \DateTime $updatedAt;
+
+ public string $unitType;
+ public int $buildTimeFactor;
+ public array $resourceRequirements;
+ public array $buildingRequirements;
+ public array $techRequirements;
+ public int $maxLevel;
+
+
+ public static function get(int $id): ?Building
+ {
+ $results = DB::fetch(Building::class, 'select * from village_buildings where id=:id', ['id' => $id]);
+
+ return isset($results[0]) ? $results[0]->cast() : null;
+ }
+
+ public static function getByVillage(int $villageId, string $buildingType): ?Building
+ {
+ $results = DB::fetch(Building::class, 'select * from village_buildings where village_id=:id and type=:type', ['id' => $villageId, 'type' => $buildingType]);
+
+ return isset($results[0]) ? $results[0]->cast() : null;
+ }
+
+ public static function getByVillageCoordinates(int $x, int $y, string $buildingType): ?Building
+ {
+ $results = DB::fetch(
+ Building::class,
+ <<<SQL
+ select village_buildings.*
+ from village_buildings
+ join villages on (villages.x=:x and villages.y=:y)
+ where village_buildings.village_id=villages.id and type=:type
+ SQL,
+ ['x' => $x, 'y' => $y, 'type' => $buildingType]
+ );
+
+ return isset($results[0]) ? $results[0]->cast() : null;
+ }
+
+
+ public function getBuildTime(): int
+ {
+ $townHall = Village::getBuilding($this->villageId, 'TownHall');
+
+ $nextLevel = $this->level + 1;
+
+ return intval($nextLevel * ($nextLevel / $townHall->level) * $_ENV['BASE_BUILDING_BUILD_TIME_FACTOR'] * $this->buildTimeFactor);
+ }
+
+ /**
+ * @return array<string, int>
+ */
+ public function getResourceRequirements(): array
+ {
+ return $this->getResourceRequirementsForLevel($this->level);
+ }
+
+ /**
+ * @return array<string, int>
+ */
+ public function getResourceRequirementsForLevel(int $level): array
+ {
+ $level += 1;
+
+ return array_map(
+ fn ($resourceRequirement) => ceil(log($level * 2) * $resourceRequirement * 64 * $level),
+ $this->resourceRequirements
+ );
+ }
+
+
+ /* OOP */
+
+ public function cast(): Building
+ {
+ $class = Building::resolveType($this->type);
+
+ return Model::castToType($this, Building::resolveType($this->type));
+ }
+
+ public static function resolveType(string $type): string
+ {
+ return __NAMESPACE__ . '\\Building\\' . $type;
+ }
+}
diff --git a/src/Model/Building/ClayPit.php b/src/Model/Building/ClayPit.php
new file mode 100644
index 0000000..8127818
--- /dev/null
+++ b/src/Model/Building/ClayPit.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Model\Building;
+
+class ClayPit extends ResourceGenerator
+{
+ public string $unitType = 'PitWorker';
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+
+ public string $resourceType = 'clay';
+}
diff --git a/src/Model/Building/Farm.php b/src/Model/Building/Farm.php
new file mode 100644
index 0000000..aaa58b5
--- /dev/null
+++ b/src/Model/Building/Farm.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Model\Building;
+
+use App\Model\Unit;
+use App\Model\Village;
+
+class Farm extends ResourceGenerator
+{
+ public string $unitType = 'Farmer';
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+
+ public string $resourceType = 'food';
+
+ public function getResourceIncrementor(): int
+ {
+ $populationDemand = array_reduce(
+ Village::getUnits($this->villageId, Village::FETCH_UNIT_RESIDENCE),
+ function ($carry, Unit $unit) {
+ return $carry + $unit->getPopulationDemand();
+ }
+ );
+
+ return parent::getResourceIncrementor() - $populationDemand;
+ }
+}
diff --git a/src/Model/Building/IronMine.php b/src/Model/Building/IronMine.php
new file mode 100644
index 0000000..4bf5cc6
--- /dev/null
+++ b/src/Model/Building/IronMine.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Model\Building;
+
+class IronMine extends ResourceGenerator
+{
+ public string $unitType = 'Miner';
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+
+ public string $resourceType = 'iron';
+}
diff --git a/src/Model/Building/ResourceGenerator.php b/src/Model/Building/ResourceGenerator.php
new file mode 100644
index 0000000..5f1a6bb
--- /dev/null
+++ b/src/Model/Building/ResourceGenerator.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Model\Building;
+
+use App\Model\Building;
+use App\Model\Unit;
+
+class ResourceGenerator extends Building
+{
+ public string $resourceType;
+
+ public function getResourceIncrementor(): int
+ {
+ $amountResiding = Unit::getAmountResiding($this->unitType, $this->villageId);
+
+ return (int)ceil(
+ log(
+ ($this->level * $amountResiding) + 1
+ ) * $_ENV['BASE_RESOURCE_GENERATION_FACTOR']
+ );
+ }
+}
diff --git a/src/Model/Building/Storage.php b/src/Model/Building/Storage.php
new file mode 100644
index 0000000..fde4c4e
--- /dev/null
+++ b/src/Model/Building/Storage.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Model\Building;
+
+use App\Model\Building;
+use App\Model\Village;
+
+class Storage extends Building
+{
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+
+ public function getCapacity(): int
+ {
+ return $this->level * 2560;
+ }
+
+ public function getResourceCapacity(string $resourceType): int
+ {
+ $p = Village::getStorageConfig($this->villageId)->$resourceType / 100;
+
+ return ceil($this->getCapacity() * $p);
+ }
+}
diff --git a/src/Model/Building/TownHall.php b/src/Model/Building/TownHall.php
new file mode 100644
index 0000000..608f083
--- /dev/null
+++ b/src/Model/Building/TownHall.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Model\Building;
+
+use App\Model\Building;
+
+class TownHall extends Building
+{
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ 'clay' => 1.0,
+ 'iron' => 1.0,
+ ];
+}
diff --git a/src/Model/Building/WoodCutter.php b/src/Model/Building/WoodCutter.php
new file mode 100644
index 0000000..86bde9b
--- /dev/null
+++ b/src/Model/Building/WoodCutter.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Model\Building;
+
+class WoodCutter extends ResourceGenerator
+{
+ public string $unitType = 'WoodCutter';
+ public int $buildTimeFactor = 1;
+ public int $maxLevel = 25;
+
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+
+ public string $resourceType = 'wood';
+}
diff --git a/src/Model/Event.php b/src/Model/Event.php
new file mode 100644
index 0000000..aa235f9
--- /dev/null
+++ b/src/Model/Event.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Model;
+
+use App\Model;
+
+class Event
+{
+ public int $id;
+
+ public string $type;
+ public \DateTime $time;
+ public string $payload;
+
+ public int $villageId;
+
+
+ /* OOP */
+
+ public function cast(): Event
+ {
+ $class = Event::resolveType($this->type);
+ $object = new $class();
+
+ return Model::castToType($this, Event::resolveType($this->type));
+ }
+
+ public static function resolveType(string $type): string
+ {
+ return __NAMESPACE__ . '\\Event\\' . $type;
+ }
+}
diff --git a/src/Model/Event/TrainUnits.php b/src/Model/Event/TrainUnits.php
new file mode 100644
index 0000000..0c7e0de
--- /dev/null
+++ b/src/Model/Event/TrainUnits.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Model\Event;
+
+use App\DB;
+use App\Model\Event;
+
+class TrainUnits extends Event
+{
+ /**
+ * @return void
+ */
+ public function __invoke(): void
+ {
+ $payload = json_decode($this->payload, true);
+
+ DB::query(
+ <<<SQL
+ insert into village_units (amount, type, is_traveling, home_village_id, residence_village_id)
+ values (:amount, :type, false, :id, :id)
+ on conflict (type, home_village_id, residence_village_id)
+ do update set amount = excluded.amount+:amount
+ SQL,
+ ['amount' => $payload['amount'], 'type' => $payload['type'], 'id' => $this->villageId]
+ );
+ }
+}
diff --git a/src/Model/Event/UpgradeBuilding.php b/src/Model/Event/UpgradeBuilding.php
new file mode 100644
index 0000000..c014cfe
--- /dev/null
+++ b/src/Model/Event/UpgradeBuilding.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Model\Event;
+
+use App\DB;
+use App\Model\Event;
+
+class UpgradeBuilding extends Event
+{
+ /**
+ * @return void
+ */
+ public function __invoke(): void
+ {
+ $payload = json_decode($this->payload, true);
+
+ DB::query(
+ 'update village_buildings set level=level+1 where id=:id',
+ ['id' => $payload['id']]
+ );
+
+ DB::query(
+ 'delete from events where id=:id',
+ ['id' => $this->id]
+ );
+ }
+}
diff --git a/src/Model/Unit.php b/src/Model/Unit.php
new file mode 100644
index 0000000..a0d1a35
--- /dev/null
+++ b/src/Model/Unit.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Model;
+
+use App\DB;
+use App\Model;
+
+class Unit
+{
+ public int $id;
+
+ public int $amount;
+ public string $type;
+ public bool $isTraveling;
+
+ public int $homeVillageId;
+ public int $residenceVillageId;
+
+ public string $createdAt;
+ public string $updatedAt;
+
+ public string $buildingType;
+ public int $travelTime;
+ public int $populationDemandFactor;
+ public array $resourceRequirements = [];
+
+
+ public function getBuildTime(int $amount): int
+ {
+ return intval(($_ENV['BASE_UNIT_BUILD_TIME_FACTOR'] / ($this->getBuilding()->level ?: 1)) * $amount);
+ }
+
+ public function getPopulationDemand(): int
+ {
+ return $this->getPopulationDemandForAmount($this->amount);
+ }
+
+ public function getPopulationDemandForAmount(int $amount): int
+ {
+ return $amount * $this->populationDemandFactor;
+ }
+
+
+ /* Relations */
+
+ public function getBuilding(): ?Building
+ {
+ return Village::getBuilding($this->homeVillageId, $this->buildingType);
+ }
+
+ public function cast(): Unit
+ {
+ $class = Unit::resolveType($this->type);
+
+ return Model::castToType($this, Unit::resolveType($this->type));
+ }
+
+ public static function resolveType(string $type): string
+ {
+ return __NAMESPACE__ . '\\Unit\\' . $type;
+ }
+
+
+ /* Static */
+
+ public static function getAmountResiding(string $unitType, int $villageId): int
+ {
+ $statement = DB::query(
+ 'select SUM(amount) from village_units where type=:type and residence_village_id=:id',
+ ['type' => $unitType, 'id' => $villageId]
+ );
+ $result = $statement->fetch()['sum'];
+
+ return intval($result);
+ }
+
+}
diff --git a/src/Model/Unit/Farmer.php b/src/Model/Unit/Farmer.php
new file mode 100644
index 0000000..de37802
--- /dev/null
+++ b/src/Model/Unit/Farmer.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Model\Unit;
+
+use App\Model\Unit;
+
+class Farmer extends Unit
+{
+ public string $buildingType = 'Farm';
+ public int $travelTime = 1;
+ public int $populationDemandFactor = 1;
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+}
diff --git a/src/Model/Unit/Miner.php b/src/Model/Unit/Miner.php
new file mode 100644
index 0000000..ae6c00a
--- /dev/null
+++ b/src/Model/Unit/Miner.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Model\Unit;
+
+use App\Model\Unit;
+
+class Miner extends Unit
+{
+ public string $buildingType = 'IronMine';
+ public int $travelTime = 1;
+ public int $populationDemandFactor = 1;
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+}
diff --git a/src/Model/Unit/PitWorker.php b/src/Model/Unit/PitWorker.php
new file mode 100644
index 0000000..4f873b4
--- /dev/null
+++ b/src/Model/Unit/PitWorker.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Model\Unit;
+
+use App\Model\Unit;
+
+class PitWorker extends Unit
+{
+ public string $buildingType = 'ClayPit';
+ public int $travelTime = 1;
+ public int $populationDemandFactor = 1;
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+}
diff --git a/src/Model/Unit/WoodCutter.php b/src/Model/Unit/WoodCutter.php
new file mode 100644
index 0000000..17923ca
--- /dev/null
+++ b/src/Model/Unit/WoodCutter.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Model\Unit;
+
+use App\Model\Unit;
+
+class WoodCutter extends Unit
+{
+ public string $buildingType = 'WoodCutter';
+ public int $travelTime = 1;
+ public int $populationDemandFactor = 1;
+ public array $resourceRequirements = [
+ 'wood' => 1.0,
+ ];
+}
diff --git a/src/Model/User.php b/src/Model/User.php
new file mode 100644
index 0000000..fcb1869
--- /dev/null
+++ b/src/Model/User.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Model;
+
+class User
+{
+}
diff --git a/src/Model/Village.php b/src/Model/Village.php
new file mode 100644
index 0000000..cd1c749
--- /dev/null
+++ b/src/Model/Village.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace App\Model;
+
+use App\DB;
+use App\Model\Building\Storage;
+use App\Model\Village\StorageConfig;
+
+class Village
+{
+ public int $id;
+
+ public string $name;
+
+ public int $x;
+ public int $y;
+
+ public int $wood;
+ public int $clay;
+ public int $iron;
+ public int $food;
+
+ public int $satisfaction;
+
+ public string $createdAt;
+ public string $updatedAt;
+
+ public static function canBuild(Village $village, Building $building): bool
+ {
+ if ($building->level >= $building->maxLevel) {
+ return false;
+ }
+
+ $resourceRequirements = $building->getResourceRequirements();
+ foreach ($resourceRequirements as $resourceType => $requirement) {
+ if ($village->$resourceType < $requirement) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /* DB - Actions */
+
+ public static function get(int $id): ?Village
+ {
+ return DB::fetch(Village::class, 'select * from villages where id=:id', ['id' => $id])[0] ?? null;
+ }
+
+ public static function getByCoordinates(int $x, int $y): ?Village
+ {
+ return DB::fetch(Village::class, 'select * from villages where x=:x and y=:y', ['x' => $x, 'y' => $y])[0] ?? null;
+ }
+
+ public function updateResources(): mixed
+ {
+ return DB::query(
+ 'update villages set wood=:wood,clay=:clay,iron=:iron,food=:food where id=:id',
+ ['wood' => $this->wood, 'clay' => $this->clay, 'iron' => $this->iron, 'food' => $this->food, 'id' => $this->id]
+ );
+ }
+
+ /* DB - Relations */
+
+ public static function getBuildings(int $villageId): array
+ {
+ $buildings = DB::fetch(Building::class, 'select * from village_buildings where village_id=:id', ['id' => $villageId]);
+
+ return array_map(function (Building $building) {
+ return $building->cast();
+ }, $buildings);
+ }
+
+ public static function getBuilding(int $villageId, string $buildingType): ?Building
+ {
+ $results = DB::fetch(
+ Building::resolveType($buildingType),
+ 'select * from village_buildings where village_id=:id and type=:type',
+ ['id' => $villageId, 'type' => $buildingType]
+ );
+
+ return isset($results[0]) ? $results[0]->cast() : null;
+ }
+
+ public static function getStorage(int $villageId): ?Storage
+ {
+ return Village::getBuilding($villageId, 'Storage');
+ }
+
+ public static function getStorageConfig(int $villageId): ?StorageConfig
+ {
+ $results = DB::fetch(
+ StorageConfig::class,
+ 'select * from village_storage_config where village_id=:id',
+ ['id' => $villageId]
+ );
+
+ return $results[0] ?? null;
+ }
+
+ public const FETCH_UNIT_HOME_AT_HOME = 1;
+ public const FETCH_UNIT_HOME_AT_SUPPORT = 2;
+ public const FETCH_UNIT_SUPPORT_AT_HOME = 3;
+ public const FETCH_UNIT_RESIDENCE = 4;
+
+ public static function getUnit(string $unitType, int $flag): ?Unit
+ {
+ }
+
+ /**
+ * @param int $flag
+ *
+ * @return array<int, Unit>
+ */
+ public static function getUnits(int $villageId, $flag = Village::FETCH_UNIT_ALL): array
+ {
+ if ($flag == Village::FETCH_UNIT_HOME_AT_HOME) {
+ $units = DB::fetch(Unit::class, 'select * from village_units where home_village_id=:id and residence_village_id=:id', ['id' => $villageId]);
+ }
+ else if ($flag == Village::FETCH_UNIT_HOME_AT_SUPPORT) {
+ $units = DB::fetch(Unit::class, 'select * from village_units where home_village_id=:id and residence_village_id!=:id', ['id' => $villageId]);
+ }
+ else if ($flag == Village::FETCH_UNIT_SUPPORT_AT_HOME) {
+ $units = DB::fetch(Unit::class, 'select * from village_units where home_village_id!=:id and residence_village_id=:id', ['id' => $villageId]);
+ }
+ else if ($flag == Village::FETCH_UNIT_RESIDENCE) {
+ $units = DB::fetch(Unit::class, 'select * from village_units where residence_village_id=:id', ['id' => $villageId]);
+ }
+
+ return array_map(function (Unit $unit) {
+ return $unit->cast();
+ }, $units);
+ }
+}
diff --git a/src/Model/Village/StorageConfig.php b/src/Model/Village/StorageConfig.php
new file mode 100644
index 0000000..272eaf5
--- /dev/null
+++ b/src/Model/Village/StorageConfig.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Model\Village;
+
+class StorageConfig
+{
+ public int $id;
+
+ public int $wood;
+ public int $clay;
+ public int $iron;
+ public int $food;
+
+ public int $villageId;
+
+ public string $createdAt;
+ public string $updatedAt;
+}
diff --git a/src/Router.php b/src/Router.php
new file mode 100644
index 0000000..8b24000
--- /dev/null
+++ b/src/Router.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace App;
+
+use App\Support\RouteLoader;
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Exception\MethodNotAllowedException;
+use Symfony\Component\Routing\Exception\ResourceNotFoundException;
+use Symfony\Component\Routing\Generator\UrlGenerator;
+use Symfony\Component\Routing\Loader\AnnotationFileLoader;
+use Symfony\Component\Routing\Matcher\UrlMatcher;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Component\Routing\RouteCollection;
+
+class Router
+{
+ public static Request $request;
+ public static RequestContext $context;
+ public static RouteCollection $routes;
+
+ public static function init(Request $request): void
+ {
+ self::$request = $request;
+
+ self::$context = new RequestContext();
+ self::$context->fromRequest($request);
+
+ self::$routes = new RouteCollection();
+ $loader = new AnnotationFileLoader(new FileLocator(), new RouteLoader());
+ $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(__DIR__ . '/Controller'));
+ foreach ($iterator as $file) {
+ /**@var \SplFileInfo $file*/
+ if (in_array($file->getFilename(), ['.', '..'])) continue;
+
+ $collection = $loader->load($file->getPathname(), 'attribute');
+ self::$routes->addCollection($collection);
+ }
+ }
+
+ public static function execute(): Response
+ {
+ try {
+ $matcher = new UrlMatcher(self::$routes, self::$context);
+ $match = $matcher->matchRequest(self::$request);
+
+ foreach ($match as $key => $value) {
+ if (str_starts_with($key, '_')) continue;
+
+ self::$request->query->set($key, $value);
+ }
+
+ /**@var \ReflectionClass $class*/
+ $class = $match['_']['class'];
+ /**@var \ReflectionMethod $method*/
+ $method = $match['_']['method'];
+
+ return ($class->newInstance())->{$method->getName()}(self::$request);
+ } catch (ResourceNotFoundException $exception) {
+ return new Response('404', 404);
+ } catch (MethodNotAllowedException $exception) {
+ return new Response('403', 403);
+ } catch (\Exception $exception) {
+ return new Response('500: ' . $exception->getMessage(), 500);
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param array $parameters
+ * @param int $referenceType
+ */
+ public static function generate(string $name, array $parameters = [], int $referenceType = 1): string
+ {
+ $generator = new UrlGenerator(self::$routes, self::$context);
+
+ return $generator->generate($name, $parameters, $referenceType);
+ }
+}
diff --git a/src/Support/ResourceType.php b/src/Support/ResourceType.php
new file mode 100644
index 0000000..96c0f2c
--- /dev/null
+++ b/src/Support/ResourceType.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Support;
+
+enum ResourceType: string
+{
+ case Wood = 'Wood';
+ case Clay = 'Clay';
+ case Iron = 'Iron';
+ case Food = 'Food';
+}
diff --git a/src/Support/RouteLoader.php b/src/Support/RouteLoader.php
new file mode 100644
index 0000000..ba124c5
--- /dev/null
+++ b/src/Support/RouteLoader.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Support;
+
+use Symfony\Component\Routing\Loader\AnnotationClassLoader;
+use Symfony\Component\Routing\Route;
+
+class RouteLoader extends AnnotationClassLoader
+{
+ protected function configureRoute(Route $route, \ReflectionClass $class, \ReflectionMethod $method, object $annotation) {
+ $route->setDefault('_', compact('class', 'method', 'annotation'));
+ }
+
+ protected function getDefaultRouteName(\ReflectionClass $class, \ReflectionMethod $method)
+ {
+ $name = parent::getDefaultRouteName($class, $method);
+
+ return str_replace(
+ '_',
+ '.',
+ str_replace('app_controller_', '', $name)
+ );
+ }
+}
diff --git a/src/Support/UnitType.php b/src/Support/UnitType.php
new file mode 100644
index 0000000..5a53f24
--- /dev/null
+++ b/src/Support/UnitType.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Support;
+
+enum UnitType: string
+{
+ case WoodCutter = 'WoodCutter';
+ case PitWorker = 'PitWorker';
+ case Miner = 'Miner';
+ case Farmer = 'Farmer';
+}
diff --git a/src/View.php b/src/View.php
new file mode 100644
index 0000000..5f9ca34
--- /dev/null
+++ b/src/View.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App;
+
+use Twig\Environment;
+use Twig\Extension\DebugExtension;
+use Twig\Loader\FilesystemLoader;
+use Twig\TwigFilter;
+
+class View
+{
+ private static Environment $twig;
+
+ public static function init(): void
+ {
+ $loader = new FilesystemLoader(dirname(__DIR__) . '/views');
+ self::$twig = new Environment($loader, [
+ 'debug' => $_ENV['APP_ENV'] === 'development',
+ ]);
+
+ self::$twig->addExtension(new DebugExtension());
+ // self::$twig->addExtension(new IntlExtension());
+
+ self::$twig->addFilter(new TwigFilter('buildTime', function ($buildTime) {
+ return @sprintf('%02d:%02d:%02d', $buildTime / 3600, ($buildTime / 60) % 60, $buildTime % 60);
+ }));
+ }
+
+ /**
+ * @param string $name
+ * @param array $context
+ */
+ public static function render(string $name, array $context = []): string
+ {
+ return self::$twig->render($name, $context);
+ }
+}
diff --git a/views/base.twig b/views/base.twig
new file mode 100644
index 0000000..94e5037
--- /dev/null
+++ b/views/base.twig
@@ -0,0 +1,26 @@
+{% extends 'root.twig' %}
+
+{% block body %}
+<div class="wrap">
+ <header></header>
+
+ <main>
+ {% block main %}{% endblock %}
+ </main>
+
+ <footer></footer>
+
+ <div class="global-timer"></div>
+ <script>
+ const timer = document.querySelectorAll('.global-timer');
+ function setTime () {
+ const now = new Date();
+ for (const t of timer) {
+ t.innerHTML = now;
+ }
+ }
+ setTime();
+ setInterval(setTime, 1000);
+ </script>
+</div>
+{% endblock %}
diff --git a/views/components/timer.twig b/views/components/timer.twig
new file mode 100644
index 0000000..97977da
--- /dev/null
+++ b/views/components/timer.twig
@@ -0,0 +1,25 @@
+<span class="timer" data-time="{{ time }}">
+ <span class="timer__time"></span>
+</span>
+<script>
+document.addEventListener('DOMContentLoaded', function (ev) {
+ const timer = document.querySelector('.timer[data-time="{{ time }}"] .timer__time');
+ const time = new Date('{{ time }}');
+
+ const interval = setInterval(setTime, 1000);
+ function setTime() {
+ let diff = time - new Date();
+ if (diff <= -1) {
+ clearInterval(interval);
+ }
+
+ const hh = Math.floor(diff/1000/60/60);
+ diff -= hh*1000*60*60;
+ const mm = Math.floor(diff/1000/60);
+ diff -= mm*1000*60;
+ const ss = Math.floor(diff/1000);
+ timer.innerHTML = `${('00' + hh).slice(-2)}:${('00' + mm).slice(-2)}:${('00' + ss).slice(-2)}`;
+ }
+ setTime();
+});
+</script>
diff --git a/views/root.twig b/views/root.twig
new file mode 100644
index 0000000..11dc665
--- /dev/null
+++ b/views/root.twig
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+</head>
+<body>
+ {% block body %}{% endblock %}
+</body>
+</html>
diff --git a/views/village.twig b/views/village.twig
new file mode 100644
index 0000000..7bb55b2
--- /dev/null
+++ b/views/village.twig
@@ -0,0 +1,169 @@
+{% extends 'base.twig' %}
+
+{% block main %}
+<div class="village">
+
+ <div class="village__top">
+ <div><span>{{ village.x }} x {{ village.y }}</span> &mdash; {{ village.name }}</div>
+
+ <div class="resources">
+ <span>wood: <b>{{ village.wood }}</b> / {{ village.getStorage(village.id).getResourceCapacity('wood') }} &ndash; {{ village.getBuilding(village.id, 'WoodCutter').getResourceIncrementor() }}</span>
+ <span>clay: <b>{{ village.clay }}</b> / {{ village.getStorage(village.id).getResourceCapacity('clay') }} &ndash; {{ village.getBuilding(village.id, 'ClayPit').getResourceIncrementor() }}</span>
+ <span>iron: <b>{{ village.iron }}</b> / {{ village.getStorage(village.id).getResourceCapacity('iron') }} &ndash; {{ village.getBuilding(village.id, 'IronMine').getResourceIncrementor() }}</span>
+ <span>food: <b>{{ village.food }}</b> / {{ village.getStorage(village.id).getResourceCapacity('food') }} &ndash; {{ village.getBuilding(village.id, 'Farm').getResourceIncrementor() }}</span>
+
+ <span>capacity: {{ village.getStorage(village.id).getCapacity() }}</span>
+ </div>
+ </div>
+
+ <div class="village__events">
+ <h3>Events</h3>
+
+ {% if events['UpgradeBuilding'] %}
+ <h4>Upgrade Buildings</h4>
+ <table>
+ <thead>
+ <tr>
+ <th>Building</th>
+ <th>Time</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for event in events['UpgradeBuilding'] %}
+ <tr>
+ <td>{{ event.data.building }}</td>
+ <td class="timer">
+ {% include 'components/timer.twig' with { 'time': event.event.time|date('c') } %}
+ </td>
+ <td>
+ <a class="btn" href="/village/{{ village.x }}/{{ village.y }}/building/{{ event.data.building }}/build/cancel">
+ Cancel
+ </a>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ {% endif %}
+
+ {% if events.train %}
+ <h4>Train Units</h4>
+ {% endif %}
+
+ {% if events.send %}
+ <h4>Send Resources / Units</h4>
+ {% endif %}
+ </div>
+
+ <div class="village__main">
+ <div class="village__buildings">
+ <h3>Buildings</h3>
+ <table>
+ <thead>
+ <tr>
+ <th>Type</th>
+ <th>Level</th>
+ <th>Build Time</th>
+ <th>Resources</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for building in village.getBuildings(village.id) %}
+ <tr class="village__buildings__row">
+ <td>{{ building.type }}</td>
+ <td>{{ building.level }}</td>
+ <td>{{ building.getBuildTime() | buildTime }}</td>
+ <td class="resources">
+ <span>wood: {{ building.getResourceRequirements()['wood'] }}</span>
+ &nbsp;
+ <span>clay: {{ building.getResourceRequirements()['clay'] }}</span>
+ &nbsp;
+ <span>iron: {{ building.getResourceRequirements()['iron'] }}</span>
+ </td>
+ <td>
+ <form action="/village/{{ village.x }}/{{ village.y }}/building/{{ building.type }}/level-up" method="post">
+ <input type="submit" value="Level up" {{ village.canBuild(village, building) ? '' : 'disabled' }}>
+ </form>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+
+ <div class="village_units">
+ <h3>Units</h3>
+ <table>
+ <thead>
+ <tr>
+ <th>Type</th>
+ <th>Amount</th>
+ <th>Build Time</th>
+ <th>Resources</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for unit in village.getUnits(village.id, 1) %}
+ <tr>
+ <td>{{ unit.type }}</td>
+ <td>{{ unit.amount }}</td>
+ <td>
+ {{ unit.getBuildTime(1) | buildTime }}
+ </td>
+ <td>
+ <span>wood: {{ unit.getResourceRequirements()['wood'] }}</span>
+ &nbsp;
+ <span>clay: {{ unit.getResourceRequirements()['clay'] }}</span>
+ &nbsp;
+ <span>iron: {{ unit.getResourceRequirements()['iron'] }}</span>
+ &nbsp;
+ <span>food: {{ unit.getResourceRequirements()['food'] ?? 0 }}</span>
+ </td>
+ <td>
+ <form action="/village/{{ village.x }}/{{ village.y }}/unit/{{ unit.type }}/create" method="post" class="inline">
+ <input type="number" min="0" name="amount" placeholder="Amount">
+ <input type="submit" value="Create">
+ </form>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+
+ <h4>Supporting Units</h4>
+ <table>
+ <thead>
+ <tr>
+ <th>Type</th>
+ <th>Amount</th>
+ <th>Origin</th>
+ <th>Location</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for unit in village.getUnits(village.id, 2) | merge(village.getUnits(village.id, 3)) %}
+ <tr>
+ <td>{{ unit.type }}</td>
+ <td>{{ unit.amount }}</td>
+ <td>{{ village.get(unit.homeVillageId).name }}</td>
+ <td>{{ not unit.isTraveling ? village.get(unit.residenceVillageId).name : '~traveling~' }}</td>
+ <td>
+ {% if not unit.isTraveling %}
+ <form action="/village/{{ village.id }}/unit/{{ unit.id }}/send-back" method="post" class="inline">
+ <input type="number" min="1" max="{{ unit.amount }}" name="amount" placeholder="Amount" required>
+ <input type="submit" value="{{ (unit.homeVillageId != unit.residenceVillageId) ? 'Send Back' : 'Recall Home' }}">
+ </form>
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+{% endblock %}
diff --git a/views/villages.twig b/views/villages.twig
new file mode 100644
index 0000000..21d8cec
--- /dev/null
+++ b/views/villages.twig
@@ -0,0 +1,38 @@
+{% extends 'base.twig' %}
+
+{% block main %}
+<table>
+ <thead>
+ <tr>
+ <td>Name</td>
+ <td>Coordinates</td>
+ <td>Wood</td>
+ <td>Clay</td>
+ <td>Iron</td>
+ <td>Food</td>
+ <td>Storage</td>
+ <td>Reputation</td>
+ </tr>
+ </thead>
+ <tbody>
+ {% for village in villages %}
+ <tr>
+ <td>
+ <a href="/village/{{ village.x}}/{{ village.y }}">
+ {{ village.name }}
+ </a>
+ </td>
+ <td>
+ { x: {{ village.x }}, y: {{ village.y }} }
+ </td>
+ <td>{{ village.wood }}</td>
+ <td>{{ village.clay }}</td>
+ <td>{{ village.iron }}</td>
+ <td>{{ village.food }}</td>
+ <td>{{ village.getStorage(village.id).getCapacity() * (25 / 100) }}</td>
+ <td>{{ village.reputation }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+{% endblock %}