diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ff6158..4004f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: [ push ] jobs: - tests: + qa: runs-on: ubuntu-20.04 env: DB_HOST: 127.0.0.1 @@ -22,16 +22,17 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v2 - - uses: nanasess/setup-php@master + - uses: shivammathur/setup-php@v2 with: php-version: '8.2' + extensions: xdebug - name: install vendors run: composer install --no-interaction - name: install project run: php install.php -# - name: codestyle -# run: ./vendor/bin/phpcs -# - name: static analyze -# run: ./vendor/bin/phpstan + - name: codestyle + run: ./vendor/bin/phpcs + - name: static analyze + run: ./vendor/bin/phpstan - name: tests - run: ./vendor/bin/phpunit + run: php -dxdebug.mode=coverage ./vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore index 7ceb938..c42c078 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ log.txt .phpunit.result.cache var/** !var/.gitkeep +.phpstan +.phpcs-cache diff --git a/bootstrap.php b/bootstrap.php index 3a9da4b..9aaeb29 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -50,7 +50,7 @@ public static function init(): void } /** - * @template T + * @template T of object * * @param class-string $id * @return T @@ -60,6 +60,8 @@ public static function getService(string $id): object if (self::$container === null) { self::init(); } + + // @phpstan-ignore-next-line return self::$container->get($id); } } diff --git a/composer.json b/composer.json index 1793f03..51c35c4 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,10 @@ "autoload": { "psr-4": { "Game\\": "src/" - } + }, + "files": [ + "utils.php" + ] }, "autoload-dev": { "psr-4": { @@ -22,6 +25,8 @@ "symfony/http-foundation": "^6.3" }, "require-dev": { - "phpunit/phpunit": "^10.3" + "phpunit/phpunit": "^10.3", + "phpstan/phpstan": "^1.10", + "free2er/coding-standard": "^1.1" } } diff --git a/composer.lock b/composer.lock index 74293a4..04f47f8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4a98a9cc6a310ab4fe341cc384bc4591", + "content-hash": "afe60457861efaec673655cfdfb6cfef", "packages": [ { "name": "doctrine/cache", @@ -1596,6 +1596,41 @@ } ], "packages-dev": [ + { + "name": "free2er/coding-standard", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/free2er/coding-standard.git", + "reference": "6534210585a54c2ff42d4b817865cd1b0d23f246" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/free2er/coding-standard/zipball/6534210585a54c2ff42d4b817865cd1b0d23f246", + "reference": "6534210585a54c2ff42d4b817865cd1b0d23f246", + "shasum": "" + }, + "require": { + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Max Pryakhin", + "email": "m.pryakhin@gmail.com" + } + ], + "description": "PHP CodeSniffer Coding Standard", + "support": { + "issues": "https://github.com/free2er/coding-standard/issues", + "source": "https://github.com/free2er/coding-standard/tree/1.1.1" + }, + "time": "2019-12-02T09:44:29+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.11.1", @@ -1822,6 +1857,68 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "1.10.32", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c47e47d3ab03137c0e121e77c4d2cb58672f6d44", + "reference": "c47e47d3ab03137c0e121e77c4d2cb58672f6d44", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2023-08-24T21:54:50+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.3", @@ -3154,6 +3251,63 @@ ], "time": "2023-02-07T11:34:05+00:00" }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.7.2", + "source": { + "type": "git", + "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "bin": [ + "bin/phpcs", + "bin/phpcbf" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "lead" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", + "source": "https://github.com/squizlabs/PHP_CodeSniffer", + "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + }, + "time": "2023-02-22T23:07:41+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.1", diff --git a/data/activity_option.yaml b/data/activity_option.yaml new file mode 100644 index 0000000..8ff808a --- /dev/null +++ b/data/activity_option.yaml @@ -0,0 +1,27 @@ +Lumberjack: + - id: 1 + name: 'Hollow tree' + description: 'Hollow tree. Can be sold as fuel for a coin or two.' + complexity: 1 + reward_exp: 5 + reward_item_id: 6 + + - id: 2 + name: 'Oak tree' + description: 'Strong tree. They say hogs love hanging around it.' + complexity: 3 + reward_exp: 8 + reward_item_id: 7 +Farmer: + - id: 1 + name: 'Hay field' + description: 'Dried grass that is usually used to feed cows.' + complexity: 1 + reward_exp: 5 + reward_item_id: 8 + - id: 2 + name: 'Potato field' + description: 'Grows underground. Does not need much and yields plenty of resource.' + complexity: 3 + reward_exp: 8 + reward_item_id: 9 diff --git a/data/item.yaml b/data/item.yaml index 2538f6c..5f327bd 100644 --- a/data/item.yaml +++ b/data/item.yaml @@ -2,19 +2,44 @@ id: 1 name: 'Gold Coins' worth: 1 + type: 'currency' - id: 2 name: Cheese worth: 2 + type: 'consumable' - id: 3 name: 'Short Sword' worth: 18 + type: 'weapon' - id: 4 name: 'Animal hide' worth: 2 + type: 'material' - id: 5 name: Leather worth: 5 + type: 'material' +- + id: 6 + name: 'Firewood bundle' + worth: 1 + type: 'material' +- + id: 7 + name: Oak plank + worth: 2 + type: 'material' +- + id: 8 + name: 'Hay bale' + worth: 1 + type: 'material' +- + id: 9 + name: 'Potato' + worth: 2 + type: 'consumable' diff --git a/data/item_effect.yaml b/data/item_effect.yaml index a9d11f2..90e43df 100644 --- a/data/item_effect.yaml +++ b/data/item_effect.yaml @@ -3,3 +3,8 @@ name: 'Restore stamina' type: 1 power: 5 +- + item_id: 9 + name: 'Restore stamina' + type: 1 + power: 3 diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..368f366 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + Line is too long. This might be difficult to read. + 3 + + + Double check. This rule has to be re-enabled once phpcs fix the php8.2 readonly issue. + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + 0 + +Squiz.Commenting.FunctionComment.MissingParamTag + src/ + tests/ + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..95276e4 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +parameters: + level: 9 + tmpDir: '%currentWorkingDirectory%/.phpstan' + paths: + - src + - bootstrap.php + excludePaths: + - src/Engine/DBConnection.php + fileExtensions: + - php + parallel: + processTimeout: 60.0 + maximumNumberOfProcesses: 4 + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d828797..3ffec3b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,7 +20,6 @@ tests - src diff --git a/src/API/HttpApi.php b/src/API/HttpApi.php index 6466be4..ac59618 100644 --- a/src/API/HttpApi.php +++ b/src/API/HttpApi.php @@ -1,4 +1,5 @@ getLastMessages(10); + $messages = \DI::getService(Chat::class)->getLastMessages(10); $messagesData = []; foreach ($messages as $message) { $messagesData[] = [ 'isFromAdmin' => $message->isFromAdmin, - 'sender' => $message->sender, - 'message' => $message->content, - 'sentAt' => $message->sentAt->format(DATE_ATOM), + 'sender' => $message->sender, + 'message' => $message->content, + 'sentAt' => $message->sentAt->format(DATE_ATOM), ]; } @@ -101,7 +101,7 @@ private function banPlayer(Request $request): Response private function buyItem(Request $request): Response { - $itemId = $request->request->getInt('itemId'); + $itemId = $request->request->getInt('itemId'); $fromShop = $request->request->getString('shop'); $shop = \DI::getService(ShopRepository::class)->findShopByName($fromShop); @@ -124,7 +124,7 @@ private function buyItem(Request $request): Response private function sellItem(Request $request): Response { - $itemId = $request->request->getInt('itemId'); + $itemId = $request->request->getInt('itemId'); $quantity = 1; $sellingItem = new Item($itemId, $quantity); @@ -161,17 +161,21 @@ private function useItem(Request $request): Response private function viewLogs(Request $request): Response { - $logs = iterator_to_array($this->player->getLogs(5)); + $logs = iterable_to_array($this->player->getLogs(5)); return $this->success(['logs' => $logs]); } + /** + * @param array $data + * @return Response + */ private function success(array $data = []): Response { return new JsonResponse([ 'success' => true, 'message' => '', - 'data' => $data, + 'data' => $data, ]); } @@ -180,7 +184,7 @@ private function failure(string $message): Response return new JsonResponse([ 'success' => false, 'message' => $message, - 'data' => [], + 'data' => [], ]); } } diff --git a/src/Auth/AuthService.php b/src/Auth/AuthService.php index ba753f1..9be0061 100644 --- a/src/Auth/AuthService.php +++ b/src/Auth/AuthService.php @@ -1,4 +1,5 @@ db->execute("UPDATE users set banned = 1 WHERE anv = ?", [$name]); + $this->db->execute('UPDATE users set banned = 1 WHERE anv = ?', [$name]); } public function register(string $playerName, string $password, ?string $ip = ''): null|Error @@ -58,7 +61,7 @@ public function login(string $playerName, string $password): null|Error return new Error('User is banned'); } - $this->currentUser = null; + $this->currentUser = null; $_SESSION['username'] = $user['anv']; return null; diff --git a/src/Chat/Chat.php b/src/Chat/Chat.php index 4a3bf17..a8fc53c 100644 --- a/src/Chat/Chat.php +++ b/src/Chat/Chat.php @@ -1,4 +1,5 @@ db->execute("INSERT INTO chat (username, messages) VALUES (?, ?)", [$player->getName(), $message]); + $this->db->execute('INSERT INTO chat (username, messages) VALUES (?, ?)', [$player->getName(), $message]); } public function addSystemMessage(string $message): void @@ -22,13 +25,13 @@ public function addSystemMessage(string $message): void } /** - * @param int $amount + * @param int $amount * @return iterable */ public function getLastMessages(int $amount): iterable { // TODO Need to filter out message from banned users? - $result = $this->db->fetchRows("SELECT * FROM chat ORDER BY id DESC LIMIT " . $amount); + $result = $this->db->fetchRows('SELECT * FROM chat ORDER BY id DESC LIMIT ' . $amount); foreach ($result as $message) { yield new ChatMessage( $message['username'], diff --git a/src/Chat/ChatMessage.php b/src/Chat/ChatMessage.php index 2960407..95127f8 100644 --- a/src/Chat/ChatMessage.php +++ b/src/Chat/ChatMessage.php @@ -1,4 +1,5 @@ sentAt = (new \DateTimeImmutable())->setTimestamp($timestamp); } -} \ No newline at end of file +} diff --git a/src/Client.php b/src/Client.php index c59f277..b98750e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,4 +1,5 @@ Scene\Auth::class, + self::SCENE_CHARACTER_CREATION => Scene\CharacterCreation::class, + self::SCENE_MAIN => Scene\MainMenu::class, + self::SCENE_DUNGEONS => Scene\Dungeons::class, + self::SCENE_ACTIVITY => Scene\Activity::class, + self::SCENE_LIBRARY => Scene\Library::class, + self::SCENE_HIGH_SCORE => Scene\Highscore::class, + self::SCENE_INVENTORY => Scene\Inventory::class, + self::SCENE_SHOPS => Scene\Shops::class, + self::SCENE_SHOP => Scene\Shop::class, + ]; + public function __construct( private AuthService $authService, private CharacterRepository $characterRepository - ){} + ) { + } public function run(): void { - // TODO add self-sufficient front controller handling + $userInput = new HttpInput(); + if (!$this->isSignedIn()) { + $scene = $this->getScene(self::SCENE_AUTH); + } elseif ($this->getCurrentPlayer() === null) { + $scene = $this->getScene(self::SCENE_CHARACTER_CREATION); + } elseif (isset($_GET['scene']) && is_string($_GET['scene'])) { + $scene = $this->getScene($_GET['scene']); + } else { + $scene = $this->getScene(self::SCENE_MAIN); + } + + echo $scene->run($userInput); } public function getCurrentPlayer(): ?Player @@ -29,17 +70,15 @@ public function getCurrentPlayer(): ?Player return $this->characterRepository->findByUser($currentUser); } - /** - * Basically pretends to say that client is not running unless user is signed in - * - * @todo requires rethinking - * - * @return bool - */ - public function isRunning(): bool + private function isSignedIn(): bool { $currentUser = $this->authService->getCurrentUser(); return $currentUser !== null; } + + private function getScene(string $scene): Scene\SceneInterface + { + return \DI::getService(self::CONTROLLERS[$scene]); + } } diff --git a/src/Dungeon/Drop.php b/src/Dungeon/Drop.php deleted file mode 100644 index 24fddc0..0000000 --- a/src/Dungeon/Drop.php +++ /dev/null @@ -1,14 +0,0 @@ -getData(); + /** @var RawData $dropDetails */ foreach ($data as $dropDetails) { if ($dropDetails['monster_id'] == $monster->id) { yield new DropChance( - Chance::percentage((float)$dropDetails['chance']), + Chance::percentage((float) $dropDetails['chance']), $this->itemPrototypeRepository->getById($dropDetails['item_id']), $dropDetails['quantity_min'], $dropDetails['quantity_max'] diff --git a/src/Dungeon/Dungeon.php b/src/Dungeon/Dungeon.php index 9bce5a4..7ceb659 100644 --- a/src/Dungeon/Dungeon.php +++ b/src/Dungeon/Dungeon.php @@ -1,4 +1,5 @@ + */ public function listDungeons(): iterable { + /** @var RawData $dungeon */ foreach ($this->getData() as $dungeon) { yield new Dungeon( $dungeon['id'], diff --git a/src/Dungeon/Monster.php b/src/Dungeon/Monster.php index 8a37eb9..4079b87 100644 --- a/src/Dungeon/Monster.php +++ b/src/Dungeon/Monster.php @@ -1,4 +1,5 @@ getData() as $monster) { yield new Monster( $monster['id'], diff --git a/src/Dungeon/Reward.php b/src/Dungeon/Reward.php deleted file mode 100644 index 8d668cf..0000000 --- a/src/Dungeon/Reward.php +++ /dev/null @@ -1,52 +0,0 @@ -addDrop($entry); - } - } - - public function isEmpty(): bool - { - return $this->exp === 0 && $this->drop === []; - } - - /** - * @return Drop[] - */ - public function listDrop(): array - { - return $this->drop; - } - - private function addDrop(Drop $drop): void - { - $itemId = $drop->item->id; - if (isset($this->drop[$itemId])) { - $existingDrop = $this->drop[$itemId]; - $this->drop[$itemId] = new Drop($existingDrop->item, $existingDrop->quantity + $drop->quantity); - } else { - $this->drop[$itemId] = $drop; - } - } -} diff --git a/src/Dungeon/RewardCalculator.php b/src/Dungeon/RewardCalculator.php index 04cd72e..ddc6e9f 100644 --- a/src/Dungeon/RewardCalculator.php +++ b/src/Dungeon/RewardCalculator.php @@ -1,51 +1,30 @@ ttkCalculator = new TTKCalculator(); } - // TODO SRP is violated here. At the same time encapsulation is not fulfiled. Calculate ttk separately and issue them too. - public function calculate(Dungeon $dungeon, Player $hunter, int $minutesInDungeon): Reward + public function calculateForHuntedMonster(Monster $monster, int $unitsKilled): Reward { - $approximateSpentMinutes = $minutesInDungeon; - if ($approximateSpentMinutes === 0) { - return Reward::none(); - } - - $ttkMonster = $this->ttkCalculator->calculate($hunter, $dungeon->inhabitant); - $ttkPlayer = $this->ttkCalculator->calculateForMonster($dungeon->inhabitant, $hunter); - - $unitsKilled = (int)floor($approximateSpentMinutes / $ttkMonster->toMinutes()); - if ($unitsKilled === 0) { - return Reward::none(); - } - - // If player needs more time to kill monster than monsters needs to kill player, then issue no rewards - if ($ttkMonster->isGreaterThan($ttkPlayer)) { - return Reward::none(); - } - - $expEarned = $unitsKilled * $dungeon->inhabitant->exp; + $expEarned = $unitsKilled * $monster->exp; - $drops = []; - foreach ($this->dropRepository->getMonsterDrop($dungeon->inhabitant) as $dropChance) { + $items = []; + foreach ($this->dropRepository->getMonsterDrop($monster) as $dropChance) { $quantity = $dropChance->roll($unitsKilled); if ($quantity > 0) { - $drops[] = new Drop($dropChance->itemPrototype, $quantity); + $items[] = new Item($dropChance->itemPrototype->id, $quantity); } } - return new Reward($expEarned, $drops); + return new Reward($expEarned, $items); } } diff --git a/src/Dungeon/TTKCalculator.php b/src/Dungeon/TTKCalculator.php index af0516b..ca1ccd4 100644 --- a/src/Dungeon/TTKCalculator.php +++ b/src/Dungeon/TTKCalculator.php @@ -1,4 +1,5 @@ connection = DriverManager::getConnection([ - 'dbname' => $db, - 'driver' => 'pdo_mysql', - 'host' => $host, - 'user' => $user, + 'dbname' => $db, + 'driver' => 'pdo_mysql', + 'host' => $host, + 'user' => $user, 'password' => $pass, ]); } public function fetchRow(string $query, array $params = []): array { - $result = $this->connection->executeQuery($query, $params, $this->detectTypes($params)); + $result = $this->connection->executeQuery($query, $params, $this->detectTypes($params)); return $result->fetchAssociative() ?: []; } diff --git a/src/Engine/DbTimeFactory.php b/src/Engine/DbTimeFactory.php index e01d73b..c3a2a83 100644 --- a/src/Engine/DbTimeFactory.php +++ b/src/Engine/DbTimeFactory.php @@ -1,4 +1,5 @@ format('Y-m-d H:i:s'); } + + public static function fromTimestamp(string $dbTimeStamp): CarbonImmutable + { + $timestamp = strtotime($dbTimeStamp); + if ($timestamp === false) { + throw new \RuntimeException('Could not parse time'); + } + + return CarbonImmutable::createFromTimestamp($timestamp); + } } diff --git a/src/Engine/Error.php b/src/Engine/Error.php index e394051..4e4a7ee 100644 --- a/src/Engine/Error.php +++ b/src/Engine/Error.php @@ -1,9 +1,12 @@ getById($itemId); - $this->prototype = $prototype; - $this->id = $prototype->id; - $this->name = $prototype->name; - $this->quantity = $quantity; - $this->worth = $prototype->worth; - $this->isSellable = $prototype->isSellable(); // todo replace with Type + $this->prototype = \DI::getService(ItemPrototypeRepository::class)->getById($itemId); + $this->id = $this->prototype->id; + $this->name = $this->prototype->name; + $this->quantity = $quantity; + $this->worth = $this->prototype->worth; + $this->isSellable = $this->prototype->isSellable(); } /** @@ -38,7 +38,6 @@ public function listEffects(): iterable public function isConsumable(): bool { - // TODO implement item types - return $this->id === 2; + return $this->prototype->type === ItemType::CONSUMABLE; } } diff --git a/src/Item/ItemPrototype.php b/src/Item/ItemPrototype.php index 8fa3513..4455724 100644 --- a/src/Item/ItemPrototype.php +++ b/src/Item/ItemPrototype.php @@ -1,17 +1,17 @@ id !== 1; + return $this->type !== ItemType::CURRENCY; } } diff --git a/src/Item/ItemPrototypeRepository.php b/src/Item/ItemPrototypeRepository.php index 15538e6..2cdcb85 100644 --- a/src/Item/ItemPrototypeRepository.php +++ b/src/Item/ItemPrototypeRepository.php @@ -1,18 +1,23 @@ + */ private array $cache = []; public function getById(int $id): ItemPrototype { if (!isset($this->cache[$id])) { $prototype = []; + /** @var array{id: int, name: string, worth: int, type: string} $itemData */ foreach ($this->getData() as $itemData) { if ($itemData['id'] === $id) { $prototype = $itemData; @@ -24,7 +29,7 @@ public function getById(int $id): ItemPrototype throw new \RuntimeException('Unknown item'); } - $this->cache[$id] = new ItemPrototype($id, $prototype['name'], (int) $prototype['worth']); + $this->cache[$id] = new ItemPrototype($id, $prototype['name'], ItemType::from($prototype['type']), (int) $prototype['worth']); } return $this->cache[$id]; diff --git a/src/Item/ItemType.php b/src/Item/ItemType.php new file mode 100644 index 0000000..52d8ead --- /dev/null +++ b/src/Item/ItemType.php @@ -0,0 +1,14 @@ +exists($name)) { + throw new \RuntimeException(sprintf('Unknown activity "%s"', $name)); + } + + $optionDetails = \DI::getService(ActivityRepository::class)->findActivityOption($name, $option); + if ($optionDetails === null) { + throw new \RuntimeException(sprintf('Unknown option "%d" for activity "%s"', $option, $name)); + } + + $this->name = $name; + $this->option = $optionDetails; + } + + public static function lumberjack(int $option): self + { + return new self(self::NAME_LUMBERJACK, $option); + } + + public function getName(): string + { + return $this->name; + } + + public function getOption(): int + { + return $this->option->id; + } + + public function getOptionName(): string + { + return $this->option->name; + } + + public function calculateReward(Player $for, TimeInterval $duration): Reward + { + if (!$for->canPerformActivity($this->name)) { + return Reward::none(); + } + + $playerGeneralEfficiency = $for->getActivitySkillLevel($this->name); + + $efficiency = (int) round($playerGeneralEfficiency / $this->option->complexity); + if ($efficiency === 0) { + return Reward::none(); + } + + $rewardPerHour = new Reward($this->option->rewardExp, [new Item($this->option->rewardItemId, $efficiency)]); + + return $rewardPerHour->multiply($duration->toHours()); + } + + public function isSame(ActivityInterface $activity): bool + { + return $activity->getName() === $this->getName() && $activity->getOptionName() === $this->getOptionName(); + } + + private static function exists(string $name): bool + { + return in_array($name, [ + self::NAME_LUMBERJACK, + self::NAME_FARMER, + ]); + } +} diff --git a/src/Player/Activity/ActivityInterface.php b/src/Player/Activity/ActivityInterface.php new file mode 100644 index 0000000..089abf8 --- /dev/null +++ b/src/Player/Activity/ActivityInterface.php @@ -0,0 +1,33 @@ +getData()[$activityName] ?? []); + + $entries = []; + /** @var array{id: int, name: string, description:string, complexity: int, reward_exp: int, reward_item_id: int} $entry */ + foreach ($raw as $entry) { + $entries[] = new OptionDetails( + $entry['id'], + $entry['name'], + $entry['description'], + $entry['complexity'], + $entry['reward_exp'], + $entry['reward_item_id'] + ); + } + + return $entries; + } + + public function findActivity(string $activity, int $optionId): ?Activity + { + $option = $this->findActivityOption($activity, $optionId); + if ($option === null) { + return null; + } + + return new Activity($activity, $optionId); + } + + public function findActivityOption(string $activity, int $optionId): ?OptionDetails + { + foreach ($this->getActivityOptions($activity) as $option) { + if ($option->id === $optionId) { + return $option; + } + } + + return null; + } + + protected function getDataName(): string + { + return 'activity_option'; + } +} diff --git a/src/Player/Activity/OptionDetails.php b/src/Player/Activity/OptionDetails.php new file mode 100644 index 0000000..944aa60 --- /dev/null +++ b/src/Player/Activity/OptionDetails.php @@ -0,0 +1,18 @@ +activity = new Activity($name, $option); + } + + public function getName(): string + { + return $this->activity->getName(); + } + + public function getOption(): int + { + return $this->activity->getOption(); + } + + public function getOptionName(): string + { + return $this->activity->getOptionName(); + } + + public function calculateReward(Player $for, TimeInterval $duration): Reward + { + return $this->activity->calculateReward($for, $duration); + } + + public function isSame(ActivityInterface $activity): bool + { + return $this->activity->isSame($activity); + } +} diff --git a/src/Player/CharacterRepository.php b/src/Player/CharacterRepository.php index e2d9b30..1d44c4a 100644 --- a/src/Player/CharacterRepository.php +++ b/src/Player/CharacterRepository.php @@ -1,4 +1,5 @@ db); } + /** + * @param int $amount + * @return iterable + */ public function listTopCharacters(int $amount): iterable { $topCharacters = $this->db->fetchRows('SELECT id FROM players ORDER BY level DESC LIMIT ' . $amount); diff --git a/src/Player/CreateCharacter.php b/src/Player/CreateCharacter.php index 1578750..68ba2e1 100644 --- a/src/Player/CreateCharacter.php +++ b/src/Player/CreateCharacter.php @@ -1,11 +1,12 @@ db->execute( - "INSERT INTO players(user_id, race, name, health, health_max, strength, defence) VALUE (?, ?, ?, ?, ?, ?, ?)", + 'INSERT INTO players( + user_id, + race, + name, + health, + health_max, + strength, + defence, + woodcutting, + harvesting, + mining, + blacksmith, + gathering, + alchemy + ) VALUE (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $forUser->id, $race->id, @@ -28,15 +43,21 @@ public function execute(string $characterName, Race $race, User $forUser): Playe $race->stats->maxHealth, $race->stats->maxHealth, $race->stats->strength, - $race->stats->defence + $race->stats->defence, + $race->perks->canWoodcut ? 1 : 0, + $race->perks->canHarvest ? 1 : 0, + $race->perks->canMine ? 1 : 0, + $race->perks->canCraft ? 1 : 0, + $race->perks->canGather ? 1 : 0, + $race->perks->canBrew ? 1 : 0, ] ); - $gold = $this->itemPrototypeRepository->getById(1); + $goldId = 1; $character = $this->characterRepository->getByUser($forUser); - $character->obtainItem($gold, 10); + $character->obtainItem(new Item($goldId, 10)); $this->chat->addSystemMessage(sprintf('New character %s appears in the world', $characterName)); return $character; diff --git a/src/Player/LvlCalculator.php b/src/Player/LvlCalculator.php index b768ce5..4ff34ac 100644 --- a/src/Player/LvlCalculator.php +++ b/src/Player/LvlCalculator.php @@ -1,4 +1,5 @@ getProperty('in_combat'); + return $this->isInState(self::STATE_IN_COMBAT); } public function isInProtectiveZone(): bool { - return !$this->isFighting(); + return $this->isInState(self::STATE_IDLE); } public function isInDungeon(Dungeon $dungeon): bool @@ -58,7 +53,7 @@ public function isInDungeon(Dungeon $dungeon): bool public function getHuntingDungeonId(): ?int { - $hunt = $this->connection->fetchRow('SELECT dungeon_id FROM hunting WHERE character_id = ?',[$this->id]); + $hunt = $this->connection->fetchRow('SELECT dungeon_id FROM hunting WHERE character_id = ?', [$this->id]); if ($hunt === []) { return null; } @@ -77,20 +72,25 @@ public function enterDungeon(Dungeon $dungeon): null|Error return new Error('You are already hunting in a dungeon'); } - $this->connection->execute('INSERT INTO hunting (character_id, dungeon_id) VALUES (?, ?)', [$this->id, $dungeon->id]); - $this->connection->execute('UPDATE players SET in_combat = 1 WHERE id = ?', [$this->id]); + if (!$this->isInState(self::STATE_IDLE)) { + return new Error('Can not go to the dungeon if not idle'); + } + + $now = DbTimeFactory::createCurrentTimestamp(); + $this->connection->execute('INSERT INTO hunting (character_id, dungeon_id, checked_at, last_reward_at) VALUES (?, ?, ?, ?)', [$this->id, $dungeon->id, $now, $now]); + $this->moveToState(self::STATE_IN_COMBAT); return null; } public function measureDifficulty(Dungeon $dungeon): string { - $ttkCalculator = new TTKCalculator(); - $ttkMonster = $ttkCalculator->calculate($this, $dungeon->inhabitant)->seconds; - $ttkPlayer = $ttkCalculator->calculateForMonster($dungeon->inhabitant, $this)->seconds; + $ttkCalculator = new TTKCalculator(); + $ttkMonster = $ttkCalculator->calculate($this, $dungeon->inhabitant)->seconds; + $ttkPlayer = $ttkCalculator->calculateForMonster($dungeon->inhabitant, $this)->seconds; $difficultyRatio = $ttkPlayer / $ttkMonster; - switch(true) { + switch (true) { case $difficultyRatio > 50: return 'easy(>50 mobs/h)'; case $difficultyRatio > 20: @@ -104,8 +104,12 @@ public function measureDifficulty(Dungeon $dungeon): string public function leaveDungeon(): void { + if (!$this->isInState(self::STATE_IN_COMBAT)) { + return; + } + $this->connection->execute('DELETE from hunting WHERE character_id = ?', [$this->id]); - $this->connection->execute('UPDATE players SET in_combat = 0 WHERE id = ?', [$this->id]); + $this->moveToState(self::STATE_IDLE); } public function getId(): int @@ -142,13 +146,81 @@ public function addExp(int $amount): void // Update max hp $amountToAdd = 15; - $maxHealth = $level * $amountToAdd; + $maxHealth = $level * $amountToAdd; $db->execute("UPDATE players SET health_max = {$maxHealth} WHERE id = {$this->id}"); }); $this->logger->add($this->id, "You gained $amount experience points."); } + public function canPerformActivity(string $activity): bool + { + return $this->getActivitySkillLevel($activity) > 0; + } + + public function getActivitySkillLevel(string $activity): int + { + switch ($activity) { + case Activity::NAME_LUMBERJACK: + return $this->getWoodcutting(); + case Activity::NAME_FARMER: + return $this->getHarvesting(); + case Activity::NAME_MINER: + return $this->getMining(); + case Activity::NAME_GATHERER: + return $this->getGathering(); + case Activity::NAME_CRAFTER: + return $this->getBlacksmith(); + case Activity::NAME_ALCHEMIST: + return $this->getAlchemy(); + default: + return 0; + } + } + + public function startActivity(ActivityInterface $activity): ?Error + { + if (!$this->isInState(self::STATE_IDLE)) { + return new Error('Can not go for activities when not in protective zone'); + } + + $now = DbTimeFactory::createCurrentTimestamp(); + + $this->connection->execute(' + INSERT INTO activity(character_id, name, selected_option, started_at, checked_at, last_reward_at) + VALUE (?, ?, ?, ?, ?, ?) + ', [$this->id, $activity->getName(), $activity->getOption(), $now, $now, $now]); + + $this->moveToState(self::STATE_PERFORMING_ACTIVITY); + + return null; + } + + public function getCurrentActivity(): ?CharacterActivity + { + $data = $this->connection->fetchRow('SELECT name, selected_option, checked_at, last_reward_at FROM activity WHERE character_id=' . $this->id); + if ($data === []) { + return null; + } + + return new CharacterActivity( + $data['name'], + $data['selected_option'], + DbTimeFactory::fromTimestamp($data['checked_at']), + DbTimeFactory::fromTimestamp($data['last_reward_at']) + ); + } + + public function stopActivity(): void + { + if (!$this->isInState(self::STATE_PERFORMING_ACTIVITY)) { + return; + } + + $this->connection->execute('DELETE FROM activity WHERE character_id=' . $this->id); + $this->moveToState(self::STATE_IDLE); + } + public function getLevel(): int { return (int) $this->getProperty('level'); @@ -220,9 +292,9 @@ public function getHarvesting(): int return (int) $this->getProperty('harvesting'); } - public function getHerbalism(): int + public function getAlchemy(): int { - return (int) $this->getProperty('herbalism'); + return (int) $this->getProperty('alchemy'); } public function getBlacksmith(): int @@ -238,24 +310,25 @@ public function getLogs(int $amount): iterable return $this->logger->readLogs($this->id, $amount); } - public function pickUp(Drop $drop): void + public function pickUp(Item $item): void { - $this->obtainItem($drop->item, $drop->quantity); - $this->logger->add($this->id, sprintf("You picked up %d %s", $drop->quantity, $drop->item->name)); + $this->obtainItem($item); + + $this->logger->add($this->id, sprintf('You picked up %d %s', $item->quantity, $item->name)); } - public function obtainItem(ItemPrototype $item, int $quantity): void + public function obtainItem(Item $item): void { if ($this->getItemQuantity($item->id) === 0) { $this->connection - ->execute('INSERT INTO inventory (character_id, item_id, amount, worth) VALUES (?, ?, ?, ?)', [$this->id, $item->id, $quantity, $item->worth]); + ->execute('INSERT INTO inventory (character_id, item_id, amount, worth) VALUES (?, ?, ?, ?)', [$this->id, $item->id, $item->quantity, $item->worth]); } else { $this->connection - ->execute('UPDATE inventory SET amount = amount + ? WHERE item_id = ? AND character_id = ?', [$quantity, $item->id, $this->id]); + ->execute('UPDATE inventory SET amount = amount + ? WHERE item_id = ? AND character_id = ?', [$item->quantity, $item->id, $this->id]); } } - public function dropItem(ItemPrototype $item, int $quantity): Drop + public function dropItem(ItemPrototype $item, int $quantity): Item { $this->connection->transaction(function () use ($item, $quantity) { $this->connection->execute('UPDATE inventory SET amount = amount - ? WHERE item_id = ? AND character_id = ?', [$quantity, $item->id, $this->id]); @@ -264,13 +337,10 @@ public function dropItem(ItemPrototype $item, int $quantity): Drop if ($remainingItemsQuantity < 0) { throw new \RuntimeException('Player does not have that many items'); } - - if ($remainingItemsQuantity === 0) { - $this->destroyItem($item); - } + $this->removeNonExistentItems(); }); - return new Drop($item, $quantity); + return new Item($item->id, $quantity); } public function destroyItem(ItemPrototype $item, int $quantity = null): void @@ -352,7 +422,7 @@ public function acceptOffer(Offer $offer): ?Error $this->connection->transaction(function () use ($offer) { // TODO drop returns actually dropped item which means that it can be used for actual trade player<=>seller $this->dropItem($offer->inExchange->prototype, $offer->inExchange->quantity); - $this->obtainItem($offer->item->prototype, $offer->item->quantity); + $this->obtainItem($offer->item); }); return null; @@ -368,6 +438,23 @@ private function getProperty(string $property): string|int|float|null return $result[$property]; } + private function isInState(int $state): bool + { + return (int) $this->getProperty('state') === $state; + } + + private function moveToState(int $state): void + { + // Dummy state-machine. Check transitions before applying new state + if ($state === self::STATE_IN_COMBAT || $state === self::STATE_PERFORMING_ACTIVITY) { + if (!$this->isInState(self::STATE_IDLE)) { + throw new \DomainException('Impossible transition'); + } + } + + $this->connection->execute('UPDATE players SET state = ? WHERE id = ?', [$state, $this->id]); + } + private function getItemQuantity(int $itemId): int { $result = $this->connection->fetchRow("SELECT amount FROM inventory WHERE item_id = $itemId AND character_id = ?", [$this->id]); diff --git a/src/Player/PlayerLog.php b/src/Player/PlayerLog.php index bf00ce0..59816c6 100644 --- a/src/Player/PlayerLog.php +++ b/src/Player/PlayerLog.php @@ -1,4 +1,5 @@ db->execute("INSERT INTO log (character_id, message) VALUES (?, ?)", [$characterId, $log]); + $this->db->execute('INSERT INTO log (character_id, message) VALUES (?, ?)', [$characterId, $log]); } /** @@ -20,7 +23,7 @@ public function add(int $characterId, string $log): void public function readLogs(int $characterId, int $amount): iterable { $logs = $this->db - ->fetchRows("SELECT message FROM log WHERE id=? ORDER BY tid DESC LIMIT ?", [$characterId, $amount]); + ->fetchRows('SELECT message FROM log WHERE character_id=? ORDER BY tid DESC LIMIT ?', [$characterId, $amount]); foreach ($logs as $log) { yield $log['message']; diff --git a/src/Player/Race.php b/src/Player/Race.php index 640b222..313be8b 100644 --- a/src/Player/Race.php +++ b/src/Player/Race.php @@ -1,4 +1,5 @@ + */ public function listAll(): iterable { + /** @var RawData $race */ foreach ($this->getData() as $race) { yield new Race( $race['id'], @@ -31,6 +50,11 @@ public function listAll(): iterable } } + /** + * @param int $raceId + * + * @return Race + */ public function getById(int $raceId): Race { foreach ($this->listAll() as $race) { diff --git a/src/Player/Reward.php b/src/Player/Reward.php new file mode 100644 index 0000000..7388055 --- /dev/null +++ b/src/Player/Reward.php @@ -0,0 +1,79 @@ +items = $this->groupItems($items); + } + + public function isEmpty(): bool + { + return $this->exp === 0 && $this->items === []; + } + + /** + * @param Item[] $items + * + * @return Item[] + */ + private function groupItems(array $items): array + { + /** @var Item[] $groupedItems */ + $groupedItems = []; + foreach ($items as $item) { + $itemId = $item->id; + if (isset($groupedItems[$itemId])) { + $existingItem = $groupedItems[$itemId]; + $groupedItems[$itemId] = new Item($existingItem->id, $existingItem->quantity + $item->quantity); + } else { + $groupedItems[$itemId] = $item; + } + } + + return array_values($groupedItems); + } + + public function multiply(float $modifier): self + { + if ($modifier < 0) { + throw new \RuntimeException('Negative modifier is not allowed'); + } + + if ($modifier == 0) { + return self::none(); + } + + $newExp = (int) round($this->exp * $modifier); + $newItems = []; + foreach ($this->items as $item) { + $newAmount = (int) round($item->quantity * $modifier); + if ($newAmount === 0) { + continue; + } + $newItems[] = new Item($item->id, $newAmount); + } + + return new self($newExp, $newItems); + } +} diff --git a/src/Player/Stats.php b/src/Player/Stats.php index b2139d8..e9125a6 100644 --- a/src/Player/Stats.php +++ b/src/Player/Stats.php @@ -1,4 +1,5 @@ currentTime = CarbonImmutable::now(); - $this->logs = []; - $this->logs[] = sprintf('%s', $this->currentTime->format("H:i:s d-m-Y")); + $this->logs = []; + $this->logs[] = sprintf('%s', $this->currentTime->format('H:i:s d-m-Y')); $this->db->transaction(function () { $this->regenerateStamina(); $this->giveRewards(); - $this->stopExhaustedHunters(); + $this->stopExhaustedCharacters(); }); - $logs = $this->logs; + $logs = $this->logs; $this->logs = []; return $logs; } - // Regenerates resting hunters stamina + /** + * Regenerates resting hunters stamina + */ private function regenerateStamina(): void { // Basically means timestamp when last action was applied. Implied that action is taken only by Engine $row = $this->db->fetchRow("SELECT tid FROM timetable WHERE name = 'stamina'"); - $lastUpdateAt = CarbonImmutable::create($row['tid']); + $lastUpdateAt = DbTimeFactory::fromTimestamp($row['tid']); $minutesPassed = $this->currentTime->diffInMinutes($lastUpdateAt); if ($minutesPassed === 0) { return; } - $this->logs[] = sprintf('%d', $minutesPassed); + $this->logs[] = sprintf('%d', min($minutesPassed, Player::MAX_POSSIBLE_STAMINA)); + $this->db->execute( - 'UPDATE players SET stamina = LEAST(stamina + ?, ?) WHERE in_combat = 0 AND stamina < ?', - [$minutesPassed, Player::MAX_POSSIBLE_STAMINA, Player::MAX_POSSIBLE_STAMINA] + 'UPDATE players SET stamina = LEAST(stamina + ?, ?) WHERE state = ? AND stamina < ?', + [ + $minutesPassed, + Player::MAX_POSSIBLE_STAMINA, + Player::STATE_IDLE, + Player::MAX_POSSIBLE_STAMINA, + ] ); $this->db->execute("UPDATE timetable SET tid = NOW() WHERE name='stamina'"); } - private function stopExhaustedHunters(): void + private function stopExhaustedCharacters(): void { - $huntingPlayers = $this->db->fetchRows("SELECT id, name, in_combat, stamina FROM players WHERE stamina <= 0 AND in_combat = 1"); - foreach ($huntingPlayers as $row) { + $labourers = $this->db->fetchRows('SELECT id, name FROM players WHERE stamina <= 0 AND state = ' . Player::STATE_PERFORMING_ACTIVITY); + foreach ($labourers as $row) { + $playerNameWithNoStamina = $row['name']; + + $this->db->execute('DELETE from activity WHERE character_id = ?', [$row['id']]); + $this->logs[] = sprintf('', $playerNameWithNoStamina); + } + + $hunters = $this->db->fetchRows('SELECT id, name, stamina FROM players WHERE stamina <= 0 AND state = ' . Player::STATE_IN_COMBAT); + foreach ($hunters as $row) { $playerNameWithNoStamina = $row['name']; $this->db->execute('DELETE from hunting WHERE character_id = ?', [$row['id']]); - $this->db->execute('UPDATE players SET in_combat = 0, stamina=0 WHERE id = ?', [$row['id']]); $this->logs[] = sprintf('', $playerNameWithNoStamina); } + + $this->db->execute('UPDATE players SET state = ?, stamina = 0 WHERE stamina <= 0 AND state IN (?, ?)', [Player::STATE_IDLE, Player::STATE_IN_COMBAT, Player::STATE_PERFORMING_ACTIVITY]); } private function giveRewards(): void { $logTemplate = << + %d %s %d %s %s @@ -91,44 +118,50 @@ private function giveRewards(): void XML; $rewardLogs[] = ''; - $now = CarbonImmutable::now(); foreach ($this->getHunters() as $row) { - $hunter = $this->characterRepository->getById($row['character_id']); + $hunter = $this->characterRepository->getById($row['character_id']); $huntingZone = $this->dungeonRepository->getById($row['dungeon_id']); - $lastCheckedAt = CarbonImmutable::create($row['checked_at']); - $lastRewardedAt = CarbonImmutable::create($row['last_reward_at']); + $lastCheckedAt = DbTimeFactory::fromTimestamp($row['checked_at']); + $lastRewardedAt = DbTimeFactory::fromTimestamp($row['last_reward_at']); - $minutesSinceLastCheck = $lastCheckedAt->diffInMinutes($now, false); + $minutesSinceLastCheck = $lastCheckedAt->diffInMinutes($this->currentTime, false); if ($minutesSinceLastCheck < 1) { continue; } - $minutesSinceLastReward = $lastRewardedAt->diffInMinutes($now, false); - $remainingStamina = $hunter->getStamina(); + $minutesSinceLastReward = $lastRewardedAt->diffInMinutes($this->currentTime, false); + $remainingStamina = $hunter->getStamina(); // If player spent in dungeon more time than stamina he has, decrease spent time according that amount if ($remainingStamina < $minutesSinceLastCheck) { - $minutesSinceLastCheck = $remainingStamina; + $minutesSinceLastCheck = $remainingStamina; $minutesSinceLastReward = $lastRewardedAt->diffInMinutes($lastCheckedAt, false) + $remainingStamina; } $this->db->execute('UPDATE players SET stamina = GREATEST(stamina - ?, 0) WHERE id = ?', [$minutesSinceLastCheck, $hunter->getId()]); - $reward = $this->rewardCalculator->calculate($huntingZone, $hunter, $minutesSinceLastReward); - if ($reward->isEmpty()) { - $this->db->execute('UPDATE hunting SET checked_at = ? WHERE character_id = ?', [DbTimeFactory::createTimestamp($now), $hunter->getId()]); + [ + 'unitsKilled' => $unitsKilled, + 'withinTime' => $timeSpent, + ] = $this->calculateKillCount($hunter, $huntingZone->inhabitant, TimeInterval::fromMinutes($minutesSinceLastReward)); + + if ($unitsKilled < 1) { + $this->db->execute('UPDATE hunting SET checked_at = ? WHERE character_id = ?', [DbTimeFactory::createTimestamp($this->currentTime), $hunter->getId()]); continue; } - $this->db->execute('UPDATE hunting SET checked_at = ?, last_reward_at = ? WHERE character_id = ?', [DbTimeFactory::createTimestamp($now), DbTimeFactory::createTimestamp($now), $hunter->getId()]); + $reward = $this->rewardCalculator->calculateForHuntedMonster($huntingZone->inhabitant, $unitsKilled); - $hunter->addExp($reward->exp); + $rewardedAt = $lastRewardedAt->addSeconds($timeSpent->seconds); + $this->db->execute('UPDATE hunting SET checked_at = ?, last_reward_at = ? WHERE character_id = ?', [DbTimeFactory::createTimestamp($this->currentTime), DbTimeFactory::createTimestamp($rewardedAt), $hunter->getId()]); + + $hunter->addExp($reward->exp); $lootDetails = ''; - foreach ($reward->listDrop() as $drop) { - $hunter->pickUp($drop); - $lootDetails .= sprintf('', $drop->item->name, $drop->quantity); + foreach ($reward->items as $item) { + $hunter->pickUp($item); + $lootDetails .= sprintf('', $item->name, $item->quantity); } $rewardLogs[] = sprintf( @@ -136,6 +169,8 @@ private function giveRewards(): void $hunter->getName(), $huntingZone->name, $minutesSinceLastReward . 'minutes', + $unitsKilled, + $huntingZone->inhabitant->name, $reward->exp, $lootDetails, 'Stamina reduced by ' . $minutesSinceLastCheck @@ -149,19 +184,99 @@ private function giveRewards(): void $this->logs[] = $rewardLog; } } + + $this->giveActivityRewards(); + } + + private function giveActivityRewards(): void + { + $lastCheckedAt = $this->currentTime->subMinute(); + + $activities = $this->db->fetchRows('SELECT character_id, last_reward_at, checked_at FROM activity WHERE checked_at < ?', [DbTimeFactory::createTimestamp($lastCheckedAt)]); + + foreach ($activities as $data) { + $character = $this->characterRepository->getById($data['character_id']); + $activity = $character->getCurrentActivity(); + if ($activity === null) { + $this->logs[] = sprintf('Character %s expected to have activity but it is missing', $character->getName()); + continue; + } + + $lastCheckedAt = $activity->checkedAt; + $lastRewardedAt = $activity->rewardedAt; + + $minutesSinceLastCheck = $lastCheckedAt->diffInMinutes($this->currentTime, false); + $minutesSinceLastReward = $lastRewardedAt->diffInMinutes($this->currentTime, false); + $remainingStamina = $character->getStamina(); + // If player spent in dungeon more time than stamina he has, decrease spent time according that amount + if ($remainingStamina < $minutesSinceLastCheck) { + $minutesSinceLastCheck = $remainingStamina; + $minutesSinceLastReward = $lastRewardedAt->diffInMinutes($lastCheckedAt, false) + $remainingStamina; + } + + $this->db->execute('UPDATE players SET stamina = GREATEST(stamina - ?, 0) WHERE id = ?', [$minutesSinceLastCheck, $character->getId()]); + $this->db->execute('UPDATE activity SET checked_at=? WHERE character_id=?', [DbTimeFactory::createTimestamp($this->currentTime), $character->getId()]); + + if ($minutesSinceLastReward < 60) { + continue; + } + + $fullHoursPassed = (int) ($minutesSinceLastReward / 60); + + $reward = $activity->calculateReward($character, TimeInterval::fromHours($fullHoursPassed)); + $character->addExp($reward->exp); + foreach ($reward->items as $item) { + $character->pickUp($item); + } + + $lastRewardedAt = $lastRewardedAt->addHours($fullHoursPassed); + $this->db->execute('UPDATE activity SET last_reward_at=? WHERE character_id=?', [DbTimeFactory::createTimestamp($lastRewardedAt), $character->getId()]); + } } /** - * @return iterable + * @return iterable */ private function getHunters(): iterable { $lastCheckedAt = $this->currentTime->subMinute(); - // TODO change username to id return $this->db->fetchRows( 'SELECT character_id, dungeon_id, last_reward_at, checked_at FROM hunting WHERE checked_at < ?', [DbTimeFactory::createTimestamp($lastCheckedAt)] ); } + + /** + * @param Player $hunter + * @param Monster $monster + * + * @return array{unitsKilled: int, withinTime: TimeInterval} + */ + private function calculateKillCount(Player $hunter, Monster $monster, TimeInterval $withinTime): array + { + $ttkMonster = $this->ttkCalculator->calculate($hunter, $monster); + $ttkPlayer = $this->ttkCalculator->calculateForMonster($monster, $hunter); + + // If player needs more time to kill monster than monsters needs to kill player + if ($ttkMonster->isGreaterThan($ttkPlayer)) { + return [ + 'unitsKilled' => 0, + 'withinTime' => $withinTime, + ]; + } + + $unitsKilled = (int) floor($withinTime->toMinutes() / $ttkMonster->toMinutes()); + if ($unitsKilled === 0) { + return [ + 'unitsKilled' => 0, + 'withinTime' => $withinTime, + ]; + } + + return [ + 'unitsKilled' => $unitsKilled, + 'withinTime' => new TimeInterval($ttkMonster->seconds * $unitsKilled), + ]; + } } diff --git a/src/Skill/Effect.php b/src/Skill/Effect.php index a28c83c..cae5e0c 100644 --- a/src/Skill/Effect.php +++ b/src/Skill/Effect.php @@ -1,4 +1,5 @@ + */ public function findByItem(int $itemId): iterable { + /** @var array{item_id: int, name: string, type: value-of, power:int} $effect */ foreach ($this->getData() as $effect) { if ($effect['item_id'] === $itemId) { yield new Effect($effect['name'], EffectType::from($effect['type']), $effect['power']); diff --git a/src/Trade/Offer.php b/src/Trade/Offer.php index 5349a39..9aa8c46 100644 --- a/src/Trade/Offer.php +++ b/src/Trade/Offer.php @@ -1,4 +1,5 @@ getData() as $shop) { yield new Shop($shop['id'], $shop['name'], $shop['description']); } } - protected function getDataName(): string + protected function getDataName(): string { return 'shop'; } diff --git a/src/Trade/StockRepository.php b/src/Trade/StockRepository.php index afc3c84..22717c5 100644 --- a/src/Trade/StockRepository.php +++ b/src/Trade/StockRepository.php @@ -1,4 +1,5 @@ + */ public function listShopStock(int $shopId): iterable { $stock = $this->getData()[$shopId] ?? []; @@ -39,10 +44,14 @@ protected function getDataName(): string return 'shop_stock'; } + /** + * @return array + */ protected function getData(): array { $data = []; - foreach(parent::getData() as $entry) { + /** @var array{shop_id: int, item_id: int, price_id: int, price_quantity: int} $entry */ + foreach (parent::getData() as $entry) { $data[$entry['shop_id']][] = $entry; } diff --git a/src/UI/Scene/AbstractScene.php b/src/UI/Scene/AbstractScene.php index d02f3da..2617aff 100644 --- a/src/UI/Scene/AbstractScene.php +++ b/src/UI/Scene/AbstractScene.php @@ -1,4 +1,5 @@ $parameters + * + * @return string + */ protected function renderTemplate(string $templateName, array $parameters = []): string { $fullTemplateName = sprintf('%s.html.twig', $templateName); $parameters += [ - 'player' => $this->getCurrentPlayer(), + 'player' => $this->getCurrentPlayer(), 'huntingDungeon' => $this->getHuntingDungeon(), ]; @@ -33,20 +39,26 @@ protected function renderTemplate(string $templateName, array $parameters = []): } /** - * @param class-string $sceneName + * @param class-string $sceneName * @return string */ protected function switchToScene(string $sceneName): string { /** @var SceneInterface $scene */ - $scene =\DI::getService($sceneName); + $scene = \DI::getService($sceneName); return $scene->run(new HttpInput()); } protected function getCurrentPlayer(): Player { - return $this->client->getCurrentPlayer(); + $player = $this->client->getCurrentPlayer(); + + if ($player === null) { + throw new \RuntimeException('Unexpected access to the scene. Player expected to be present.'); + } + + return $player; } protected function getHuntingDungeon(): ?Dungeon diff --git a/src/UI/Scene/Activity.php b/src/UI/Scene/Activity.php new file mode 100644 index 0000000..84c0218 --- /dev/null +++ b/src/UI/Scene/Activity.php @@ -0,0 +1,88 @@ +getString('activity'); + + // Player can not perform this activity + $player = $this->getCurrentPlayer(); + if (!$player->canPerformActivity($selectedActivity)) { + return $this->switchToScene(MainMenu::class); + } + $errorMsg = ''; + $infoMsg = ''; + + $this->handleInput($input, $player); + + $currentActivity = $player->getCurrentActivity(); + $options = []; + foreach ($this->activityOptionRepository->getActivityOptions($selectedActivity) as $opt) { + $option = [ + 'id' => $opt->id, + 'name' => $opt->name, + 'gainPerHour' => null, + ]; + + $activity = new ActivityModel($selectedActivity, $opt->id); + $option['isCurrent'] = $currentActivity !== null && $activity->isSame($currentActivity); + $reward = $activity->calculateReward($player, TimeInterval::fromHours(1)); + if (!$reward->isEmpty()) { + $option['gainPerHour'] = [ + 'exp' => $reward->exp, + 'drop' => $reward->items[0], + ]; + } + $options[] = $option; + } + + return $this->renderTemplate('activity', [ + 'options' => $options, + 'errorMsg' => $errorMsg, + 'infoMsg' => $infoMsg, + 'activityName' => $selectedActivity, + ]); + } + + private function handleInput(InputInterface $input, Player $player): void + { + if ($input->getString('action') === 'stop') { + $player->stopActivity(); + + return; + } + + $selectedActivity = $input->getString('activity'); + $selectedOption = $input->getInt('option'); + if ($selectedOption > 0) { + $activity = $this->activityOptionRepository->findActivity($selectedActivity, $selectedOption); + if ($activity !== null) { + $player->startActivity($activity); + return; + } + } + } +} diff --git a/src/UI/Scene/Auth.php b/src/UI/Scene/Auth.php index dea0a9a..f6b0ffc 100644 --- a/src/UI/Scene/Auth.php +++ b/src/UI/Scene/Auth.php @@ -1,4 +1,5 @@ authService->logout(); - header("Location: /"); + header('Location: /'); return ''; } @@ -33,7 +34,7 @@ public function run(InputInterface $input): string private function loginTab(InputInterface $input): string { - $error = ''; + $error = ''; $username = ''; if ($input->getString('login') !== '') { $username = $input->getString('username'); @@ -45,7 +46,7 @@ private function loginTab(InputInterface $input): string $result = $this->authService->login($username, $password); if ($result === null) { // todo Kind of meh. Think of smother toggle between scenes - header("Location: /"); + header('Location: /'); return ''; } @@ -56,23 +57,23 @@ private function loginTab(InputInterface $input): string return $this->renderer->render('login.html.twig', [ 'username' => $username, - 'error' => $error, + 'error' => $error, ]); } private function registerTab(InputInterface $input): string { - $error = ''; + $error = ''; $username = ''; if ($input->getString('register') !== '') { - $username = $input->getString('username'); - $password = $input->getString('password'); + $username = $input->getString('username'); + $password = $input->getString('password'); $passwordConfirm = $input->getString('password_confirm'); if ($username === '' || $password === '' || $passwordConfirm === '') { - $error = "All fields are required"; - } else if ($password !== $passwordConfirm) { - $error = "Passwords do not match"; + $error = 'All fields are required'; + } elseif ($password !== $passwordConfirm) { + $error = 'Passwords do not match'; } else { $result = $this->authService->register($username, $password); if ($result === null) { @@ -80,7 +81,7 @@ private function registerTab(InputInterface $input): string $this->authService->login($username, $password); // todo Kind of meh. Think of smother toggle between scenes - header("Location: /"); + header('Location: /'); return ''; } @@ -91,7 +92,7 @@ private function registerTab(InputInterface $input): string return $this->renderer->render('register.html.twig', [ 'username' => $username, - 'error' => $error, + 'error' => $error, ]); } } diff --git a/src/UI/Scene/CharacterCreation.php b/src/UI/Scene/CharacterCreation.php index 7386ad3..02652bd 100644 --- a/src/UI/Scene/CharacterCreation.php +++ b/src/UI/Scene/CharacterCreation.php @@ -1,4 +1,5 @@ switchToMainMenu(); } - $error = ''; + $error = ''; $selectedRace = $input->getInt('race'); if ($selectedRace > 0) { - $user = $this->authService->getCurrentUser(); + $user = $this->getCurrentUser(); $race = $this->raceRepository->getById($selectedRace); $this->createCharacter->execute($user->name, $race, $user); return $this->switchToMainMenu(); } - $races = iterator_to_array($this->raceRepository->listAll()); + $races = iterable_to_array($this->raceRepository->listAll()); return $this->renderer->render('character-creation.html.twig', [ 'races' => $races, @@ -51,8 +52,19 @@ public function run(InputInterface $input): string private function switchToMainMenu(): string { /** @var SceneInterface $scene */ - $scene =\DI::getService(MainMenu::class); + $scene = \DI::getService(MainMenu::class); return $scene->run(new HttpInput()); } + + private function getCurrentUser(): User + { + $user = $this->authService->getCurrentUser(); + + if ($user === null) { + throw new \RuntimeException('Unauthenticated access was not expected'); + } + + return $user; + } } diff --git a/src/UI/Scene/Dungeons.php b/src/UI/Scene/Dungeons.php index 9403e88..3aa2160 100644 --- a/src/UI/Scene/Dungeons.php +++ b/src/UI/Scene/Dungeons.php @@ -1,4 +1,5 @@ renderTemplate('dungeons', [ 'dungeons' => $this->dungeonRepository->listDungeons(), 'errorMsg' => $this->errorMsg, - 'infoMsg' => $this->infoMsg, + 'infoMsg' => $this->infoMsg, ]); } @@ -37,7 +38,7 @@ private function handleInteraction(InputInterface $input): void { $selectedDungeon = $input->getInt('hunt'); if ($selectedDungeon !== 0) { - $dungeon = $this->dungeonRepository->findById($selectedDungeon); + $dungeon = $this->dungeonRepository->getById($selectedDungeon); $result = $this->getCurrentPlayer()->enterDungeon($dungeon); if ($result instanceof Error) { diff --git a/src/UI/Scene/Highscore.php b/src/UI/Scene/Highscore.php index 42426a4..50b0d8e 100644 --- a/src/UI/Scene/Highscore.php +++ b/src/UI/Scene/Highscore.php @@ -1,4 +1,5 @@ getCurrentPlayer(); + $player = $this->getCurrentPlayer(); $topPlayers = $this->characterRepository->listTopCharacters(100); return $this->renderTemplate('highscores', [ - 'player' => $player, + 'player' => $player, 'topPlayers' => $topPlayers, ]); } diff --git a/src/UI/Scene/Input/HttpInput.php b/src/UI/Scene/Input/HttpInput.php index c7bb525..4f491ab 100644 --- a/src/UI/Scene/Input/HttpInput.php +++ b/src/UI/Scene/Input/HttpInput.php @@ -1,4 +1,5 @@ renderTemplate('shop', [ 'playerGold' => $player->getGold(), - 'shop' => $shop, + 'shop' => $shop, ]); } } diff --git a/src/UI/Scene/Shops.php b/src/UI/Scene/Shops.php index 549fb09..d14e0eb 100644 --- a/src/UI/Scene/Shops.php +++ b/src/UI/Scene/Shops.php @@ -1,4 +1,5 @@ renderTemplate('shops', [ 'player' => $player, - 'shops' => $this->shopRepository->listShops(), + 'shops' => $this->shopRepository->listShops(), ]); } } diff --git a/src/UI/Template/activity.html.twig b/src/UI/Template/activity.html.twig new file mode 100644 index 0000000..4b2dcfe --- /dev/null +++ b/src/UI/Template/activity.html.twig @@ -0,0 +1,52 @@ +{% extends "minimalistic.template.twig" %} + +{% block content %} +
+
{{ activityName }}
+
+
+
+ {% if errorMsg %} +
{{ errorMsg }}

