Projekt TheGame rośnie i rośnie ? Do brancha master wchodzi komponent Shipyard a wraz z nim zmiany w… 114 plikach! 🙂 Zapraszam na podsumowanie ostatniego tygodnia prac 🙂

Co robię i gdzie aktualnie jestem?

Mały reminder: jestem w trakcie tworzenia strategicznej gry MMO typu OGame / XWars / SpacePioneers z udziałem wzorców z rodziny Domain Driven Design. Kodzik piszę w PHPie, korzystając z frameworka Symfony. Grę tworzę w publicznym repozytorium na GitHubie.

Link do repozytorium: https://github.com/senghe/TheGame. Serdecznie zachęcam Was do gwiazdkowania i… udzielania się 🙂 Fajnie by było z kimś popisać, poznać Wasze spojrzenie na projekt i w ogóle.

Obecnie mamy zrobione:

  • Obsługę wydobywania i magazynowania surowców (wpis)
  • Tworzenie budynków (wpis)

A dziś do grona funkcjonalności włączamy tworzenie statków kosmicznych i działek planetarnych.

Uwaga, mamy nowe procesy! 🙂

W ostatnim tygodniu pracowałem nad komponentem Shipyard, czyli stocznią kosmiczną, w której możemy produkować statki oraz działa obronne planety. Link do pull requestu z wprowadzonymi zmianami znajdziecie tutaj: https://github.com/senghe/TheGame/pull/3.

Pierwszym procesem, który powstał w tej materii jest budowa oraz upgrade stoczni. Ten proces nie posiada własnej komendy, ponieważ jest on bezpośrednio połączony z komponentem BuildingConstruction. Dokładniej, komponent Shipyard nasłuchuje na zdarzenie konstrukcji odpowiedniego budynku. Jeżeli zbudowaliśmy poziom pierwszy, to wtedy tworzymy encję Shipyard, czyli głównego agregatu. Jeżeli poziom budynku jest wyższy, to znaczy, że powinniśmy zrobić upgrade stoczni, zwiększając jej moce przerobowe. Kod opisywanego listenera wygląda następująco:

<?php

final class UpgradeShipyardEventListener
{
    // ...

    public function __invoke(ShipyardConstructionHasBeenFinishedEvent $event): void
    {
        $planetId = new PlanetId($event->getPlanetId());
        $buildingId = new BuildingId($event->getBuildingId());

        $shipyard = $event->getUpgradedLevel() === 1
            ? $this->shipyardFactory->create($planetId, $buildingId)
            : $this->shipyardRepository->findAggregateForBuilding($planetId, $buildingId);

        if ($shipyard === null) {
            throw new ShipyardHasNotBeenFoundException($buildingId);
        }

        $shipyard->upgrade(
            $this->shipyardBalanceContext->getShipyardProductionLimit($event->getUpgradedLevel()),
        );
    }

Wielkość limitu produkcyjnego zostaje zaczytana z komponentu Balance, a dokładniej z interfejsu, który obecnie on dostarcza. Póki co komponent Balance jest jednym wielkim zbiorem interfejsów, które pod koniec pracy nad warstwą aplikacji będziemy implementować.

Kolejnymi procesami, na pozór bliźniaczymi, są konstrukcja statku kosmicznego oraz działka obronnego. W tym miejscu mamy dwie komendy, z których możemy skorzystać:

<?php

declare(strict_types=1);

namespace TheGame\Application\Component\Shipyard\Command;

final class ConstructCannonsCommand implements CommandInterface
{
    public function __construct(
        private readonly string $shipyardId,
        private readonly string $cannonType,
        private readonly int $quantity,
    ) {
    }

    // ...
}

final class ConstructShipsCommand implements CommandInterface
{
    public function __construct(
        private readonly string $shipyardId,
        private readonly string $shipType,
        private readonly int $quantity,
    ) {
    }

    // ...
}

Na tą chwilę zarówno komendy, jak i handlery je obsługujące wyglądają bardzo podobnie; różnią się tylko tym, że w niektórych miejscach mamy słowo „Cannon” a w innych „Ship”. Na chwilę obecną to wygląda jak niepotrzebna duplikacja kodu, ale zgodnie z Domain Driven Design – tak musi byc. Bo są to osobne procesy, które mimo podobieństwa – mogą się w przyszłości różnić. Najprostszym przykładem znanym z gry XWars jest to, że statek kosmiczny może tam mieć podział na części takie jak kadłub, działka czy napęd. I nie jest powiedziane, że w przyszłości coś takiego będziemy tutaj implementować. A kiedy będzie to robione, to bez wpływania na logikę tworzonych działek, bo już to mamy odseparowane.

Stocznia, zarówno w oryginalnym OGame, jak i w mojej implementacji, pracuje w oparciu o kolejki. Każde zlecenie budowy trafia do kolejki i wykonuje się jedno po drugim. Na każde zlecenie (encja Job) składa się konstrukcja, którą tworzymy (Constructible) oraz ilość (quantity). Nie pamiętam, czy w OGame tak jest, ale u mnie można anulować zlecenie, które nie zostało jeszcze podjęte przez stocznię (nie będące pierwszym w kolejce). I na to również mamy komendę, bo chcemy na to zezwolić graczowi.

Ostatnim procesem, który ma swój odpowiednik w komendzie jest ten, który odpowiada za zakończenie zadań stoczni, w zależności od czasu, który minął. Mamy na to średnio zaawansowaną logikę w encji Shipyard:

<?php

class Shipyard
{
    // ...

    public function finishJobs(): FinishedJobsSummaryInterface
    {
        $summary = new FinishedJobsSummary();
        if (count($this->jobQueue) === 0) {
            return $summary;
        }

        $now = new \DateTimeImmutable();
        $elapsedTime = $now->getTimestamp() - $this->lastUpdatedAt->getTimestamp();

        do {
            if (count($this->jobQueue) === 0) {
                break;
            }

            $currentJob = $this->jobQueue[0];
            if ($currentJob->getDuration() > $elapsedTime) {
                $finishedQuantity = $currentJob->finishPartially($elapsedTime);
                $summary->addEntry(
                    $currentJob->getConstructionUnit(),
                    $currentJob->getConstructionType(),
                    $finishedQuantity,
                );

                break;
            }

            $elapsedTime -= $currentJob->getDuration();
            $currentJob->finish();

            $summary->addEntry(
                $currentJob->getConstructionUnit(),
                $currentJob->getConstructionType(),
                $currentJob->getQuantity(),
            );

            array_shift($this->jobQueue);
        } while ($elapsedTime > 0);

        $this->lastUpdatedAt = $now;

        return $summary;
    }
}

To jest jedna z zasad Domain Driven Design: logikę domenową trzymamy w encjach. Nie potrzeba nam do tego żadnego dodatkowego serwisu. Bo jest to zachowanie bezpośrednio związane ze stocznią. I dlatego to tam powinien być ten kodzik.

Chyba złamałem sobie CQSa…

Ale tylko na pozór. Jak popatrzycie na powyszszą metodę Shipyard::finishJobs(...), to zauważycie że modyfikujemy tam stan obiektu, równocześnie zwracając dane przez tą metodę. No CQS złamay ewidentnie, co nie? No właśnie, że nie. Bo zasada CQS nie zabrania zwracania czegokolwiek z metody zmieniającej stan obiektu. Ona zakazuje zwrócenia informacji o stanie obiektu. A ja tam nie zwracam informacji o stanie obiektu. Zwracam jakiś nowo utworzony w tej metodzie obiekt, który w żaden sposób nie jest połączony z encją Shipyard. Ha, 1:0 dla mnie! (。◕‿◕。)

A jeżeli chcielibyście coś więcej poczytać o CQS, to zapraszam do pierwszego wpisu napisanego na tym blogu: Czym jest i dlaczego należy stosować CQS.

Trochę pozmieniałem procesy budowania budynków

Pracując nad stocznią, doszedłem do wniosku, że procesy w komponencie BuildingConstruction są… słabe. Popatrzcie na tą komendę:

<?php

// ...

final class CancelConstructingCommand implements CommandInterface
{
    public function __construct(
        private readonly string $planetId,
        private readonly string $buildingType,
    ) {
    }

    // ...
}

Ta logika jawnie blokuje nas przed tym, aby mieć kilka budynków tego samego typu na planecie. No bo jak anulujemy konstrukcję, to skąd będziemy wiedzieli, którą kopalnię chcemy anulować? A co, jeżeli w jakiś sposób będziemy mieli możliwość konstrukcji dwóch, trzech albo pięciu budynków na planecie jednocześnie? No i co, jeżeli wszystkie z nich będą kopalniami?

Popatrzcie, na co się pokusiłem:

<?php

// ...

final class CancelConstructingCommand implements CommandInterface
{
    public function __construct(
        private readonly string $planetId,
        private readonly string $buildingId,
    ) {
    }