+ {% elseif infoMsg %} +
{{ infoMsg }}

+ {% endif %} + {% set column = 0 %} + {% for option in options %} + {% set column = column + 1 %} + {% if column == 1 %} +
+ {% endif %} +
+

{{ option.name }}

+ {% if option.gainPerHour is not null %} + Per hour
+ Exp: {{ option.gainPerHour.exp }}
+ Resource: {{ option.gainPerHour.drop.quantity }} {{ option.gainPerHour.drop.name }}
+ {% else %} +
+
+ Skill level is too low
+ {% endif %} + {% if option.isCurrent %} + + {% else %} + + {% endif %} +
+ {% if column == 2 %} + {% set column = 0 %} +
+
+ {% endif %} + {% endfor %} + {# helps to close an unclosed div-row #} + {% if column == 1 %} +
+
+ {% endif %} +
+
+
+ +{% endblock %} diff --git a/src/UI/Template/character-creation.html.twig b/src/UI/Template/character-creation.html.twig index ee2ccc7..0be9ccf 100644 --- a/src/UI/Template/character-creation.html.twig +++ b/src/UI/Template/character-creation.html.twig @@ -54,7 +54,12 @@

Perks

-

Not implemented yet

+ {% if race.perks.canCraft %}

Can craft items

{% endif %} + {% if race.perks.canMine %}

Can mine ore

{% endif %} + {% if race.perks.canGather %}

Can gather herbs

{% endif %} + {% if race.perks.canWoodcut %}

Can chop wood

{% endif %} + {% if race.perks.canHarvest %}

Can grow crops and vegetables

{% endif %} + {% if race.perks.canBrew %}

Can brew potions and beverages

{% endif %}
{% endfor %} diff --git a/src/UI/Template/component/player-info.twig b/src/UI/Template/component/player-info.twig index 07382a1..6ad9e9e 100644 --- a/src/UI/Template/component/player-info.twig +++ b/src/UI/Template/component/player-info.twig @@ -5,12 +5,20 @@ HP: {{ player.currentHealth }}/{{ player.maxHealth }} | {% if player.stamina < 50 %} - Stamina: {{ player.stamina }}/100 + Stamina: {{ player.stamina }}/100 {% elseif player.stamina < 10 %} Stamina: {{ player.stamina }}/100 {% else %} Stamina: {{ player.stamina }}/100 {% endif %} + + {% if player.isFighting %} +

Status:

+ {% elseif player.getCurrentActivity %} +

Status:

+ {% else %} +

Status:

+ {% endif %}
Skills
@@ -21,19 +29,6 @@ Defense: {{ player.defence }}
-
  • -
    -
    Abilities
    -
    - - Woodcutting: {{ player.woodcutting }}
    - Mining: {{ player.mining }}
    - Gathering: {{ player.gathering }}
    - Harvesting: {{ player.harvesting }}
    - Blacksmith: {{ player.blacksmith }}
    - Herbalism: {{ player.herbalism }}
    -
    -
  • Bank
    diff --git a/src/UI/Template/component/player-quick-actions.twig b/src/UI/Template/component/player-quick-actions.twig new file mode 100644 index 0000000..ccb1d4a --- /dev/null +++ b/src/UI/Template/component/player-quick-actions.twig @@ -0,0 +1,17 @@ +
    + +
    +
    diff --git a/src/UI/Template/component/player-status.twig b/src/UI/Template/component/player-status.twig deleted file mode 100644 index c1c7a6e..0000000 --- a/src/UI/Template/component/player-status.twig +++ /dev/null @@ -1,27 +0,0 @@ -
    -
      -
    • -
      -
      Status
      -
      - {% if player.isFighting %} -

      Combat:

      -

      Dungeon: {{ huntingDungeon.name }}

      - {% else %} -

      Combat:

      -

      Dungeon: In protective zone

      - {% endif %} -
    • -
    • - Inventory -
    • - -
    • - Shops -
    • -
    • Mining
    • -
    • Gathering
    • -
    • Harvesting
    • -
    -
    -
    diff --git a/src/UI/Template/inventory.html.twig b/src/UI/Template/inventory.html.twig index 191a79f..bc0f449 100644 --- a/src/UI/Template/inventory.html.twig +++ b/src/UI/Template/inventory.html.twig @@ -26,7 +26,7 @@ {% endif %} {% if item.isConsumable %} - + {% endif %} diff --git a/src/UI/Template/minimalistic.template.twig b/src/UI/Template/minimalistic.template.twig index 43017b3..48dbfec 100644 --- a/src/UI/Template/minimalistic.template.twig +++ b/src/UI/Template/minimalistic.template.twig @@ -15,8 +15,8 @@ {% block head %} {% endblock %} - -