    // ...
}

Szaleństwo, co nie? To teraz zadajmy sobie pytanie, czy nie powinno tak być od samego początku? Dodatkowo, odseparowałem od siebie logikę tworzenia budynku od jego upgrade. Komenda tworzenia przyjmuje parametr $buildingType, a komenda upgradująca dostaje $buildingId (z takich samych pobudek jak wyżej).

Dlaczego Shipyard jako encja?

Kolejnym tematem, który mógłby Was zastanawiać jest fakt, że Shipyard jest osobną encją. Nie widziałem kodu OGame (chociaż ten jest dostępny w internecie), ale założyłbym się, że tam stocznia jest jednym polem w tabelce z planetami. No i spoko. Bo tam jest jedna stocznia, która ma swoją kolejkę prac.

Ja podchodzę do tego nieco inaczej: na planecie może być kilka różnych stoczni, a każda z nich może mieć inny poziom, inną linię produkcyjną, inne zadania. Co, jeżeli w przyszłości będziemy mogli specjalizować stocznie? I wtedy ta z „ulepszeniami” do produkcji statków będzie produkowała wyłącznie statki? A co, jeżeli chcielibyśmy jakąś planetę poświęcić na produkującą wyłącznie statki? Możemy też wtedy zbudować kilka stoczni wysokiego poziomu, które będą szybko budowały statki potężne, oraz dużo stoczni niskiego poziomu, które będą produkowały statki proste, ale za to w dużej ilości. Encja stoczni to z jednej strony prosta decyzja. Z drugiej strony jednak jest to decyzja, która otwiera wiele ciekawych dróg.

Kontrowersyjny JobFactory…

Mamy sobie fabrykę, której interfejs wygląda tak:

<?php

declare(strict_types=1);

namespace TheGame\Application\Component\Shipyard\Domain\Factory;

// ...

interface JobFactoryInterface
{
    public function createNewCannonsJob(
        string $cannonType,
        int $quantity,
        int $shipyardLevel,
        int $cannonConstructionTime,
        int $cannonProductionLoad,
        ResourceRequirementsInterface $cannonResourceRequirements,
    ): Job;

    // ...
}

Aby utworzyć nowy obiekt Job, potrzebujemy przesłać 6 parametrów, w tym jeden agregujący wiele wartości. Wywołanie tej metody wygląda następująco:

<?php

namespace TheGame\Application\Component\Shipyard\CommandHandler;

use TheGame\Application\Component\Balance\Bridge\ShipyardContextInterface;
// ...

final class ConstructCannonsCommandHandler
{
    private function createJob(
        string $cannonType,
        int $quantity,
        Shipyard $shipyard,
    ): Job {
        return $this->jobFactory->createNewCannonsJob(
            $cannonType,
            $quantity,
            $shipyard->getCurrentLevel(),
            $this->shipyardBalanceContext->getCannonConstructionTime($cannonType, $shipyard->getCurrentLevel()),
            $this->shipyardBalanceContext->getCannonProductionLoad($cannonType),
            $this->shipyardBalanceContext->getCannonResourceRequirements($cannonType),
        );
    }
}

Gdybyśmy trochę porefaktoryzowali, to moglibyśmy przekazać dwa parametry mniej, bo moglibyśmy przesłać w parametrze cały shipyardBalanceContext. A gdybyśmy trochę bardziej pokombinowali, to wyszłoby nam, że ten serwis możemy sobie wstrzyknąć do serwisu JobFactory i odpadną nam aż trzy parametry. Niby tak, ale nie do końca 😉

W Domain Driven Design mamy warstwę domeny, która jest otoczona warstwą aplikacji. Zarówno domena jak i aplikacja pocięte są na komponenty. O ile komponenty aplikacji mogą gdzieś tam komunikować się między sobą (opisane w poprzednim wpisie), o tyle domena już niekoniecznie. To powinno działać tak, że aplikacja widzi domenę, ale domena nie widzi aplikacji. Jeżeli wstrzyknęlibyśmy do fabryki domenowej JobFactory serwis aplikacyjny (serwis balansu jest serwisem aplikacyjnym), to wtedy zrobimy zależność warstwy domeny od warstwy aplikacji. A tego kategorycznie nie chcemy robić. Obecne rozwiązanie może nie jest idealne, ale za to zgodne z DDD i architekturą warstwową.

Integracja Shipyardu z innymi komponentami

Jak już wcześniej pisałem, musiałem zintegrować komponent Shipyard z komponentem BuildingConstruction. Jest to integracja przez nasłuchiwanie zdarzenia w Shipyard. Oprócz tego, musiałem pointegrować się z komponentem ResourceStorage, bo trzeba było sprawdzić, czy mamy wystarczającą ilość zasobów na konstrukcję statków / działek. Tutaj klasycznie skorzystaliśmy z serwisu-bridge wydzielonego w ResourceStorage. Oprócz weryfikacji stanu zasobów, po zleceniu zadania trzeba zapłacić za nie surowcami. I tutaj działamy w drugą stronę – komponent ResourceStorage nasłuchuje na odpowiednie zdarzenia z komponentu Shipyard. W tym przypadku są to zdarzenia zakolejkowania produkcji w stoczni.

Aaaaaale dużo specek już mamy 🙂

Komponent Shipyard nie jest jakiś wielki. Pomimi tego, nawet nie wyobrażacie sobie, ile drobnych oraz tych większych problemów pokazały mi specki. Tak więc ich koszt się spłacił i to już w trakcie pisania. Z jednej strony, testy trochę ciążą – trzeba było całej soboty na ogarnięcie ponad 100 specek. Z drugiej – teraz już wiem, że to na pewno działa. Na obecną chwilę specek mamy już 270 ?, a liczba ta szybko rośnie.

W kontekście specek, chciałem Wam jeszcze podrzucić protip na duże specki. Jeżeli macie taką sytuację, że dla Waszej logiki musicie napisać zestaw podobnych specek, które różnią się tylko kilkoma parametrami, to możecie nieco uprościć sobie temat. Zamiast pisać kilka dużych specek, część boilerplate możecie wrzycić do prywatnej metody, tak jak ja to zrobiłem:

<?php

namespace spec\TheGame\Application\Component\Shipyard\Domain\Entity;

// ...

final class ShipyardSpec extends ObjectBehavior
{
    public function it_throws_exception_when_trying_to_cancel_already_taken_job(
        JobIdInterface $job1Id,
        Job $job1,
        ResourceRequirementsInterface $job1ResourceRequirements,
        JobIdInterface $job2Id,
        Job $job2,
        ResourceRequirementsInterface $job2ResourceRequirements,
    ): void {
        $this->stubTwoJobs(
            $job1,
            $job1Id,
            10,
            $job1ResourceRequirements,
            $job2,
            $job2Id,
            10,
            $job2ResourceRequirements,
        );
        $job1Id->getUuid()->willReturn("60446888-CF73-4FD0-8862-E70E56320622");

        $this->queueJob($job1);
        $this->queueJob($job2);

        $this->shouldThrow(CantCancelCurrentlyTakenJobException::class)
            ->during('cancelJob', [$job1Id]);
    }

    private function stubTwoJobs(
        Job $job1,
        JobIdInterface $job1Id,
        int $duration1,
        ResourceRequirementsInterface $job1ResourceRequirements,
        Job $job2,
        JobIdInterface $job2Id,
        int $duration2,
        ResourceRequirementsInterface $job2ResourceRequirements,
    ): void {
        $job1Id->getUuid()->willReturn("CF224D29-FEAE-45C1-9C69-D6D8D92110BC");
        $job1->getId()->willReturn($job1Id);
        $job1->getDuration()->willReturn($duration1);
        $job1->getProductionLoad()->willReturn(3);
        $job1->getQuantity()->willReturn(1);
        $job1->getConstructionUnit()->willReturn(ConstructibleUnit::Ship);
        $job1->getConstructionType()->willReturn('light-fighter');
        $job1->getRequirements()->willReturn($job1ResourceRequirements);

        $job2Id->getUuid()->willReturn("38A1A7BA-225D-4C58-BF24-8645ECD768A7");
        $job2->getId()->willReturn($job2Id);
        $job2->getDuration()->willReturn($duration2);
        $job2->getProductionLoad()->willReturn(3);
        $job2->getQuantity()->willReturn(1);
        $job2->getConstructionUnit()->willReturn(ConstructibleUnit::Cannon);
        $job2->getConstructionType()->willReturn('laser');
        $job2->getRequirements()->willReturn($job2ResourceRequirements);
    }
}

U mnie w ten sposób zredukowałem aż 10 metod w jednej klasie specki. Co prawda ECS trochę mi to formatowanie popsuł (co widać w kodzie wyżej), ale tu w sumie nie tylko o linijki chodzi, a o logikę, którą trzeba niepotrzebnie powtarzać.

Wpis bez obrazków to nie wpis 😀

Na koniec perełka: obrazki, które prawdopodobnie wykorzystam w fixturkach projektu. Poznajcie kolejno: stocznię planetarną, lekkiego myśliwca, ciężkiego myśliwca, lekkie działko laserowe oraz ciężkie działko laserowe ?

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.