diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml new file mode 100644 index 0000000..d4dde89 --- /dev/null +++ b/.github/workflows/code-analysis.yml @@ -0,0 +1,26 @@ +name: code-analysis + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '**.md' + workflow_call: + +concurrency: + group: 'test-${{ github.ref_name }}' + cancel-in-progress: true + +env: + APP_VERSION: ${{ format('0.0.0-build{0}', github.run_number) }} + +jobs: + app: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Disable Xdebug + run: sudo phpdismod xdebug + - run: composer install --prefer-dist --no-ansi --no-interaction --no-progress + - run: composer cs:check + - run: composer phpstan diff --git a/.github/workflows/test-application.yml b/.github/workflows/test-application.yml new file mode 100644 index 0000000..8540f49 --- /dev/null +++ b/.github/workflows/test-application.yml @@ -0,0 +1,32 @@ +name: test + +on: + pull_request: + paths-ignore: + - 'docs/**' + - '**.md' + workflow_call: + +concurrency: + group: 'test-${{ github.ref_name }}' + cancel-in-progress: true + +env: + APP_VERSION: ${{ format('0.0.0-build{0}', github.run_number) }} + +jobs: + app: + runs-on: ubuntu-latest + strategy: + matrix: + testsuite: + - core + - data + steps: + - uses: actions/checkout@v5 + - name: Disable Xdebug + run: sudo phpdismod xdebug + - name: Create test folder + run: mkdir tmp && mkdir tmp/test-drafts + - run: composer install --prefer-dist --no-ansi --no-interaction --no-progress + - run: composer paratest -- --processes=4 --testsuite ${{ matrix.testsuite }} diff --git a/.gitignore b/.gitignore index 1a81e67..d447c49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ clean.php .DS_Store supervisord.log supervisord.pid -tmp \ No newline at end of file +tmp +.phpunit.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..042b638 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,57 @@ +in([ + __DIR__ . '/app', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setRules([ + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => [ + 'imports_order' => ['class', 'function', 'const'], + 'sort_algorithm' => 'alpha', + ], + 'fully_qualified_strict_types' => [ + 'phpdoc_tags' => [], + ], + 'no_unused_imports' => true, + 'blank_line_after_opening_tag' => true, + 'declare_strict_types' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => [ + 'elements' => ['arguments', 'arrays', 'match', 'parameters'], + ], + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'no_extra_blank_lines' => [ + 'tokens' => ['parenthesis_brace_block', 'return', 'square_brace_block', 'extra'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'void_return' => true, + 'single_quote' => true, + 'multiline_promoted_properties' => [ + 'minimum_number_of_parameters' => 1, + ], + ]) + ->setFinder($finder); diff --git a/README.md b/README.md index 3511f3a..a0f2fdc 100644 --- a/README.md +++ b/README.md @@ -15,24 +15,26 @@ To install a local copy of this app you can clone it from the Git Repo: Then follow these steps: -1. Add `127.0.0.1 miltydraft.test` to your `/etc/hosts` file. This first step is optional though. You can use +1. Add `127.0.0.1 milty.localhost` to your `/etc/hosts` file. This first step is optional though. You can use 127.0.0.1 directly as well. 2. Run `docker-compose up -d --build`. This will first build the image, then start all services. 3. Run `docker-compose exec app composer install`. This will install all php dependencies. 4. Create a `.env` file. See `.env.example` for details. -5. Go to [http://miltydraft.test](http://miltydraft.test) or [127.0.0.1](127.0.0.1) in your browser. +5. Go to [https://milty.localhost](https://milty.localhost) in your browser (or http://localhost if you don't want to go through the hassle of the following steps) +6. Your browser might give you some scary warnings about untrusted certificates. That's because we're using a self-signed certificate. +If you want to, you can add the certificate to your device's truster certificate. +7. Run `docker compose exec app cat /home/app/.local/share/caddy/pki/authorities/local/root.crt > caddy-cert.crt` to make a copy of the certificate in `caddy-cert.crt` (which you can then import wherever you need it) ### Libraries and Dependencies Frontend runs on vanilla JS/jQuery (I'm aware jQuery is a bit of a blast from the past at this point; sue me and/or change it and PR me if you want) and the Back-end is vanilla PHP. As such there's no build-system, or compiling required except for the steps described above. -To make this app as lean and mean (and easy to understand for anyone) as possible, external dependencies, both in the front- and backend should be kept to an absolute minimum. +To make this app as lean and mean (and easy to understand for anyone) as possible, external dependencies, both in the front- and backend should be kept to an absolute minimum. ### Understanding the App flow 1. Players come in on index.php and choose their options. 2. A JSON config file is created (either locally or remotely, depending on .env settings) with a unique ID -3. That Draft ID is also the Draft URL: APP_URL/d/{draft-id} (URL rewriting is done via Caddy) +3. That Draft ID is also the Draft URL: APP_URL/d/{draft-id} (URL rewriting is done via Caddy locally) 4. Players (or the Admin) make draft choices, which updates the draft json file (with very loose security, since we're assuming a very low amount of bad actors) - diff --git a/app/Application.php b/app/Application.php new file mode 100644 index 0000000..9fc6639 --- /dev/null +++ b/app/Application.php @@ -0,0 +1,130 @@ +repository = new S3DraftRepository(); + } else { + $this->repository = new LocalDraftRepository(); + } + } + + public function run(): void + { + $response = $this->handleIncomingRequest(); + + http_response_code($response->code); + header('Content-type: ' . $response->getContentType()); + echo $response->getBody(); + exit; + } + + private function handleIncomingRequest(): HttpResponse + { + try { + $handler = $this->handlerForRequest($_SERVER['REQUEST_URI']); + if ($handler == null) { + return new ErrorResponse('Page not found', 404, true); + } else { + return $handler->handle(); + } + + } catch (\Exception $e) { + return new ErrorResponse($e->getMessage()); + } + } + + private function matchToRoute(string $path): ?RouteMatch + { + $routes = include 'app/routes.php'; + + foreach($routes as $route => $handlerClass) { + $route = new Route($route, $handlerClass); + $match = $route->match($path); + + if ($match != null) { + return $match; + } + } + + return null; + } + + public function handlerForRequest(string $requestUri): ?RequestHandler + { + $requestChunks = explode('?', $requestUri); + + $match = $this->matchToRoute($requestChunks[0]); + + if ($match == null) { + return null; + } else { + $request = HttpRequest::fromRequest($match->requestParameters); + + $handler = new $match->requestHandlerClass($request); + + if (! $handler instanceof RequestHandler) { + throw new \Exception('Handler does not implement RequestHandler'); + } + + return $handler; + } + } + + public function spyOnDispatcher($commandReturnValue = null): void + { + $this->spyOnDispatcher = true; + $this->spy = new DispatcherSpy($commandReturnValue); + } + + public function dontSpyOnDispatcher(): void + { + $this->spyOnDispatcher = false; + unset($this->spy); + } + + public function handle(Command $command) + { + if ($this->spyOnDispatcher) { + return $this->spy->handle($command); + } else { + return $command->handle(); + } + } + + public static function getInstance(): self + { + if (! isset(self::$instance)) { + self::$instance = new Application(); + } + + return self::$instance; + } +} \ No newline at end of file diff --git a/app/ApplicationTest.php b/app/ApplicationTest.php new file mode 100644 index 0000000..264e3d3 --- /dev/null +++ b/app/ApplicationTest.php @@ -0,0 +1,82 @@ + [ + 'route' => '/', + 'handler' => HandleViewFormRequest::class, + ]; + + yield 'For viewing a draft' => [ + 'route' => '/d/1234', + 'handler' => HandleViewDraftRequest::class, + ]; + + yield 'For fetching draft data' => [ + 'route' => '/api/draft/1234', + 'handler' => HandleGetDraftRequest::class, + ]; + + yield 'For generating a draft' => [ + 'route' => '/api/generate', + 'handler' => HandleGenerateDraftRequest::class, + ]; + + yield 'For making a pick' => [ + 'route' => '/api/pick', + 'handler' => HandlePickRequest::class, + ]; + + yield 'For claiming a player' => [ + 'route' => '/api/claim', + 'handler' => HandleClaimOrUnclaimPlayerRequest::class, + ]; + + yield 'For restoring a claim' => [ + 'route' => '/api/restore', + 'handler' => HandleRestoreClaimRequest::class, + ]; + + yield 'For undoing a pick' => [ + 'route' => '/api/undo', + 'handler' => HandleUndoRequest::class, + ]; + + yield 'For regenerating a draft' => [ + 'route' => '/api/regenerate', + 'handler' => HandleRegenerateDraftRequest::class, + ]; + } + + #[Test] + #[DataProvider('allRoutes')] + public function itHasHandlerForAllRoutes($route, $handler): void + { + $application = new Application(); + $determinedHandler = $application->handlerForRequest($route); + + $this->assertInstanceOf($handler, $determinedHandler); + } +} \ No newline at end of file diff --git a/app/Draft.php b/app/Draft.php deleted file mode 100644 index e5ceb4f..0000000 --- a/app/Draft.php +++ /dev/null @@ -1,365 +0,0 @@ -draft = ($draft === [] ? [ - 'players' => $this->generatePlayerData(), - 'log' => [], - ] : $draft); - - $this->done = $this->isDone(); - $this->draft["current"] = $this->currentPlayer(); - } - - public static function createFromConfig(GeneratorConfig $config) - { - $id = uniqid(); - $secrets = array("admin_pass" => md5(uniqid("", true))); - $slices = Generator::slices($config); - $factions = Generator::factions($config); - - $name = $config->name; - - return new self($id, $secrets, [], $slices, $factions, $config, $name); - } - - public static function getCurrentInstance(): self - { - return self::$instance; - } - - private static function getS3Client() - { - $s3 = new \Aws\S3\S3Client([ - 'version' => 'latest', - 'region' => 'us-east-1', - 'endpoint' => 'https://' . $_ENV['REGION'] . '.digitaloceanspaces.com', - 'credentials' => [ - 'key' => $_ENV['ACCESS_KEY'], - 'secret' => $_ENV['ACCESS_SECRET'], - ], - ]); - - return $s3; - } - - public static function load($id): self - { - if (!$id) { - throw new \Exception('Tried to load draft with no id'); - } - - try { - if ($_ENV['STORAGE'] == 'local') { - - $path = $_ENV['STORAGE_PATH'] . '/' . 'draft_' . $id . '.json'; - - if(!file_exists($path)) { - throw new \Exception('Draft not found'); - } - - $rawDraft = file_get_contents($_ENV['STORAGE_PATH'] . '/' . 'draft_' . $id . '.json'); - } else { - $s3 = self::getS3Client(); - $file = $s3->getObject([ - 'Bucket' => $_ENV['BUCKET'], - 'Key' => 'draft_' . $id . '.json', - ]); - - $rawDraft = (string) $file['Body']; - } - - $draft = json_decode($rawDraft, true); - - $secrets = $draft["secrets"] ?: array("admin_pass" => $draft["admin_pass"]); - - return new self($id, $secrets, $draft["draft"], $draft["slices"], $draft["factions"], GeneratorConfig::fromArray($draft["config"]), $draft["name"]); - } catch (S3Exception $e) { - abort(404, 'Draft not found'); - } - - } - - public function getId(): string - { - return $this->id; - } - - public function getAdminPass(): string - { - return $this->secrets["admin_pass"]; - } - - public function isAdminPass(?string $pass): bool - { - return ($pass ?: "") === $this->getAdminPass(); - } - - public function getPlayerSecret($playerId = ""): string - { - return $this->secrets[$playerId] ?: ""; - } - - public function isPlayerSecret($playerId, $secret): bool - { - return ($secret ?: "") === $this->getPlayerSecret($playerId); - } - - public function getPlayerIdBySecret($secret): string - { - return array_search($secret ?: "", $this->secrets); - } - - public function name(): string - { - return $this->name; - } - - public function slices(): array - { - return $this->slices; - } - - public function factions(): array - { - return $this->factions; - } - - public function config(): GeneratorConfig - { - return $this->config; - } - - public function currentPlayer(): string - { - $doneSteps = count($this->draft['log']); - $snakeDraft = array_merge(array_keys($this->draft['players']), array_keys(array_reverse($this->draft['players']))); - return $snakeDraft[$doneSteps % count($snakeDraft)]; - } - - public function log(): array - { - return $this->draft['log']; - } - - public function players(): array - { - return $this->draft['players']; - } - - public function isDone(): bool - { - return count($this->log()) >= (count($this->players()) * 3); - } - - public function undoLastAction() - { - $last_log = array_pop($this->draft['log']); - - $this->draft["players"][$last_log['player']][$last_log['category']] = null; - $this->draft['current'] = $last_log['player']; - - $this->save(); - } - - public function pick($player, $category, $value) - { - $this->draft['log'][] = [ - 'player' => $player, - 'category' => $category, - 'value' => $value - ]; - - $this->draft['players'][$player][$category] = $value; - - $this->draft['current'] = $this->currentPlayer(); - - $this->done = $this->isDone(); - - $this->save(); - } - - public function claim($player) - { - if ($this->draft['players'][$player]["claimed"] == true) { - return_error('Already claimed'); - } - $this->draft['players'][$player]["claimed"] = true; - $this->secrets[$player] = md5(uniqid("", true)); - - return $this->save(); - } - - public function unclaim($player) - { - if ($this->draft['players'][$player]["claimed"] == false) { - return_error('Already unclaimed'); - } - $this->draft['players'][$player]["claimed"] = false; - unset($this->secrets[$player]); - - return $this->save(); - } - - public function save() - { - if ($_ENV['STORAGE'] == 'local') { - file_put_contents($_ENV['STORAGE_PATH'] . '/' . 'draft_' . $this->getId() . '.json', (string) $this); - } else { - $s3 = $this->getS3Client(); - - $result = $s3->putObject([ - 'Bucket' => $_ENV['BUCKET'], - 'Key' => 'draft_' . $this->getId() . '.json', - 'Body' => (string) $this, - 'ACL' => 'private' - ]); - - return $result; - } - } - - public function regenerate(bool $regen_slices, bool $regen_factions, bool $regen_order): void - { - if ($this->config->seed !== null) { - mt_srand($this->config->seed); - $this->config->seed = mt_rand(1, GeneratorConfig::MAX_SEED_VALUE); - } - - if ($regen_factions) { - $this->factions = Generator::factions($this->config); - } - - if ($regen_slices) { - $this->slices = Generator::slices($this->config); - } - - if ($regen_order) { - $this->draft['players'] = $this->generatePlayerData(); - } - - $this->save(); - } - - private function generatePlayerData() - { - $player_data = []; - - $alliance_mode = $this->config->alliance != null; - - if ($this->config->seed !== null) { - mt_srand($this->config->seed + self::SEED_OFFSET_PLAYER_ORDER); - } - - if ($alliance_mode) { - $playerTeams = $this->generateTeams(); - } else { - if(!$this->config->preset_draft_order || !isset($this->config->preset_draft_order)) { - shuffle($this->config->players); - } - } - - - $player_names = $this->config->players; - - foreach ($player_names as $p) { - // use admin password and player name to hash an id for the player - $id = 'p_' . md5($p . $this->getAdminPass()); - - $player_data[$id] = [ - 'id' => $id, - 'name' => $p, - 'claimed' => false, - 'position' => null, - 'slice' => null, - 'faction' => null, - 'team' => $alliance_mode ? $playerTeams[$p] : null - ]; - } - - return $player_data; - } - - private function generateTeams(): array - { - $teamNames = array_slice(['A', 'B', 'C', 'D'], 0, count($this->config->players) / 2); - - if ($this->config->alliance["alliance_teams"] == 'random') { - shuffle($this->config->players); - } - - $teams = []; - $currentTeam = []; - $i = 0; - // put players in teams - while(count($teamNames) > 0) { - $currentTeam[] = $this->config->players[$i]; - $i++; - - // if we filled up a team add it to the teams array with an unused name - if(count($currentTeam) == 2) { - $name = array_shift($teamNames); - // randomise order of team - shuffle($currentTeam); - $teams[$name] = $currentTeam; - $currentTeam = []; - } - } - - // determine team order - // + put em in dictionary to map player names to team - $playerTeams = []; - $teamNames = array_keys($teams); - shuffle($teamNames); - $newPlayerOrder = []; - - foreach($teamNames as $n) { - foreach($teams[$n] as $player) { - $newPlayerOrder[] = $player; - $playerTeams[$player] = $n; - } - } - - // violates immutability a bit, whoopsie... - $this->config->players = $newPlayerOrder; - - return $playerTeams; - } - - public function __toString(): string - { - return json_encode($this->toArray()); - } - - public function toArray(): array - { - return get_object_vars($this); - } - - public function jsonSerialize(): array - { - $draft = $this->toArray(); - unset($draft["secrets"]); - unset($draft["admin_pass"]); - return $draft; - } -} diff --git a/app/Draft/Commands/ClaimPlayer.php b/app/Draft/Commands/ClaimPlayer.php new file mode 100644 index 0000000..4ed7afd --- /dev/null +++ b/app/Draft/Commands/ClaimPlayer.php @@ -0,0 +1,30 @@ +draft->playerById($this->playerId); + $this->draft->updatePlayerData($player->claim()); + $secret = $this->draft->secrets->generateSecretForPlayer($this->playerId); + + app()->repository->save($this->draft); + + return $secret; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/ClaimPlayerTest.php b/app/Draft/Commands/ClaimPlayerTest.php new file mode 100644 index 0000000..22d963e --- /dev/null +++ b/app/Draft/Commands/ClaimPlayerTest.php @@ -0,0 +1,65 @@ +testDraft->players)); + $claimPlayer = new ClaimPlayer($this->testDraft, $playerId); + + $this->assertInstanceOf(Command::class, $claimPlayer); + } + + #[Test] + public function itCanClaimAPlayer(): void + { + $playerId = PlayerId::fromString(array_key_first($this->testDraft->players)); + + $claimPlayer = new ClaimPlayer($this->testDraft, $playerId); + + $secret = $claimPlayer->handle(); + + // check to see if changes were saved + $this->reloadDraft(); + + $this->assertTrue($this->testDraft->playerById($playerId)->claimed); + $this->assertTrue($this->testDraft->secrets->checkPlayerSecret($playerId, $secret)); + } + + #[Test] + public function itThrowsAnErrorIfPlayerIsNotPartOfDraft(): void + { + $playerId = PlayerId::fromString('123'); + + $this->expectException(\Exception::class); + $claimPlayer = new ClaimPlayer($this->testDraft, $playerId); + $claimPlayer->handle(); + } + + #[Test] + public function itThrowsAnErrorIfPlayerIsAlreadyClaimed(): void + { + $playerId = PlayerId::fromString(array_key_first($this->testDraft->players)); + + $claimPlayer = new ClaimPlayer($this->testDraft, $playerId); + $claimPlayer->handle(); + + $this->expectException(InvalidClaimException::class); + $claimPlayer->handle(); + } + +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateDraft.php b/app/Draft/Commands/GenerateDraft.php new file mode 100644 index 0000000..ce62b75 --- /dev/null +++ b/app/Draft/Commands/GenerateDraft.php @@ -0,0 +1,95 @@ +generatePlayerData(); + + // not going through dispatch method because if we're faking it then that sucks + $slices = (new GenerateSlicePool($this->settings))->handle(); + $factions = (new GenerateFactionPool($this->settings))->handle(); + + return new Draft( + DraftId::generate(), + false, + $players, + $this->settings, + $this->generateSecrets(), + $slices, + $factions, + [], + PlayerId::fromString(array_key_first($players)), + ); + } + + protected function generateSecrets(): Secrets + { + return new Secrets(Secrets::generateSecret()); + } + + /** + * @return array + */ + protected function generateTeamNames(): array + { + return array_slice(['A', 'B', 'C', 'D'], 0, count($this->settings->playerNames) / 2); + } + + /** + * @return array + */ + public function generatePlayerData(): array + { + /** @var array $players */ + $players = []; + + $playerNames = [...$this->settings->playerNames]; + + if (! $this->settings->presetDraftOrder) { + shuffle($playerNames); + } + + foreach ($playerNames as $name) { + $p = Player::create($name); + $players[$p->id->value] = $p; + } + + if ($this->settings->allianceMode) { + $teamNames = $this->generateTeamNames(); + $teamPlayers = []; + + if ($this->settings->allianceTeamMode == AllianceTeamMode::RANDOM) { + shuffle($players); + } + + foreach(array_values($players) as $i => $player) { + $teamName = $teamNames[(int) floor($i / 2)]; + $teamPlayers[$player->id->value] = $player->putInTeam($teamName); + } + + $players = $teamPlayers; + } + + return $players; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateDraftTest.php b/app/Draft/Commands/GenerateDraftTest.php new file mode 100644 index 0000000..30ca36c --- /dev/null +++ b/app/Draft/Commands/GenerateDraftTest.php @@ -0,0 +1,115 @@ +assertInstanceOf(Command::class, $cmd); + } + + #[Test] + public function itCanGenerateADraftBasedOnSettings(): void + { + // don't have to check slices and factions, that's tested in their respective generators + $settings = DraftSettingsFactory::make([ + 'numberOfPlayers' => 4, + ]); + $generator = new GenerateDraft($settings); + $draft = $generator->handle(); + + $this->assertNotEmpty($draft->slicePool); + $this->assertNotEmpty($draft->factionPool); + $this->assertEquals($draft->currentPlayerId, array_values($draft->players)[0]->id); + } + + #[Test] + public function itCanGeneratePlayerData(): void + { + $originalPlayerNames = ['Alice', 'Bob', 'Christine', 'David']; + $settings = DraftSettingsFactory::make([ + 'playerNames' => $originalPlayerNames, + ]); + $generator = new GenerateDraft($settings); + $draft = $generator->handle(); + + $playerIds = []; + $playerNames = []; + foreach($draft->players as $player) { + $playerIds[] = $player->id->value; + $playerNames[] = $player->name; + $this->assertFalse($player->claimed); + $this->assertNull($player->pickedFaction); + $this->assertNull($player->pickedSlice); + $this->assertNull($player->pickedPosition); + } + + $this->assertCount(count($originalPlayerNames), array_unique($playerIds)); + $this->assertCount(count($originalPlayerNames), array_unique($playerNames)); + } + + #[Test] + public function itCanGeneratePlayerDataInPresetOrder(): void + { + $originalPlayerNames = ['Alice', 'Bob', 'Christine', 'David']; + $settings = DraftSettingsFactory::make([ + 'playerNames' => $originalPlayerNames, + 'presetDraftOrder' => true, + ]); + $generator = new GenerateDraft($settings); + + $draft = $generator->handle(); + + $playerNames = []; + foreach($draft->players as $player) { + $playerNames[] = $player->name; + } + + $this->assertSame($originalPlayerNames, $playerNames); + unset($generator); + } + + #[Test] + public function itCanGeneratePlayerDataForAlliances(): void + { + $originalPlayerNames = ['Alice', 'Bob', 'Christine', 'David', 'Elliot', 'Frank']; + $settings = DraftSettingsFactory::make([ + 'playerNames' => $originalPlayerNames, + 'allianceMode' => true, + 'allianceTeamMode' => AllianceTeamMode::PRESET, + 'presetDraftOrder' => true, + ]); + $generator = new GenerateDraft($settings); + $draft = $generator->handle(); + + /** + * @var array $players + */ + $players = array_values($draft->players); + + $this->assertSame('Alice', $players[0]->name); + $this->assertSame('A', $players[0]->team); + $this->assertSame('Bob', $players[1]->name); + $this->assertSame('A', $players[1]->team); + $this->assertSame('Christine', $players[2]->name); + $this->assertSame('B', $players[2]->team); + $this->assertSame('David', $players[3]->name); + $this->assertSame('B', $players[3]->team); + $this->assertSame('Elliot', $players[4]->name); + $this->assertSame('C', $players[4]->team); + $this->assertSame('Frank', $players[5]->name); + $this->assertSame('C', $players[5]->team); + } +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateFactionPool.php b/app/Draft/Commands/GenerateFactionPool.php new file mode 100644 index 0000000..433e414 --- /dev/null +++ b/app/Draft/Commands/GenerateFactionPool.php @@ -0,0 +1,64 @@ +factionData = Faction::all(); + } + + /** + * @return array + */ + public function handle(): array + { + $this->settings->seed->setForFactions(); + $factionsFromSets = $this->gatherFactionsFromSelectedSets(); + + $gatheredFactions = []; + + if (! empty($this->settings->customFactions)) { + foreach ($this->settings->customFactions as $f) { + $gatheredFactions[] = $factionsFromSets[$f]; + // take out the selected faction, so it doesn't get re-drawn in the next part + unset($factionsFromSets[$f]); + } + + $factionsStillToGather = $this->settings->numberOfFactions - count($gatheredFactions); + if ($factionsStillToGather > 0) { + shuffle($factionsFromSets); + $gatheredFactions = array_merge($gatheredFactions, array_slice($factionsFromSets, 0, $factionsStillToGather)); + } + } else { + $gatheredFactions = $factionsFromSets; + } + + shuffle($gatheredFactions); + + return array_slice($gatheredFactions, 0, $this->settings->numberOfFactions); + } + + private function gatherFactionsFromSelectedSets(): array + { + return array_filter( + $this->factionData, + fn (Faction $faction) => + in_array($faction->edition, $this->settings->factionSets) || + $faction->name == 'The Council Keleres' && $this->settings->includeCouncilKeleresFaction, + ); + } +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateFactionPoolTest.php b/app/Draft/Commands/GenerateFactionPoolTest.php new file mode 100644 index 0000000..a0719b0 --- /dev/null +++ b/app/Draft/Commands/GenerateFactionPoolTest.php @@ -0,0 +1,113 @@ +assertInstanceOf(Command::class, $cmd); + } + + #[Test] + #[DataProviderExternal(TestSets::class, 'setCombinations')] + public function itCanGenerateChoicesFromFactionSets($sets): void + { + $generator = new GenerateFactionPool(DraftSettingsFactory::make([ + 'factionSets' => $sets, + 'numberOfFactions' => 10, + ])); + + $choices = $generator->handle(); + $choicesNames = array_map(fn (Faction $faction) => $faction->name, $choices); + + $this->assertCount(10, $choices); + $this->assertCount(10, array_unique($choicesNames)); + foreach($choices as $choice) { + $this->assertContains($choice->edition, $sets); + } + } + + #[Test] + public function itUsesOnlyCustomFactionsWhenEnoughAreProvided(): void + { + $customFactions = [ + 'The Barony of Letnev', + 'The Clan of Saar', + 'The Emirates of Hacan', + 'The Ghosts of Creuss', + ]; + $generator = new GenerateFactionPool(DraftSettingsFactory::make([ + 'customFactions' => $customFactions, + 'factionSets' => [Edition::BASE_GAME], + 'numberOfFactions' => 3, + ])); + + $choices = $generator->handle(); + + $this->assertCount(3, $choices); + foreach($choices as $choice) { + $this->assertContains($choice->name, $customFactions); + } + } + + #[Test] + public function itGeneratesTheSameFactionsFromTheSameSeed(): void + { + $generator = new GenerateFactionPool(DraftSettingsFactory::make([ + 'seed' => 123, + 'factionSets' => [Edition::BASE_GAME], + 'numberOfFactions' => 3, + ])); + $previouslyGeneratedChoices = [ + 'The Ghosts of Creuss', + 'The Emirates of Hacan', + 'The Yssaril Tribes', + ]; + + $choices = $generator->handle(); + + foreach($previouslyGeneratedChoices as $i => $name) { + $this->assertSame($name, $choices[$i]->name); + } + } + + #[Test] + public function itTakesFromSetsWhenNotEnoughCustomFactionsAreProvided(): void + { + $customFactions = [ + 'The Ghosts of Creuss', + 'The Emirates of Hacan', + 'The Yssaril Tribes', + ]; + $generator = new GenerateFactionPool(DraftSettingsFactory::make([ + 'factionSets' => [Edition::BASE_GAME], + 'customFactions' => $customFactions, + 'numberOfFactions' => 10, + ])); + + $choices = $generator->handle(); + $choicesNames = array_map(fn (Faction $faction) => $faction->name, $choices); + + foreach($customFactions as $f) { + $this->assertContains($f, $choicesNames); + } + + foreach($choices as $c) { + $this->assertEquals($c->edition, Edition::BASE_GAME); + } + } +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateSlicePool.php b/app/Draft/Commands/GenerateSlicePool.php new file mode 100644 index 0000000..2df6eab --- /dev/null +++ b/app/Draft/Commands/GenerateSlicePool.php @@ -0,0 +1,229 @@ + + */ + private readonly array $tileData; + + /** @var array */ + private readonly array $allGatheredTiles; + private readonly TilePool $gatheredTiles; + + public int $tries; + + public function __construct( + private readonly Settings $settings, + ) { + $this->tileData = Tile::all(); + + // make pre-selection based on tile sets + $this->allGatheredTiles = array_filter( + $this->tileData, + fn (Tile $tile) => + in_array($tile->edition, $this->settings->tileSets) && + // tier none is mec rex and such... + $tile->tier != TileTier::NONE, + ); + + // sort pre-selected tiles in tiers + $highTier = []; + $midTier = []; + $lowTier = []; + $redTier = []; + + foreach($this->allGatheredTiles as $tile) { + switch($tile->tier) { + case TileTier::HIGH: + $highTier[] = $tile->id; + + break; + case TileTier::MEDIUM: + $midTier[] = $tile->id; + + break; + case TileTier::LOW: + $lowTier[] = $tile->id; + + break; + case TileTier::RED: + $redTier[] = $tile->id; + + break; + }; + } + + $this->gatheredTiles = new TilePool( + $highTier, + $midTier, + $lowTier, + $redTier, + ); + } + + /** @return array */ + public function handle(): array + { + if (! empty($this->settings->customSlices)) { + return $this->slicesFromCustomSlices(); + } else { + return $this->attemptToGenerate(); + } + } + + private function attemptToGenerate($previousTries = 0): array + { + $slices = []; + + if ($previousTries > self::MAX_TILE_SELECTION_TRIES) { + throw InvalidDraftSettingsException::cannotGenerateSlices(); + } + + $this->settings->seed->setForSlices($previousTries); + $this->gatheredTiles->shuffle(); + $tilePool = $this->gatheredTiles->slice($this->settings->numberOfSlices); + + $tilePoolIsValid = $this->validateTileSelection($tilePool->allIds()); + + if (! $tilePoolIsValid) { + return $this->attemptToGenerate($previousTries + 1); + } + + $validSlicesFromPool = $this->makeSlicesFromPool($tilePool); + if (empty($validSlicesFromPool)) { + unset($validSlicesFromPool); + + return $this->attemptToGenerate($previousTries + 1); + } else { + return $validSlicesFromPool; + } + } + + private function makeSlicesFromPool(TilePool $pool, $previousTries = 0): array + { + if ($previousTries > self::MAX_SLICES_FROM_SELECTION_TRIES) { + return []; + } + + $this->settings->seed->setForSlices($previousTries); + $pool->shuffle(); + + $slices = []; + + for ($i = 0; $i < $this->settings->numberOfSlices; $i++) { + $slice = new Slice([ + $this->tileData[$pool->highTier[$i]], + $this->tileData[$pool->midTier[$i]], + $this->tileData[$pool->lowTier[$i]], + $this->tileData[$pool->redTier[$i * 2]], + $this->tileData[$pool->redTier[($i * 2) + 1]], + ]); + + $sliceIsValid = $slice->validate( + $this->settings->minimumOptimalInfluence, + $this->settings->minimumOptimalResources, + $this->settings->minimumOptimalTotal, + $this->settings->maximumOptimalTotal, + $this->settings->maxOneWormholesPerSlice, + ); + + if (! $sliceIsValid) { + unset($slice); + unset($slices); + + return $this->makeSlicesFromPool($pool, $previousTries + 1); + } + + if(! $slice->arrange($this->settings->seed)) { + unset($slice); + unset($slices); + + return $this->makeSlicesFromPool($pool, $previousTries); + } + + $slices[] = $slice; + } + + return $slices; + } + + /** + * @param array $tileIds + * @return bool + */ + private function validateTileSelection(array $tileIds): bool + { + $tileInfo = array_map(fn (string $id) => $this->tileData[$id], $tileIds); + + $alphaWormholeCount = 0; + $betaWormholeCount = 0; + $legendaryPlanetCount = 0; + + foreach($tileInfo as $t) { + if ($t->hasWormhole(Wormhole::ALPHA)) { + $alphaWormholeCount++; + } + if ($t->hasWormhole(Wormhole::BETA)) { + $betaWormholeCount++; + } + if ($t->hasLegendaryPlanet()) { + $legendaryPlanetCount++; + } + } + + if ($legendaryPlanetCount < $this->settings->minimumLegendaryPlanets) { + return false; + } + + if ( + $this->settings->minimumTwoAlphaAndBetaWormholes && + ($alphaWormholeCount < 2 || $betaWormholeCount < 2) + ) { + return false; + } + + return true; + } + + /** + * @return array + */ + private function slicesFromCustomSlices(): array + { + return array_map(function (array $sliceData) { + $tileData = array_map(fn ($tileId) => $this->tileData[$tileId], $sliceData); + + return new Slice($tileData); + }, $this->settings->customSlices); + } + + /** + * Debug and test methods + */ + public function gatheredTiles(): array + { + return $this->allGatheredTiles; + } + + public function gatheredTileTiers(): TilePool + { + return $this->gatheredTiles; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/GenerateSlicePoolTest.php b/app/Draft/Commands/GenerateSlicePoolTest.php new file mode 100644 index 0000000..40eca14 --- /dev/null +++ b/app/Draft/Commands/GenerateSlicePoolTest.php @@ -0,0 +1,272 @@ +assertInstanceOf(Command::class, $cmd); + } + + #[Test] + #[DataProviderExternal(TestSets::class, 'setCombinations')] + public function itGathersTheCorrectTiles($sets): void + { + $settings = DraftSettingsFactory::make([ + 'tileSets' => $sets, + ]); + $generator = new GenerateSlicePool($settings); + + $tiles = $generator->gatheredTiles(); + $tiers = $generator->gatheredTileTiers(); + $combinedTiers = count($tiers->allIds()); + + $this->assertSame(count($tiles), $combinedTiers); + foreach($tiles as $t) { + $this->assertContains($t->edition, $sets); + $this->assertNotEquals($t->tileType, TileType::GREEN); + if (! empty($t->planets)) { + $this->assertNotEquals($t->planets[0]->name, 'Mecatol Rex'); + $this->assertNotEquals($t->planets[0]->name, 'Mallice'); + } + } + } + + #[Test] + #[DataProviderExternal(TestSets::class, 'setCombinations')] + public function itCanGenerateValidSlicesBasedOnSets($sets): void + { + // we're doing this on easy mode so that the "Only base game" tile set has a decent chance of working + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 4, + 'tileSets' => $sets, + 'maxOneWormholePerSlice' => false, + 'minimumLegendaryPlanets' => 0, + 'minimumTwoAlphaBetaWormholes' => false, + ]); + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + $tileIds = array_reduce( + $slices, + fn ($allTiles, Slice $s) => array_merge( + $allTiles, + array_map(fn (Tile $t) => $t->id, $s->tiles), + ), + [], + ); + + $this->assertCount(4, $slices); + foreach($slices as $slice) { + $this->assertTrue($slice->validate( + $settings->minimumOptimalInfluence, + $settings->minimumOptimalResources, + $settings->minimumOptimalTotal, + $settings->maximumOptimalTotal, + $settings->maxOneWormholesPerSlice, + )); + } + } + + #[Test] + #[DataProviderExternal(TestSets::class, 'setCombinations')] + public function itDoesNotReuseTiles($sets): void + { + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 4, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'maxOneWormholePerSlice' => false, + 'minimumLegendaryPlanets' => 0, + 'minimumTwoAlphaBetaWormholes' => false, + ]); + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + $tileIds = array_reduce( + $slices, + fn($allTiles, Slice $s) => array_merge( + $allTiles, + array_map(fn(Tile $t) => $t->id, $s->tiles), + ), + [], + ); + + $this->assertSameSize($tileIds, array_unique($tileIds)); + } + + #[Test] + public function itGeneratesTheSameSlicesFromSameSeed(): void + { + $settings = DraftSettingsFactory::make([ + 'seed' => 123, + 'numberOfSlices' => 4, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS], + 'maxOneWormholePerSlice' => true, + 'minimumLegendaryPlanets' => 1, + 'minimumTwoAlphaBetaWormholes' => true, + 'minimumOptimalInfluence' => 4, + 'minimumOptimalResources' => 2.5, + 'minimumOptimalTotal' => 9, + 'maximumOptimalTotal' => 13, + ]); + + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + $generatedSlices = array_map(fn (Slice $slice) => $slice->tileIds(), $slices); + + $secondGenerator = new GenerateSlicePool($settings); + + $slices = $secondGenerator->handle(); + + foreach($slices as $sliceIndex => $slice) { + $this->assertSame($generatedSlices[$sliceIndex], $slice->tileIds()); + } + } + + #[Test] + public function itCanGenerateSlicesForDifficultSettings(): void + { + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 8, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'maxOneWormholePerSlice' => true, + 'minimumLegendaryPlanets' => 2, + 'minimumTwoAlphaBetaWormholes' => true, + 'minimumOptimalTotal' => 10, + 'maximumOptimalTotal' => 13, + ]); + + $generator = new GenerateSlicePool($settings); + $generator->handle(); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function itCanGenerateSlicesWithMinimumTwoAlphaAndBetaWormholes(): void + { + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 6, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS], + 'maxOneWormholePerSlice' => false, + 'minimumTwoAlphaBetaWormholes' => true, + ]); + + $this->assertTrue($settings->minimumTwoAlphaAndBetaWormholes); + + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + $alphaWormholeCount = 0; + $betaWormholeCount = 0; + foreach($slices as $slice) { + if ($slice->hasWormhole(Wormhole::ALPHA)) { + $alphaWormholeCount++; + } + if ($slice->hasWormhole(Wormhole::BETA)) { + $betaWormholeCount++; + } + } + + $this->assertGreaterThanOrEqual(2, $alphaWormholeCount); + $this->assertGreaterThanOrEqual(2, $betaWormholeCount); + } + + #[Test] + public function itCanGenerateSlicesWithMinimumAmountOfLegendaryPlanets(): void + { + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 6, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS], + 'minimumLegendaryPlanets' => 1, + ]); + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + $legendaryPlanetCount = 0; + foreach($slices as $slice) { + if ($slice->hasLegendary()) { + $legendaryPlanetCount++; + } + } + + $this->assertGreaterThanOrEqual(1, $legendaryPlanetCount); + } + + #[Test] + public function itCanGenerateSlicesWithMaxOneWormholePerSlice(): void + { + $settings = DraftSettingsFactory::make([ + 'numberOfSlices' => 6, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::DISCORDANT_STARS], + 'maxOneWormholePerSlice' => true, + ]); + $generator = new GenerateSlicePool($settings); + + $slices = $generator->handle(); + + foreach($slices as $slice) { + $this->assertLessThanOrEqual(1, count($slice->wormholes)); + } + } + + #[Test] + public function itCanReturnCustomSlices(): void + { + $customSlices = [ + ['64', '33', '42', '67', '59'], + ['29', '66', '20', '39', '47'], + ['27', '32', '79', '68', '19'], + ['35', '37', '22', '40', '50'], + ]; + + $generator = new GenerateSlicePool(DraftSettingsFactory::make([ + 'numberOfSlices' => 4, + 'customSlices' => $customSlices, + ])); + + $slices = $generator->handle(); + + foreach($slices as $sliceIndex => $slice) { + $this->assertSame($customSlices[$sliceIndex], $slice->tileIds()); + } + } + + #[Test] + public function itGivesUpIfSettingsAreImpossible(): void + { + $generator = new GenerateSlicePool(DraftSettingsFactory::make([ + 'numberOfSlices' => 4, + 'minimumOptimalInfluence' => 40, + ])); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::cannotGenerateSlices()->getMessage()); + + $generator->handle(); + } +} \ No newline at end of file diff --git a/app/Draft/Commands/PlayerPick.php b/app/Draft/Commands/PlayerPick.php new file mode 100644 index 0000000..3479c7b --- /dev/null +++ b/app/Draft/Commands/PlayerPick.php @@ -0,0 +1,43 @@ +draft->playerById($this->pick->playerId); + + if ($player->id->value !== $this->draft->currentPlayerId->value) { + throw InvalidPickException::notPlayersTurn(); + } + + foreach($this->draft->players as $p) { + if ($p->getPick($this->pick->category) == $this->pick->pickedOption) { + throw InvalidPickException::optionAlreadyPicked($this->pick->pickedOption); + } + } + + $this->draft->updatePlayerData($player->pick($this->pick)); + + $this->draft->log[] = $this->pick; + $this->draft->updateCurrentPlayer(); + + app()->repository->save($this->draft); + + return $this->draft; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/PlayerPickTest.php b/app/Draft/Commands/PlayerPickTest.php new file mode 100644 index 0000000..d217982 --- /dev/null +++ b/app/Draft/Commands/PlayerPickTest.php @@ -0,0 +1,130 @@ +testDraft->players)); + $cmd = new PlayerPick($this->testDraft, new Pick($playerId, PickCategory::SLICE, '1')); + $this->assertInstanceOf(Command::class, $cmd); + } + + public static function picks() + { + yield 'Picking position' => [ + 'category' => PickCategory::POSITION, + 'pick' => '1', + ]; + yield 'Picking faction' => [ + 'category' => PickCategory::FACTION, + 'pick' => 'Mahact', + ]; + yield 'Picking slice' => [ + 'category' => PickCategory::SLICE, + 'pick' => '4', + ]; + } + + #[Test] + #[DataProvider('picks')] + public function itCanPerformPick(PickCategory $category, string $pick): void + { + $playerId = $this->testDraft->currentPlayerId; + $pickCmd = new PlayerPick($this->testDraft, new Pick($playerId, $category, $pick)); + + $pickCmd->handle(); + + $this->reloadDraft(); + + $this->assertSame($pick, $this->testDraft->playerById($playerId)->getPick($category)); + } + + #[Test] + public function itThrowsAnErrorWhenItIsNotPlayersTurn(): void + { + $playerId = PlayerId::fromString(array_keys($this->testDraft->players)[1]); + $pickCmd = new PlayerPick($this->testDraft, new Pick($playerId, PickCategory::POSITION, '2')); + + $this->expectException(InvalidPickException::class); + + $pickCmd->handle(); + } + + #[Test] + #[DataProvider('picks')] + public function itSavesPickInLog(PickCategory $category, string $pick): void + { + $playerId = PlayerId::fromString(array_key_first($this->testDraft->players)); + + $pickVo = new Pick($playerId, $category, $pick); + + $pickCmd = new PlayerPick($this->testDraft, $pickVo); + $pickCmd->handle(); + + $this->reloadDraft(); + + $this->assertSame($pickVo->toArray(), $this->testDraft->log[count($this->testDraft->log) - 1]->toArray()); + } + + #[Test] + public function itUpdatesCurrentPlayer(): void + { + $player1Id = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + $player2Id = PlayerId::fromString(array_keys($this->testDraft->players)[1]); + $pickCmd = new PlayerPick( + $this->testDraft, + new Pick($player1Id, PickCategory::SLICE, '7'), + ); + $pickCmd->handle(); + + $this->reloadDraft(); + + $this->assertSame($this->testDraft->currentPlayerId->value, $player2Id->value); + } + + #[Test] + #[DataProvider('picks')] + public function itThrowsAnErrorWhenPickCategoryIsPicked(PickCategory $category, string $pick): void + { + $playerId = PlayerId::fromString(array_key_first($this->testDraft->players)); + $pickCmd = new PlayerPick($this->testDraft, new Pick($playerId, $category, $pick)); + $pickCmd->handle(); + + $this->expectException(InvalidPickException::class); + + $pickCmd->handle(); + } + + #[Test] + #[DataProvider('picks')] + public function itThrowsAnErrorWhenPickWasAlreadyPicked(PickCategory $category, string $pick): void + { + $player1Id = PlayerId::fromString(array_key_first($this->testDraft->players)); + $player2Id = PlayerId::fromString(array_key_last($this->testDraft->players)); + $pickCmd = new PlayerPick($this->testDraft, new Pick($player1Id, $category, $pick)); + $pickCmd->handle(); + + $this->expectException(InvalidPickException::class); + + $pick2Cmd = new PlayerPick($this->testDraft, new Pick($player2Id, $category, $pick)); + $pick2Cmd->handle(); + + } +} \ No newline at end of file diff --git a/app/Draft/Commands/RegenerateDraft.php b/app/Draft/Commands/RegenerateDraft.php new file mode 100644 index 0000000..324dbca --- /dev/null +++ b/app/Draft/Commands/RegenerateDraft.php @@ -0,0 +1,57 @@ +draft->log) > 0) { + throw new \Exception('Cannot regenerate ongoing draft'); + } + + // generate new seed to use for the reshuffle + $seed = new Seed(); + + if ($this->regenerateOrder) { + $seed->setForPlayerOrder(); + $order = array_keys($this->draft->players); + shuffle($order); + $newPlayers = []; + foreach ($order as $key) { + $newPlayers[$key] = $this->draft->players[$key]; + } + $this->draft->players = $newPlayers; + $this->draft->updateCurrentPlayer(); + } + + if ($this->regenerateSlices) { + $slices = (new GenerateSlicePool($this->draft->settings->withNewSeed($seed)))->handle(); + $this->draft->slicePool = $slices; + } + + if ($this->regenerateFactions) { + $factions = (new GenerateFactionPool($this->draft->settings->withNewSeed($seed)))->handle(); + $this->draft->factionPool = $factions; + } + + app()->repository->save($this->draft); + + return $this->draft; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/RegenerateDraftTest.php b/app/Draft/Commands/RegenerateDraftTest.php new file mode 100644 index 0000000..0bc33c0 --- /dev/null +++ b/app/Draft/Commands/RegenerateDraftTest.php @@ -0,0 +1,96 @@ +testDraft, true, true, false); + $this->assertInstanceOf(Command::class, $cmd); + } + + public static function options() { + yield 'When regenerating slices' => [ + 'slices' => true, + 'factions' => false, + 'order' => false, + ]; + yield 'When regenerating factions' => [ + 'slices' => false, + 'factions' => true, + 'order' => false, + ]; + yield 'When regenerating order' => [ + 'slices' => false, + 'factions' => false, + 'order' => true, + ]; + yield 'When regenerating everything' => [ + 'slices' => true, + 'factions' => true, + 'order' => true, + ]; + } + + #[Test] + #[DataProvider('options')] + public function itCanRegenerateDraft(bool $slices, bool $factions, bool $order): void + { + $oldSlices = array_map(fn (Slice $slice) => $slice->tileIds(), $this->testDraft->slicePool); + $oldFactions = array_map(fn (Faction $faction) => $faction->name, $this->testDraft->factionPool); + $oldOrder = array_keys($this->testDraft->players); + + $cmd = new RegenerateDraft($this->testDraft, $slices, $factions, $order); + $cmd->handle(); + + $this->reloadDraft(); + + $newSlices = array_map(fn (Slice $slice) => $slice->tileIds(), $this->testDraft->slicePool); + $newFactions = array_map(fn (Faction $faction) => $faction->name, $this->testDraft->factionPool); + $newOrder = array_keys($this->testDraft->players); + + if ($slices) { + $this->assertNotSame($oldSlices, $newSlices); + } else { + $this->assertSame($oldSlices, $newSlices); + } + + if ($factions) { + $this->assertNotSame($oldFactions, $newFactions); + } else { + $this->assertSame($oldFactions, $newFactions); + } + + if ($order) { + $this->assertEqualsCanonicalizing($oldOrder, $newOrder); + $this->assertNotEquals($oldOrder, $newOrder); + } else { + $this->assertSame($oldOrder, $newOrder); + } + } + + #[Test] + public function itUpdatesCurrentPlayerWhenRegeneratingPlayerOrder(): void + { + $cmd = new RegenerateDraft($this->testDraft, false, false, true); + + $cmd->handle(); + $this->reloadDraft(); + + $this->assertSame($this->testDraft->currentPlayerId->value, array_key_first($this->testDraft->players)); + } +} \ No newline at end of file diff --git a/app/Draft/Commands/UnclaimPlayer.php b/app/Draft/Commands/UnclaimPlayer.php new file mode 100644 index 0000000..1ad4854 --- /dev/null +++ b/app/Draft/Commands/UnclaimPlayer.php @@ -0,0 +1,30 @@ +player = $this->draft->playerById($this->playerId); + } + + public function handle(): void + { + $this->draft->updatePlayerData($this->player->unclaim()); + $this->draft->secrets->removeSecretForPlayer($this->playerId); + + app()->repository->save($this->draft); + } +} \ No newline at end of file diff --git a/app/Draft/Commands/UnclaimPlayerTest.php b/app/Draft/Commands/UnclaimPlayerTest.php new file mode 100644 index 0000000..4c9a716 --- /dev/null +++ b/app/Draft/Commands/UnclaimPlayerTest.php @@ -0,0 +1,57 @@ +testDraft->players)); + + $this->testDraft->players[$playerId->value] = $this->testDraft->playerById($playerId)->claim(); + $this->testDraft->secrets->generateSecretForPlayer($playerId); + + $unclaimPlayer = new UnclaimPlayer($this->testDraft, $playerId); + $unclaimPlayer->handle(); + + // check to see if changes were saved + $this->reloadDraft(); + + $this->assertFalse($this->testDraft->playerById($playerId)->claimed); + $this->assertNull($this->testDraft->secrets->secretById($playerId)); + + } + + #[Test] + public function itThrowsAnErrorIfPlayerIsNotPartOfDraft(): void + { + $playerId = PlayerId::fromString('123'); + + $this->expectException(\Exception::class); + $claimPlayer = new ClaimPlayer($this->testDraft, $playerId); + $claimPlayer->handle(); + } + + #[Test] + public function itThrowsAnErrorIfPlayerIsNotClaimed(): void + { + $playerId = PlayerId::fromString(array_key_first($this->testDraft->players)); + + $unclaim = new UnclaimPlayer($this->testDraft, $playerId); + + $this->expectException(InvalidClaimException::class); + $unclaim->handle(); + } + +} \ No newline at end of file diff --git a/app/Draft/Commands/UndoLastPick.php b/app/Draft/Commands/UndoLastPick.php new file mode 100644 index 0000000..02ec4c6 --- /dev/null +++ b/app/Draft/Commands/UndoLastPick.php @@ -0,0 +1,33 @@ +draft->log)) { + throw new \Exception('Cannot undo pick, draft has not started'); + } + + $lastPick = array_pop($this->draft->log); + $player = $this->draft->playerById($lastPick->playerId); + $this->draft->updatePlayerData($player->unpick($lastPick->category)); + + $this->draft->updateCurrentPlayer(); + + app()->repository->save($this->draft); + + return $this->draft; + } +} \ No newline at end of file diff --git a/app/Draft/Commands/UndoLastPickTest.php b/app/Draft/Commands/UndoLastPickTest.php new file mode 100644 index 0000000..198d7cc --- /dev/null +++ b/app/Draft/Commands/UndoLastPickTest.php @@ -0,0 +1,128 @@ +testDraft); + $this->assertInstanceOf(Command::class, $cmd); + } + + protected function makeTwoPicks(): void + { + $player1Id = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + $player2Id = PlayerId::fromString(array_keys($this->testDraft->players)[1]); + + (new PlayerPick( + $this->testDraft, + new Pick($player1Id, PickCategory::SLICE, '4'), + ) + )->handle(); + (new PlayerPick( + $this->testDraft, + new Pick($player2Id, PickCategory::SLICE, '3'), + ) + )->handle(); + } + + #[Test] + public function itCanUndoLastPick(): void + { + $player2Id = PlayerId::fromString(array_keys($this->testDraft->players)[1]); + $this->makeTwoPicks(); + $cmd = new UndoLastPick($this->testDraft); + + $cmd->handle(); + $this->reloadDraft(); + + $this->assertNull($this->testDraft->playerById($player2Id)->pickedSlice); + } + + #[Test] + public function itThrowsAnErrorWhenNothingHasBeenPicked(): void + { + $cmd = new UndoLastPick($this->testDraft); + + $this->expectException(\Exception::class); + + $cmd->handle(); + } + + #[Test] + public function itUpdatesCurrentPlayer(): void + { + $player2Id = PlayerId::fromString(array_keys($this->testDraft->players)[1]); + $this->makeTwoPicks(); + $cmd = new UndoLastPick($this->testDraft); + + $cmd->handle(); + $this->reloadDraft(); + + $this->assertSame($this->testDraft->currentPlayerId->value, $player2Id->value); + } + + #[Test] + public function itRemovesPickFromLog(): void + { + $player1Id = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + $this->makeTwoPicks(); + $cmd = new UndoLastPick($this->testDraft); + + $cmd->handle(); + $this->reloadDraft(); + + $this->assertCount(1, $this->testDraft->log); + $this->assertSame($this->testDraft->log[0]->playerId->value, $player1Id->value); + } + + #[Test] + public function itSetsTheDraftToUndoneIfUndoingLastPick(): void + { + $order = array_merge( + array_keys($this->testDraft->players), + array_keys(array_reverse($this->testDraft->players)), + array_keys($this->testDraft->players), + ); + + foreach($order as $currentPlayer) { + $pick = new Pick($this->testDraft->currentPlayerId, PickCategory::FACTION, 'foo'); + $this->testDraft->log[] = $pick; + $this->testDraft->updateCurrentPlayer(); + } + + $this->assertTrue($this->testDraft->isDone); + $this->assertNull($this->testDraft->currentPlayerId); + + // update player pick so the undo command doesn't complain + $lastPlayer = PlayerId::fromString(array_key_last($this->testDraft->players)); + $this->testDraft->updatePlayerData($this->testDraft->playerById($lastPlayer)->pick( + new Pick( + $lastPlayer, + PickCategory::FACTION, + 'foo', + ), + )); + + $cmd = new UndoLastPick($this->testDraft); + $cmd->handle(); + $this->reloadDraft(); + + $this->assertFalse($this->testDraft->isDone); + $this->assertNotNull($this->testDraft->currentPlayerId); + } +} \ No newline at end of file diff --git a/app/Draft/Draft.php b/app/Draft/Draft.php new file mode 100644 index 0000000..57b1cc2 --- /dev/null +++ b/app/Draft/Draft.php @@ -0,0 +1,150 @@ + $players */ + public array $players, + public Settings $settings, + public Secrets $secrets, + /** @var array $slicePool */ + public array $slicePool, + /** @var array $factionPool */ + public array $factionPool, + /** @var array $log */ + public array $log = [], + public ?PlayerId $currentPlayerId = null, + ) { + } + + public static function fromJson($data) + { + /** + * @var array + */ + $players = array_reduce($data['draft']['players'], function ($players, $playerData) { + $player = Player::fromJson($playerData); + $players[$player->id->value] = $player; + + return $players; + }, []); + + return new self( + $data['id'], + $data['done'], + $players, + Settings::fromJson($data['config']), + Secrets::fromJson($data['secrets']), + self::slicesFromJson($data['slices']), + self::factionsFromJson($data['factions']), + array_map(fn ($logData) => Pick::fromJson($logData), $data['draft']['log']), + $data['draft']['current'] != null ? PlayerId::fromString($data['draft']['current']) : null, + ); + } + + /** + * @return array + */ + private static function slicesFromJson($slicesData): array + { + $allTiles = Tile::all(); + + return array_map(function (array $sliceData) use ($allTiles) { + $tiles = array_map( + fn (string|int $tileId) => $allTiles[$tileId], + $sliceData['tiles'], + ); + + return new Slice($tiles); + }, $slicesData); + } + + /** + * @return array + */ + private static function factionsFromJson($factionNames): array + { + $allFactions = Faction::all(); + + return array_map(function (string $name) use ($allFactions) { + return $allFactions[$name]; + }, $factionNames); + } + + public function toFileContent(): string + { + return json_encode($this->toArray(true)); + } + + public function toArray($includeSecrets = false): array + { + $data = [ + 'id' => $this->id, + 'done' => $this->isDone, + 'config' => $this->settings->toArray(), + 'draft' => [ + 'players' => array_map(fn (Player $player) => $player->toArray(), $this->players), + 'log' => array_map(fn (Pick $pick) => $pick->toArray(), $this->log), + 'current' => $this->currentPlayerId?->value, + ], + 'factions' => array_map(fn (Faction $f) => $f->name, $this->factionPool), + 'slices' => array_map(fn (Slice $s) => ['tiles' => $s->tileIds()], $this->slicePool), + ]; + + if ($includeSecrets) { + $data['secrets'] = $this->secrets->toArray(); + } + + return $data; + } + + public function updateCurrentPlayer(): void + { + $doneSteps = count($this->log); + $snakeDraft = array_merge(array_keys($this->players), array_keys(array_reverse($this->players))); + + if (count($this->log) >= (count($this->players) * 3)) { + $this->isDone = true; + $this->currentPlayerId = null; + } else { + $this->isDone = false; + $this->currentPlayerId = PlayerId::fromString($snakeDraft[$doneSteps % count($snakeDraft)]); + } + } + + public function canRegenerate(): bool + { + return empty($this->log); + } + + public function playerById(PlayerId $id): Player + { + foreach ($this->players as $p) { + if ($p->id->equals($id)) { + return $p; + } + } + + throw new \Exception('No player found with id ' . $id->value); + } + + public function updatePlayerData(Player $newPlayerData): void + { + if (! isset($this->players[$newPlayerData->id->value])) { + throw new \Exception('No player found with id ' . $newPlayerData->id->value); + } + + $this->players[$newPlayerData->id->value] = $newPlayerData; + } + +} \ No newline at end of file diff --git a/app/Draft/DraftId.php b/app/Draft/DraftId.php new file mode 100644 index 0000000..b79888f --- /dev/null +++ b/app/Draft/DraftId.php @@ -0,0 +1,17 @@ +assertNotEmpty($draft->players); + $this->assertSame($data['config']['name'], (string) $draft->settings->name); + $this->assertSame($draft->id, $data['id']); + $this->assertSame($draft->isDone, $data['done']); + + $factionPoolNames = array_map(fn (Faction $f) => $f->name, $draft->factionPool); + + foreach($data['factions'] as $faction) { + $this->assertContains($faction, $factionPoolNames); + } + $this->assertSame($draft->currentPlayerId->value, $data['draft']['current']); + } + + #[Test] + public function itCanBeConvertedToArray(): void + { + $factions = Faction::all(); + $tiles = Tile::all(); + $player = new Player( + PlayerId::fromString('player_123'), + 'Alice', + ); + $draft = new Draft( + '1243', + true, + [$player->id->value => $player], + DraftSettingsFactory::make(), + new Secrets( + 'secret123', + ), + [ + new Slice([ + $tiles['64'], + $tiles['33'], + $tiles['42'], + $tiles['67'], + $tiles['59'], + ]), + ], + [ + $factions['The Barony of Letnev'], + $factions['The Embers of Muaat'], + $factions['The Clan of Saar'], + ], + [new Pick($player->id, PickCategory::FACTION, 'Vulraith')], + $player->id, + ); + + $data = $draft->toArray(); + + $this->assertSame($draft->settings->toArray(), $data['config']); + $this->assertSame($draft->id, $data['id']); + $this->assertSame($draft->isDone, $data['done']); + $this->assertSame($player->name, $data['draft']['players'][$player->id->value]['name']); + $this->assertSame($player->id->value, $data['draft']['current']); + $this->assertSame('Vulraith', $data['draft']['log'][0]['value']); + foreach($draft->factionPool as $faction) { + $this->assertContains($faction->name, $data['factions']); + } + foreach($draft->slicePool as $slice) { + $this->assertContains(['tiles' => $slice->tileIds()], $data['slices']); + } + } + + #[Test] + public function itCanUpdatePlayerData(): void + { + $draft = (new GenerateDraft(DraftSettingsFactory::make()))->handle(); + + $playerId = PlayerId::fromString(array_keys($draft->players)[3]); + + $player = $draft->playerById($playerId); + + $newPlayerData = $player->pick(new Pick($playerId, PickCategory::FACTION, 'Xxcha')); + + $draft->updatePlayerData($newPlayerData); + + $this->assertSame($draft->playerById($playerId)->toArray(), $newPlayerData->toArray()); + $this->assertSame($draft->playerById($playerId)->getPick(PickCategory::FACTION), 'Xxcha'); + } + + #[Test] + public function itCanUpdateCurrentPlayerInSnakeDraft(): void + { + $draft = (new GenerateDraft(DraftSettingsFactory::make()))->handle(); + + // the order for three rounds: Regular + Reverse + Regular + $order = array_merge(array_keys($draft->players), array_keys(array_reverse($draft->players)), array_keys($draft->players)); + + foreach($order as $expectedCurrentPlayer) { + $this->assertSame($draft->currentPlayerId->value, $expectedCurrentPlayer); + $draft->log[] = new Pick($draft->currentPlayerId, PickCategory::FACTION, 'foo'); + $draft->updateCurrentPlayer(); + } + + // it sets the draft to done at the end + $this->assertNull($draft->currentPlayerId); + $this->assertTrue($draft->isDone); + } +} \ No newline at end of file diff --git a/app/Draft/Exceptions/DraftRepositoryException.php b/app/Draft/Exceptions/DraftRepositoryException.php new file mode 100644 index 0000000..46253c6 --- /dev/null +++ b/app/Draft/Exceptions/DraftRepositoryException.php @@ -0,0 +1,13 @@ +value); + } + + public static function optionAlreadyPicked($value) + { + return new self('Other player has already picked ' . $value); + } + + public static function cannotUnpick(PickCategory $category) + { + return new self('Cannot undo pick: Player has not picked ' . $category->value); + } + + public static function notPlayersTurn() + { + return new self("It's not your turn!"); + } +} \ No newline at end of file diff --git a/app/Draft/Name.php b/app/Draft/Name.php new file mode 100644 index 0000000..71d5942 --- /dev/null +++ b/app/Draft/Name.php @@ -0,0 +1,50 @@ +name = $this->generate(); + } else { + $this->name = htmlentities(trim($submittedName)); + } + } + + public function __toString() + { + return $this->name; + } + + protected function generate(): string + { + $adjectives = [ + 'adventurous', 'aggressive', 'angry', 'arrogant', 'beautiful', 'bloody', 'blushing', 'brave', + 'clever', 'clumsy', 'combative', 'confused', 'crazy', 'curious', 'defiant', 'difficult', 'disgusted', 'doubtful', 'easy', + 'famous', 'fantastic', 'filthy', 'frightened', 'funny', 'glamorous', 'gleaming', 'glorious', + 'grumpy', 'homeless', 'hilarious', 'impossible', 'itchy', 'imperial', 'jealous', 'long', 'magnificent', 'lucky', + 'modern', 'mysterious', 'naughty', 'old-fashioned', 'outstanding', 'outrageous', 'perfect', + 'poisoned', 'puzzled', 'rich', 'smiling', 'super', 'tasty', 'terrible', 'wandering', 'zealous', + ]; + $nouns = [ + 'people', 'history', 'art', 'world', 'space', 'universe', 'galaxy', 'story', + 'map', 'game', 'family', 'government', 'system', 'method', 'computer', 'problem', + 'theory', 'law', 'power', 'knowledge', 'control', 'ability', 'love', 'science', + 'fact', 'idea', 'area', 'society', 'industry', 'player', 'security', 'country', + 'equipment', 'analysis', 'policy', 'thought', 'strategy', 'direction', 'technology', + 'army', 'fight', 'war', 'freedom', 'failure', 'night', 'day', 'energy', 'nation', + 'moment', 'politics', 'empire', 'president', 'council', 'effort', 'situation', + 'resource', 'influence', 'agreement', 'union', 'religion', 'virus', 'republic', + 'drama', 'tension', 'suspense', 'friendship', 'twilight', 'imperium', 'leadership', + 'operation', 'disaster', 'leader', 'speaker', 'diplomacy', 'politics', 'warfare', 'construction', + 'trade', 'proposal', 'revolution', 'negotiation', + ]; + + return 'Operation ' . ucfirst($adjectives[rand(0, count($adjectives) - 1)]) . ' ' . ucfirst($nouns[rand(0, count($nouns) - 1)]); + } +} \ No newline at end of file diff --git a/app/Draft/Pick.php b/app/Draft/Pick.php new file mode 100644 index 0000000..3688d91 --- /dev/null +++ b/app/Draft/Pick.php @@ -0,0 +1,33 @@ + $this->playerId->value, + 'category' => $this->category->value, + 'value' => $this->pickedOption, + ]; + } +} \ No newline at end of file diff --git a/app/Draft/PickCategory.php b/app/Draft/PickCategory.php new file mode 100644 index 0000000..f3e491e --- /dev/null +++ b/app/Draft/PickCategory.php @@ -0,0 +1,11 @@ + [ + 'pickData' => [ + 'player' => '1234', + 'category' => 'faction', + 'value' => 'Xxcha', + ], + ]; + + yield 'For a position pick' => [ + 'pickData' => [ + 'player' => '1234', + 'category' => 'position', + 'value' => '4', + ], + ]; + + yield 'For a slice pick' => [ + 'pickData' => [ + 'player' => '1234', + 'category' => 'slice', + 'value' => '1', + ], + ]; + } + + #[Test] + #[DataProvider('pickCases')] + public function itCanConvertFromJsonData($pickData): void + { + $pick = Pick::fromJson($pickData); + + $this->assertSame($pick->pickedOption, $pickData['value']); + $this->assertSame($pick->category->value, $pickData['category']); + $this->assertSame($pick->playerId->value, $pickData['player']); + } +} \ No newline at end of file diff --git a/app/Draft/Player.php b/app/Draft/Player.php new file mode 100644 index 0000000..8437daa --- /dev/null +++ b/app/Draft/Player.php @@ -0,0 +1,175 @@ +id, + $this->name, + $this->claimed, + $this->pickedPosition, + $this->pickedFaction, + $this->pickedSlice, + $team, + ); + } + + public function unclaim(): Player + { + if (! $this->claimed) { + throw InvalidClaimException::playerNotClaimed(); + } + + return new self( + $this->id, + $this->name, + false, + $this->pickedPosition, + $this->pickedFaction, + $this->pickedSlice, + $this->team, + ); + } + + public function claim(): Player + { + if ($this->claimed) { + throw InvalidClaimException::playerAlreadyClaimed(); + } + + return new self( + $this->id, + $this->name, + true, + $this->pickedPosition, + $this->pickedFaction, + $this->pickedSlice, + $this->team, + ); + } + + public function toArray(): array + { + return [ + 'id' => $this->id->value, + 'name' => $this->name, + 'claimed' => $this->claimed, + 'position' => $this->pickedPosition, + 'faction' => $this->pickedFaction, + 'slice' => $this->pickedSlice, + 'team' => $this->team, + ]; + } + + public function hasPickedSlice(): bool + { + return $this->pickedSlice != null; + } + + public function hasPickedFaction(): bool + { + return $this->pickedFaction != null; + } + + public function hasPickedPosition(): bool + { + return $this->pickedPosition != null; + } + + public function getPick(PickCategory $category): ?string + { + return match($category) { + PickCategory::POSITION => $this->pickedPosition, + PickCategory::SLICE => $this->pickedSlice, + PickCategory::FACTION => $this->pickedFaction, + }; + } + + public function hasPicked(PickCategory $category): bool + { + return match($category) { + PickCategory::FACTION => $this->hasPickedFaction(), + PickCategory::SLICE => $this->hasPickedSlice(), + PickCategory::POSITION => $this->hasPickedPosition(), + }; + } + + public function pick(Pick $pick): Player + { + if ($this->hasPicked($pick->category)) { + throw InvalidPickException::playerHasAlreadyPicked($pick->category); + } + + return new self( + $this->id, + $this->name, + $this->claimed, + $pick->category == PickCategory::POSITION ? $pick->pickedOption : $this->pickedPosition, + $pick->category == PickCategory::FACTION ? $pick->pickedOption : $this->pickedFaction, + $pick->category == PickCategory::SLICE ? $pick->pickedOption : $this->pickedSlice, + $this->team, + ); + } + + public function unpick(PickCategory $category): Player + { + if (! $this->hasPicked($category)) { + throw InvalidPickException::cannotUnpick($category); + } + + return new self( + $this->id, + $this->name, + $this->claimed, + $category == PickCategory::POSITION ? null : $this->pickedPosition, + $category == PickCategory::FACTION ? null : $this->pickedFaction, + $category == PickCategory::SLICE ? null : $this->pickedSlice, + $this->team, + ); + } +} \ No newline at end of file diff --git a/app/Draft/PlayerId.php b/app/Draft/PlayerId.php new file mode 100644 index 0000000..781733b --- /dev/null +++ b/app/Draft/PlayerId.php @@ -0,0 +1,17 @@ +assertSame($playerData['id'], $p->id->value); + $this->assertSame($playerData['name'], $p->name); + $this->assertSame($playerData['faction'], $p->pickedFaction); + $this->assertSame($playerData['slice'], $p->pickedSlice); + $this->assertSame($playerData['position'], $p->pickedPosition); + } + } + + #[Test] + public function itChecksIfPlayerHasPickedPosition(): void + { + $player1 = new Player(PlayerId::fromString('1'), 'Alice', false, '1'); + $player2 = new Player(PlayerId::fromString('2'), 'Bob'); + + $this->assertTrue($player1->hasPickedPosition()); + $this->assertFalse($player2->hasPickedPosition()); + } + + #[Test] + public function itChecksIfPlayerHasPickedFaction(): void + { + $player1 = new Player(PlayerId::fromString('1'), 'Alice', false, null, 'Mahact'); + $player2 = new Player(PlayerId::fromString('2'), 'Bob'); + + $this->assertTrue($player1->hasPickedFaction()); + $this->assertFalse($player2->hasPickedFaction()); + } + + #[Test] + public function itChecksIfPlayerHasPickedSlice(): void + { + $player1 = new Player(PlayerId::fromString('1'), 'Alice', false, null, null, '1'); + $player2 = new Player(PlayerId::fromString('2'), 'Bob'); + + $this->assertTrue($player1->hasPickedSlice()); + $this->assertFalse($player2->hasPickedSlice()); + } + + #[Test] + public function itCanBeConvertedToAnArray(): void + { + $player1 = new Player( + PlayerId::fromString('1'), + 'Alice', + true, + '2', + 'Mahact', + '3', + 'A', + ); + $player2 = new Player(PlayerId::fromString('2'), 'Bob'); + + $this->assertSame([ + 'id' => '1', + 'name' => 'Alice', + 'claimed' => true, + 'position' => '2', + 'faction' => 'Mahact', + 'slice' => '3', + 'team' => 'A', + ], $player1->toArray()); + $this->assertSame([ + 'id' => '2', + 'name' => 'Bob', + 'claimed' => false, + 'position' => null, + 'faction' => null, + 'slice' => null, + 'team' => null, + ], $player2->toArray()); + } + + public static function picks(): iterable + { + yield 'When picking slice' => [ + 'category' => PickCategory::SLICE, + ]; + + yield 'When picking position' => [ + 'category' => PickCategory::POSITION, + ]; + + yield 'When picking faction' => [ + 'category' => PickCategory::FACTION, + ]; + } + + #[Test] + #[DataProvider('picks')] + public function itCanPickSomething($category): void + { + $player = new Player( + PlayerId::fromString('1'), + 'Alice', + true, + $category == PickCategory::POSITION ? null : '2', + $category == PickCategory::FACTION ? null : 'Mahact', + $category == PickCategory::SLICE ? null : '3', + 'A', + ); + + $newPlayerVo = $player->pick(new Pick(PlayerId::fromString('1'), $category, 'some-value')); + + $this->assertSame($player->id->value, $newPlayerVo->id->value); + $this->assertSame($player->name, $newPlayerVo->name); + $this->assertSame($player->claimed, $newPlayerVo->claimed); + $this->assertSame($player->team, $newPlayerVo->team); + + switch($category) { + case PickCategory::POSITION: + $this->assertSame($player->pickedSlice, $newPlayerVo->pickedSlice); + $this->assertSame($player->pickedFaction, $newPlayerVo->pickedFaction); + $this->assertSame('some-value', $newPlayerVo->pickedPosition); + + break; + case PickCategory::FACTION: + $this->assertSame($player->pickedSlice, $newPlayerVo->pickedSlice); + $this->assertSame($player->pickedPosition, $newPlayerVo->pickedPosition); + $this->assertSame('some-value', $newPlayerVo->pickedFaction); + + break; + case PickCategory::SLICE: + $this->assertSame($player->pickedPosition, $newPlayerVo->pickedPosition); + $this->assertSame($player->pickedFaction, $newPlayerVo->pickedFaction); + $this->assertSame('some-value', $newPlayerVo->pickedSlice); + + break; + } + } +} \ No newline at end of file diff --git a/app/Draft/Repository/DraftRepository.php b/app/Draft/Repository/DraftRepository.php new file mode 100644 index 0000000..0ef833d --- /dev/null +++ b/app/Draft/Repository/DraftRepository.php @@ -0,0 +1,14 @@ +storagePath = env('STORAGE_PATH'); + } + + private function pathToDraft(string $draftId) + { + return $this->storagePath . '/' . 'draft_' . $draftId . '.json'; + } + + public function load(string $id): Draft + { + $path = $this->pathToDraft($id); + + if(! file_exists($path)) { + throw DraftRepositoryException::notFound($id); + } + + $rawDraft = json_decode(file_get_contents($path), true); + + return Draft::fromJson($rawDraft); + } + + public function save(Draft $draft): void + { + file_put_contents($this->pathToDraft($draft->id), $draft->toFileContent()); + } + + public function delete(string $id): void + { + unlink($this->pathToDraft($id)); + } +} \ No newline at end of file diff --git a/app/Draft/Repository/LocalDraftRepositoryTest.php b/app/Draft/Repository/LocalDraftRepositoryTest.php new file mode 100644 index 0000000..4d0d7c7 --- /dev/null +++ b/app/Draft/Repository/LocalDraftRepositoryTest.php @@ -0,0 +1,61 @@ +save($d); + + $this->draftsToCleanUp[] = $d->id; + + $draftPath = 'tmp/test-drafts/draft_' . $d->id . '.json'; + + $this->assertFileExists($draftPath); + $content = json_decode(file_get_contents($draftPath)); + + $this->assertSame($content->id, $d->id); + $this->assertSame($content->draft->current, $d->currentPlayerId->value); + } + + #[Test] + #[DataProviderExternal(TestDrafts::class, 'provideSingleTestDraft')] + public function itCanLoadADraft($data): void + { + $draftPath = 'tmp/test-drafts/draft_' . $data['id'] . '.json'; + file_put_contents($draftPath, json_encode($data)); + $this->draftsToCleanUp[] = $data['id']; + $repository = new LocalDraftRepository(); + + $draft = $repository->load($data['id']); + + $this->assertSame($draft->id, $data['id']); + $this->assertSame($draft->currentPlayerId->value, $data['draft']['current']); + } + + #[After] + protected function cleanupAfterTests(): void + { + $r = new LocalDraftRepository(); + foreach ($this->draftsToCleanUp as $id) { + $r->delete($id); + } + } +} \ No newline at end of file diff --git a/app/Draft/Repository/S3DraftRepository.php b/app/Draft/Repository/S3DraftRepository.php new file mode 100644 index 0000000..83bcae4 --- /dev/null +++ b/app/Draft/Repository/S3DraftRepository.php @@ -0,0 +1,69 @@ +client = new S3Client([ + 'version' => 'latest', + // @todo fix this? + 'region' => 'us-east-1', + 'endpoint' => 'https://' . env('REGION') . '.digitaloceanspaces.com', + 'credentials' => [ + 'key' => env('ACCESS_KEY'), + 'secret' => env('ACCESS_SECRET'), + ], + ]); + $this->bucket = env('BUCKET'); + } + + protected function draftKey(string $id): string + { + return 'draft_' . $id . '.json'; + } + + public function load(string $id): Draft + { + if (! $this->client->doesObjectExist($this->bucket, $this->draftKey($id))) { + throw DraftRepositoryException::notFound($id); + } + + $file = $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->draftKey($id), + ]); + + $rawDraft = (string) $file['Body']; + + return Draft::fromJson(json_decode($rawDraft, true)); + } + + public function save(Draft $draft): void + { + $this->client->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->draftKey($draft->id), + 'Body' => $draft->toFileContent(), + 'ACL' => 'private', + ]); + } + + public function delete(string $id): void + { + $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->draftKey($id), + ]); + } +} \ No newline at end of file diff --git a/app/Draft/Repository/S3DraftRepositoryTest.php b/app/Draft/Repository/S3DraftRepositoryTest.php new file mode 100644 index 0000000..9408d6e --- /dev/null +++ b/app/Draft/Repository/S3DraftRepositoryTest.php @@ -0,0 +1,20 @@ +expectNotToPerformAssertions(); + } +} \ No newline at end of file diff --git a/app/Draft/Secrets.php b/app/Draft/Secrets.php new file mode 100644 index 0000000..c75df41 --- /dev/null +++ b/app/Draft/Secrets.php @@ -0,0 +1,87 @@ + $playerSecrets + */ + public array $playerSecrets = [], + ) { + } + + public function toArray(): array + { + return [ + self::ADMIN_SECRET_KEY => $this->adminSecret, + ...$this->playerSecrets, + ]; + } + + public static function generateSecret(): string + { + return bin2hex(random_bytes(8)); + } + + public function generateSecretForPlayer(PlayerId $playerId): string + { + $secret = self::generateSecret(); + $this->playerSecrets[$playerId->value] = $secret; + + return $secret; + } + + public function removeSecretForPlayer(PlayerId $playerId): void + { + unset($this->playerSecrets[$playerId->value]); + } + + public function secretById(PlayerId $playerId): ?string + { + return $this->playerSecrets[$playerId->value] ?? null; + } + + public function checkAdminSecret(?string $secret): bool { + if ($secret == null) { + return false; + } + + return $secret == $this->adminSecret; + } + + public function playerIdBySecret(string $secret): ?PlayerId { + foreach($this->playerSecrets as $key => $playerSecret) { + if ($playerSecret == $secret) { + return PlayerId::fromString($key); + } + } + + return null; + } + + public function checkPlayerSecret(PlayerId $id, ?string $secret): bool { + if ($secret == null) { + return false; + } + + return isset($this->playerSecrets[$id->value]) && $secret == $this->playerSecrets[$id->value]; + } + + public static function fromJson($data): self + { + return new self( + $data[self::ADMIN_SECRET_KEY], + array_filter($data, fn (string $key) => $key != self::ADMIN_SECRET_KEY, ARRAY_FILTER_USE_KEY), + ); + } +} \ No newline at end of file diff --git a/app/Draft/SecretsTest.php b/app/Draft/SecretsTest.php new file mode 100644 index 0000000..e2fbc12 --- /dev/null +++ b/app/Draft/SecretsTest.php @@ -0,0 +1,55 @@ +assertNotSame($previouslyGenerated, $secret); + } + + #[Test] + public function itCanBeInitiatedFromJson(): void + { + $secretData = [ + 'admin_pass' => 'secret124', + 'player_1' => 'secret456', + 'player_3' => 'secret789', + ]; + + $secret = Secrets::fromJson($secretData); + + $this->assertTrue($secret->checkAdminSecret('secret124')); + $this->assertTrue($secret->checkPlayerSecret(PlayerId::fromString('player_1'), 'secret456')); + $this->assertTrue($secret->checkPlayerSecret(PlayerId::fromString('player_3'), 'secret789')); + } + + #[Test] + public function itCanBeConvertedToArray(): void + { + $secrets = new Secrets( + 'secret124', + [ + 'player_1' => 'secret456', + 'player_3' => 'secret789', + ], + ); + $array = $secrets->toArray(); + + $this->assertSame('secret124', $array['admin_pass']); + $this->assertSame('secret456', $array['player_1']); + $this->assertSame('secret789', $array['player_3']); + + } +} \ No newline at end of file diff --git a/app/Draft/Seed.php b/app/Draft/Seed.php new file mode 100644 index 0000000..666cef7 --- /dev/null +++ b/app/Draft/Seed.php @@ -0,0 +1,57 @@ +seed = self::generate(); + } else { + $this->seed = $seed; + } + } + + protected static function generate(): int + { + return mt_rand(1, self::MAX_VALUE); + } + + public function getValue(): int + { + return $this->seed; + } + + public function setForFactions(): void + { + mt_srand($this->seed + self::OFFSET_FACTIONS); + } + + public function setForSlices($previousTries = 0): void + { + mt_srand($this->seed + self::OFFSET_SLICES + $previousTries); + } + + public function setForPlayerOrder(): void + { + mt_srand($this->seed + self::OFFSET_PLAYER_ORDER); + } + + public function isValid() + { + return $this->seed >= self::MIN_VALUE && $this->seed <= self::MAX_VALUE; + } +} \ No newline at end of file diff --git a/app/Draft/SeedTest.php b/app/Draft/SeedTest.php new file mode 100644 index 0000000..369a3ce --- /dev/null +++ b/app/Draft/SeedTest.php @@ -0,0 +1,80 @@ +assertIsInt($seed->getValue()); + } + + #[Test] + public function itCanUseAUserSeed(): void + { + $seed = new Seed(self::TEST_SEED); + $this->assertSame(self::TEST_SEED, $seed->getValue()); + } + + #[Test] + public function itCanSetTheFactionSeed(): void + { + $seed = new Seed(self::TEST_SEED); + $seed->setForFactions(); + $n = mt_rand(1, 10000); + // pre-calculated using TEST_SEED + $this->assertSame(5295, $n); + } + + #[Test] + public function itCanSetTheSliceSeed(): void + { + $seed = new Seed(self::TEST_SEED); + $seed->setForSlices(self::TEST_SLICE_TRIES); + $n = mt_rand(1, 10000); + // pre-calculated using TEST_SEED + $this->assertSame(823, $n); + } + + #[Test] + public function itCanSetThePlayerOrderSeed(): void + { + $seed = new Seed(self::TEST_SEED); + $seed->setForPlayerOrder(); + $n = mt_rand(1, 10000); + // pre-calculated using TEST_SEED + $this->assertSame(1646, $n); + } + + #[Test] + public function arraysAreShuffledPredictablyWhenSeedIsSet(): void + { + $seed = new Seed(self::TEST_SEED); + $seed->setForFactions(); + + $a = [ + 'a', 'b', 'c', 'd', 'f', 'e', 'g', + ]; + + shuffle($a); + + // pre-calculated using TEST_SEED + factions + $newOrder = [ + 'a', 'g', 'e', 'f', 'd', 'c', 'b', + ]; + + foreach ($a as $idx => $value) { + $this->assertSame($newOrder[$idx], $a[$idx]); + } + } +} \ No newline at end of file diff --git a/app/Draft/Settings.php b/app/Draft/Settings.php new file mode 100644 index 0000000..2615220 --- /dev/null +++ b/app/Draft/Settings.php @@ -0,0 +1,318 @@ + $playerNames + */ + public array $playerNames, + public bool $presetDraftOrder, + public Name $name, + public Seed $seed, + public int $numberOfSlices, + public int $numberOfFactions, + /** + * @var array + */ + public array $tileSets, + /** + * @var array + */ + public array $factionSets, + // @todo figure out a better way to integrate this + // should be required-ish when TE is included, but optional if PoK is included + public bool $includeCouncilKeleresFaction, + public bool $minimumTwoAlphaAndBetaWormholes, + public bool $maxOneWormholesPerSlice, + public int $minimumLegendaryPlanets, + public float $minimumOptimalInfluence, + public float $minimumOptimalResources, + public float $minimumOptimalTotal, + public float $maximumOptimalTotal, + public array $customFactions, + public array $customSlices, + public bool $allianceMode, + public ?AllianceTeamMode $allianceTeamMode = null, + public ?AllianceTeamPosition $allianceTeamPosition = null, + public ?bool $allianceForceDoublePicks = null, + ) { + } + + public function includesFactionSet(Edition $e): bool { + return in_array($e, $this->factionSets); + } + + public function includesTileSet(Edition $e): bool { + return in_array($e, $this->tileSets); + } + + public function toArray() + { + /** + * @todo refactor to use tileSets and factionSets and 'minimumLegendaryPlanets + * But don't break backwards compatibility! + */ + return [ + 'players' => $this->playerNames, + 'preset_draft_order' => $this->presetDraftOrder, + 'name' => (string) $this->name, + 'num_slices' => $this->numberOfSlices, + 'num_factions' => $this->numberOfFactions, + // tiles + 'include_pok' => $this->includesTileSet(Edition::PROPHECY_OF_KINGS), + 'include_ds_tiles' => $this->includesTileSet(Edition::DISCORDANT_STARS_PLUS), + 'include_te_tiles' => $this->includesTileSet(Edition::THUNDERS_EDGE), + // faction settings + 'include_base_factions' => $this->includesFactionSet(Edition::BASE_GAME), + 'include_pok_factions' => $this->includesFactionSet(Edition::PROPHECY_OF_KINGS), + 'include_te_factions' => $this->includesFactionSet(Edition::THUNDERS_EDGE), + 'include_discordant' => $this->includesFactionSet(Edition::DISCORDANT_STARS), + 'include_discordantexp' => $this->includesFactionSet(Edition::DISCORDANT_STARS_PLUS), + 'include_keleres' => $this->includeCouncilKeleresFaction, + // slice settings + // @todo replace with minimumTwoAlphaAndBetaWormholes + 'min_wormholes' => $this->minimumTwoAlphaAndBetaWormholes ? 2 : 0, + 'max_1_wormhole' => $this->maxOneWormholesPerSlice, + 'min_legendaries' => $this->minimumLegendaryPlanets, + 'minimum_optimal_influence' => $this->minimumOptimalInfluence, + 'minimum_optimal_resources' => $this->minimumOptimalResources, + 'minimum_optimal_total' => $this->minimumOptimalTotal, + 'maximum_optimal_total' => $this->maximumOptimalTotal, + 'custom_factions' => $this->customFactions, + 'custom_slices' => $this->customSlices, + 'seed' => $this->seed->getValue(), + 'alliance' => $this->allianceMode ? [ + 'alliance_teams' => $this->allianceTeamMode->value, + 'alliance_teams_position' => $this->allianceTeamPosition->value, + 'force_double_picks' => $this->allianceForceDoublePicks, + ] : null, + ]; + } + + /** + * @return bool + * @throws InvalidDraftSettingsException + */ + public function validate(): bool + { + if (! $this->seed->isValid()) { + throw InvalidDraftSettingsException::invalidSeed(); + } + + $this->validatePlayers(); + $this->validateTiles(); + $this->validateFactions(); + $this->validateCustomSlices(); + + return true; + } + + protected function validatePlayers(): bool + { + if (count(array_filter($this->playerNames)) != count($this->playerNames)) { + throw InvalidDraftSettingsException::notAllPlayerNamesAreFilled(); + } + + if (count(array_unique($this->playerNames)) != count($this->playerNames)) { + throw InvalidDraftSettingsException::playerNamesNotUnique(); + } + + if (count($this->playerNames) < 3) { + throw InvalidDraftSettingsException::notEnoughPlayers(); + } + + if (count($this->playerNames) > $this->numberOfSlices) { + throw InvalidDraftSettingsException::notEnoughSlicesForPlayers(); + } + + if (count($this->playerNames) > $this->numberOfFactions) { + throw InvalidDraftSettingsException::notEnoughFactionsForPlayers(); + } + + return true; + } + + protected function validateTiles(): void { + // @todo base this on tile-selection.json instead of constants + // better yet: make a tileset php class that contain the data instead of loading json + + $blueTiles = array_reduce($this->tileSets, fn ($sum, Edition $e) => $sum += $e->blueTileCount(), 0); + $redTiles = array_reduce($this->tileSets, fn ($sum, Edition $e) => $sum += $e->redTileCount(), 0); + $legendaryPlanets = array_reduce($this->tileSets, fn ($sum, Edition $e) => $sum += $e->legendaryPlanetCount(), 0); + + $maxSlices = min(floor($blueTiles / 3), floor($redTiles / 2)); + + if ($this->numberOfSlices > $maxSlices) { + throw InvalidDraftSettingsException::notEnoughTilesForSlices($maxSlices); + } + + if ($this->maximumOptimalTotal < $this->minimumOptimalTotal) { + throw InvalidDraftSettingsException::invalidMaximumOptimal(); + } + + if ($this->minimumLegendaryPlanets > $this->numberOfSlices) { + throw InvalidDraftSettingsException::notEnoughSlicesForLegendaryPlanets(); + } + + if ($this->minimumLegendaryPlanets > $legendaryPlanets) { + throw InvalidDraftSettingsException::notEnoughLegendaryPlanets($legendaryPlanets); + } + } + + protected function validateFactions(): void + { + $factions = array_reduce($this->factionSets, fn ($sum, Edition $e) => $sum += $e->factionCount(), 0); + if ($factions < $this->numberOfFactions) { + throw InvalidDraftSettingsException::notEnoughFactionsInSet($factions); + } + } + + protected function validateCustomSlices(): bool + { + if (! empty($this->customSlices)) { + if (count($this->customSlices) < count($this->playerNames)) { + throw InvalidDraftSettingsException::notEnoughCustomSlices(); + } + foreach ($this->customSlices as $s) { + if (count($s) != 5) { + throw InvalidDraftSettingsException::invalidCustomSlices(); + } + } + } + + return true; + } + + public static function fromJson(array $data): self + { + $allianceMode = $data['alliance'] != null; + + return new self( + $data['players'], + $data['preset_draft_order'], + new Name($data['name']), + new Seed($data['seed'] ?? null), + $data['num_slices'], + $data['num_factions'], + self::tileSetsFromPayload($data), + self::factionSetsFromPayload($data), + $data['include_keleres'], + $data['min_wormholes'] == 2, + $data['max_1_wormhole'], + $data['min_legendaries'], + (float) $data['minimum_optimal_influence'], + (float) $data['minimum_optimal_resources'], + (float) $data['minimum_optimal_total'], + (float) $data['maximum_optimal_total'], + $data['custom_factions'] ?? [], + $data['custom_slices'] ?? [], + $allianceMode, + $allianceMode ? AllianceTeamMode::from($data['alliance']['alliance_teams']) : null, + $allianceMode ? AllianceTeamPosition::from($data['alliance']['alliance_teams_position']) : null, + $allianceMode ? (bool) $data['alliance']['force_double_picks'] : null, + ); + } + + /** + * @param $data + * @return array + */ + public static function tileSetsFromPayload($data): array + { + $tilesets = []; + + // currently there's no way to disable base game tiles + $tilesets[] = Edition::BASE_GAME; + if ($data['include_pok']) { + $tilesets[] = Edition::PROPHECY_OF_KINGS; + } + if ($data['include_ds_tiles'] ?? false) { + $tilesets[] = Edition::DISCORDANT_STARS_PLUS; + } + if ($data['include_te_tiles'] ?? false) { + $tilesets[] = Edition::THUNDERS_EDGE; + } + + return $tilesets; + } + + /** + * @param $data + * @return array + */ + public static function factionSetsFromPayload($data): array + { + $tilesets = []; + + if ($data['include_base_factions']) { + $tilesets[] = Edition::BASE_GAME; + } + if ($data['include_pok_factions']) { + $tilesets[] = Edition::PROPHECY_OF_KINGS; + } + if ($data['include_discordant'] ?? false) { + $tilesets[] = Edition::DISCORDANT_STARS; + } + if ($data['include_discordantexp'] ?? false) { + $tilesets[] = Edition::DISCORDANT_STARS_PLUS; + } + if ($data['include_te_factions'] ?? false) { + $tilesets[] = Edition::THUNDERS_EDGE; + } + + return $tilesets; + } + + public function factionSetNames() + { + return array_map(fn (Edition $e) => $e->fullName(), $this->factionSets); + } + + public function tileSetNames() + { + return array_map(fn (Edition $e) => $e->fullName(), $this->tileSets); + } + + public function withNewSeed(Seed $seed): Settings + { + return new self( + $this->playerNames, + $this->presetDraftOrder, + $this->name, + $seed, + $this->numberOfSlices, + $this->numberOfFactions, + $this->tileSets, + $this->factionSets, + $this->includeCouncilKeleresFaction, + $this->minimumTwoAlphaAndBetaWormholes, + $this->maxOneWormholesPerSlice, + $this->minimumLegendaryPlanets, + $this->minimumOptimalInfluence, + $this->minimumOptimalResources, + $this->minimumOptimalTotal, + $this->maximumOptimalTotal, + $this->customFactions, + $this->customSlices, + $this->allianceMode, + $this->allianceTeamMode, + $this->allianceTeamPosition, + $this->allianceForceDoublePicks, + ); + + } +} \ No newline at end of file diff --git a/app/Draft/SettingsTest.php b/app/Draft/SettingsTest.php new file mode 100644 index 0000000..7ce8516 --- /dev/null +++ b/app/Draft/SettingsTest.php @@ -0,0 +1,330 @@ +toArray(); + + $this->assertSame(['john', 'mike', 'suzy', 'robin'], $array['players']); + $this->assertSame('Testgame', $array['name']); + $this->assertSame(5, $array['num_slices']); + $this->assertSame(8, $array['num_factions']); + $this->assertSame(true, $array['include_pok']); + $this->assertSame(true, $array['include_ds_tiles']); + $this->assertSame(true, $array['include_te_tiles']); + $this->assertSame(false, $array['include_base_factions']); + $this->assertSame(true, $array['include_pok_factions']); + $this->assertSame(true, $array['include_keleres']); + $this->assertSame(false, $array['include_discordant']); + $this->assertSame(true, $array['include_discordantexp']); + $this->assertSame(true, $array['include_te_factions']); + $this->assertSame(true, $array['preset_draft_order']); + $this->assertSame(2, $array['min_wormholes']); + $this->assertSame(3, $array['min_legendaries']); + $this->assertSame(true, $array['max_1_wormhole']); + $this->assertSame(4.5, $array['minimum_optimal_influence']); + $this->assertSame(7.2, $array['minimum_optimal_resources']); + $this->assertSame(18.3, $array['minimum_optimal_total']); + $this->assertSame(29.0, $array['maximum_optimal_total']); + $this->assertSame([ + 'The Titans of Ul', + 'Free Systems Compact', + ], $array['custom_factions']); + $this->assertSame([ + [ + 1, 2, 3, 4, 5, + ], + ], $array['custom_slices']); + $this->assertSame(123, $array['seed']); + $this->assertSame('random', $array['alliance']['alliance_teams']); + $this->assertSame('neighbors', $array['alliance']['alliance_teams_position']); + $this->assertSame(true, $array['alliance']['force_double_picks']); + } + + public static function validationCases() + { + yield 'When player names are not unique' => [ + 'data' => [ + 'playerNames' => [ + 'sam', + 'sam', + 'kyle', + ], + ], + 'exception' => InvalidDraftSettingsException::playerNamesNotUnique(), + ]; + yield 'When not enough playerNames' => [ + 'data' => [ + 'playerNames' => [ + 'sam', + 'kyle', + ], + ], + 'exception' => InvalidDraftSettingsException::notEnoughPlayers(), + ]; + yield 'When checking slice count' => [ + 'data' => [ + 'numberOfPlayers' => 4, + 'numberOfSlices' => 3, + ], + 'exception' => InvalidDraftSettingsException::notEnoughSlicesForPlayers(), + ]; + } + + #[DataProvider('validationCases')] + #[Test] + public function itThrowsValidationErrors($data, \Exception $exception): void + { + $draft = DraftSettingsFactory::make($data); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage($exception->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesFactionCount(): void + { + $draft = DraftSettingsFactory::make([ + 'numberOfPlayers' => 4, + 'numberOfFactions' => 2, + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughFactionsForPlayers()->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesNumberOfSlices(): void { + $draft = DraftSettingsFactory::make([ + 'numberOfSlices' => 7, + 'tileSets' => [ + Edition::BASE_GAME, + ], + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughTilesForSlices(6)->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesOptimalMaximum(): void { + $draft = DraftSettingsFactory::make([ + 'minimumOptimalTotal' => 7, + 'maximumOptimalTotal' => 4, + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::invalidMaximumOptimal()->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesPlayerNamesNotEmpty(): void { + $draft = DraftSettingsFactory::make([ + 'playerNames' => ['Alice', 'Bob', '', ''], + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notAllPlayerNamesAreFilled()->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesMinimumLegendaryPlanets(): void { + $draft = DraftSettingsFactory::make([ + 'minimumLegendaryPlanets' => 6, + 'tileSets' => [ + Edition::BASE_GAME, + Edition::THUNDERS_EDGE, + ], + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughLegendaryPlanets(5)->getMessage()); + $draft->validate(); + } + + #[Test] + public function itValidatesMinimumLegendaryPlanetsAgainstSlices(): void { + $draft = DraftSettingsFactory::make([ + 'numberOfPlayers' => 5, + 'minimumLegendaryPlanets' => 6, + 'numberOfSlices' => 5, + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughSlicesForLegendaryPlanets()->getMessage()); + $draft->validate(); + } + + public static function seedValues(): iterable + { + yield 'When seed is negative' => [ + 'seed' => -1, + 'valid' => false, + ]; + yield 'When seed is too high' => [ + 'seed' => Seed::MAX_VALUE + 12, + 'valid' => false, + ]; + yield 'When seed is valid' => [ + 'seed' => 50312, + 'valid' => true, + ]; + } + + #[DataProvider('seedValues')] + #[Test] + public function itValidatesSeed($seed, $valid): void { + $draft = DraftSettingsFactory::make([ + 'seed' => $seed, + ]); + + if ($valid) { + $this->assertTrue($draft->validate()); + } else { + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::invalidSeed()->getMessage()); + + $draft->validate(); + } + } + + #[Test] + public function itValidatesFactionSetCount(): void { + $draft = DraftSettingsFactory::make([ + 'numberOfFactions' => 20, + 'factionSets' => [ + Edition::BASE_GAME, + ], + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughFactionsInSet(17)->getMessage()); + + $draft->validate(); + } + + #[Test] + public function itValidatesCustomSlices(): void { + $draft = DraftSettingsFactory::make([ + 'numberOfPlayers' => 5, + 'customSlices' => [ + [1, 2, 3, 4, 5], + ], + ]); + + $this->expectException(InvalidDraftSettingsException::class); + $this->expectExceptionMessage(InvalidDraftSettingsException::notEnoughCustomSlices()->getMessage()); + + $draft->validate(); + } + + #[DataProviderExternal(TestDrafts::class, 'provideTestDrafts')] + #[Test] + public function itCanBeInstantiatedFromJson($data): void { + $draftSettings = Settings::fromJson($data['config']); + + $this->assertSame($data['config']['name'], (string) $draftSettings->name); + $this->assertSame($data['config']['num_slices'], $draftSettings->numberOfSlices); + $this->assertSame($data['config']['num_factions'], $draftSettings->numberOfFactions); + $this->assertSame($data['config']['include_pok'], $draftSettings->includesTileSet(Edition::PROPHECY_OF_KINGS)); + $this->assertSame($data['config']['include_ds_tiles'], $draftSettings->includesTileSet(Edition::DISCORDANT_STARS_PLUS)); + $this->assertSame($data['config']['include_te_tiles'], $draftSettings->includesTileSet(Edition::THUNDERS_EDGE)); + $this->assertSame($data['config']['include_base_factions'], $draftSettings->includesFactionSet(Edition::BASE_GAME)); + $this->assertSame($data['config']['include_pok_factions'], $draftSettings->includesFactionSet(Edition::PROPHECY_OF_KINGS)); + $this->assertSame($data['config']['include_te_factions'], $draftSettings->includesFactionSet(Edition::THUNDERS_EDGE)); + $this->assertSame($data['config']['include_discordant'], $draftSettings->includesFactionSet(Edition::DISCORDANT_STARS)); + $this->assertSame($data['config']['include_discordantexp'], $draftSettings->includesFactionSet(Edition::DISCORDANT_STARS_PLUS)); + $this->assertSame($data['config']['include_keleres'], $draftSettings->includeCouncilKeleresFaction); + $this->assertSame($data['config']['preset_draft_order'], $draftSettings->presetDraftOrder); + $this->assertSame($data['config']['min_wormholes'], $draftSettings->minimumTwoAlphaAndBetaWormholes ? 2 : 0); + $this->assertSame($data['config']['min_legendaries'], $draftSettings->minimumLegendaryPlanets); + $this->assertSame($data['config']['max_1_wormhole'], $draftSettings->maxOneWormholesPerSlice); + $this->assertSame((float) $data['config']['minimum_optimal_influence'], $draftSettings->minimumOptimalInfluence); + $this->assertSame((float) $data['config']['minimum_optimal_resources'], $draftSettings->minimumOptimalResources); + $this->assertSame((float) $data['config']['minimum_optimal_total'], $draftSettings->minimumOptimalTotal); + $this->assertSame((float) $data['config']['maximum_optimal_total'], $draftSettings->maximumOptimalTotal); + if ($data['config']['custom_factions'] == null) { + $this->assertSame([], $draftSettings->customFactions); + } else { + $this->assertSame($data['config']['custom_factions'], $draftSettings->customFactions); + } + if ($data['config']['custom_slices'] == null) { + $this->assertSame([], $draftSettings->customSlices); + } else { + $this->assertSame($data['config']['custom_slices'], $draftSettings->customSlices); + } + $this->assertSame($data['config']['seed'], $draftSettings->seed->getValue()); + if ($data['config']['alliance'] != null) { + $this->assertSame($data['config']['alliance']['alliance_teams'], $draftSettings->allianceTeamMode->value); + $this->assertSame($data['config']['alliance']['alliance_teams_position'], $draftSettings->allianceTeamPosition->value); + $this->assertSame($data['config']['alliance']['force_double_picks'], $draftSettings->allianceForceDoublePicks); + } else { + $this->assertNull($draftSettings->allianceTeamMode); + $this->assertNull($draftSettings->allianceTeamPosition); + $this->assertNull($draftSettings->allianceForceDoublePicks); + } + } +} \ No newline at end of file diff --git a/app/Draft/Slice.php b/app/Draft/Slice.php new file mode 100644 index 0000000..c80c95c --- /dev/null +++ b/app/Draft/Slice.php @@ -0,0 +1,192 @@ + + */ + public array $wormholes = []; + /** + * @var array + */ + public array $specialties = []; + /** + * @var array + */ + public array $legendaryPlanets = []; + public int $totalInfluence = 0; + public int $totalResources = 0; + public float $optimalResources = 0; + public float $optimalInfluence = 0; + public float $optimalTotal = 0; + + /** + * @param Tile[] $tiles + */ + function __construct( + public array $tiles, + ) { + // if the slice doesn't have 5 tiles in it, something went awry + if (count($this->tiles) != 5) { + throw new \Exception('Slice does not have enough tiles'); + } + + foreach ($tiles as $tile) { + $this->totalInfluence += $tile->totalInfluence; + $this->totalResources += $tile->totalResources; + $this->optimalInfluence += $tile->optimalInfluence; + $this->optimalResources += $tile->optimalResources; + $this->optimalTotal += $tile->optimalTotal; + + $this->wormholes = array_merge($this->wormholes, $tile->wormholes); + + foreach ($tile->planets as $planet) { + foreach ($planet->specialties as $spec) { + $this->specialties[] = $spec; + } + if ($planet->legendary) { + $this->legendaryPlanets[] = $planet->legendary; + } + } + } + } + + public function toJson(): array + { + // @refactor so that tile ids just get imported from json and then populated from those tiles + return [ + 'tiles' => array_map(fn(Tile $tile) => $tile->id, $this->tiles), + 'specialties' => $this->specialties, + 'wormholes' => $this->wormholes, + // @todo: refactor to has_legendary_planets, but don't break backwards compatibility! + // or maybe just get rid altogether, since you can just check legendaries/legendary_planets + 'has_legendaries' => Tile::countSpecials($this->tiles)['legendary'] > 0, + // @todo: refactor to legendary_planets, but don't break backwards compatibility! + 'legendaries' => $this->legendaryPlanets, + 'total_influence' => $this->totalInfluence, + 'total_resources' => $this->totalResources, + 'optimal_influence' => $this->optimalInfluence, + 'optimal_resources' => $this->optimalResources, + ]; + } + + /** + * @todo don't use countSpecials + */ + public function validate( + float $minimumOptimalInfluence, + float $minimumOptimalResources, + float $minimumOptimalTotal, + float $maximumOptimalTotal, + bool $maxOneWormhole, + ): bool { + $specialCount = Tile::countSpecials($this->tiles); + + // can't have 2 alpha, beta or legendary planets + if ($specialCount['alpha'] > 1 || $specialCount['beta'] > 1 || $specialCount['legendary'] > 1) { + return false; + } + + // has the right minimum optimal values? + if ( + $this->optimalInfluence < $minimumOptimalInfluence || + $this->optimalResources < $minimumOptimalResources + ) { + return false; + } + + if ($maxOneWormhole && $specialCount['alpha'] + $specialCount['beta'] > 1) { + return false; + } + + // has the right total optimal value? (not too much, not too little) + if ( + $this->optimalTotal < $minimumOptimalTotal || + $this->optimalTotal > $maximumOptimalTotal + ) { + return false; + } + + return true; + } + + public function arrange(Seed $seed): bool { + $tries = 0; + // always shuffle at least once + $seed->setForSlices($tries); + shuffle($this->tiles); + + while (! $this->tileArrangementIsValid()) { + $tries++; + $seed->setForSlices($tries); + shuffle($this->tiles); + + if ($tries > self::MAX_ARRANGEMENT_TRIES) { + return false; + } + } + + return true; + } + + /** + * Determine if the tiles (in the current order) are a valid arrangement + * + * tiles are laid out like this: + * 4 + * 3 + * 1 + * 0 2 + * H + * so for example, tile #1 neighbours #0, #3 and #4. #2 only neighbours #1 + * And we want to avoid two neighbouring anomalies (That's in the rules). + * + * The nature of milty draft makes it so that you can't predict the placement of slices, + * so neighbouring anomalies might happen just by virtue of player order and slice choice. + * But since we can't really do anything about that, we just stop enforce the rule here. + * + * @return bool + */ + public function tileArrangementIsValid(): bool + { + + $neighbours = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [3, 4]]; + + foreach ($neighbours as $neighbouringPair) { + // can't have two neighbouring anomalies + if ( + $this->tiles[$neighbouringPair[0]]->hasAnomaly() && + $this->tiles[$neighbouringPair[1]]->hasAnomaly() + ) { + return false; + } + } + + return true; + } + + public function tileIds(): array + { + return array_map(fn (Tile $t) => $t->id, $this->tiles); + } + + public function hasLegendary(): bool + { + return count($this->legendaryPlanets) > 0; + } + + public function hasWormhole(Wormhole $wormhole): bool + { + return in_array($wormhole, $this->wormholes); + } +} diff --git a/app/Draft/SliceTest.php b/app/Draft/SliceTest.php new file mode 100644 index 0000000..6cb1dc5 --- /dev/null +++ b/app/Draft/SliceTest.php @@ -0,0 +1,325 @@ + 4, + 'influence' => 2, + ]), // optimal: 4, 0 + PlanetFactory::make([ + 'resources' => 3, + 'influence' => 3, + ]), // optimal: 1.5, 1.5 + PlanetFactory::make([ + 'resources' => 1, + 'influence' => 0, + ]), // optimal: 1, 0 + PlanetFactory::make([ + 'resources' => 1, + 'influence' => 2, + ]), // optimal: 0, 2 + ]; + + $totalInfluence = array_reduce($planets, fn ($sum, Planet $p) => $sum += $p->influence); + $totalResources = array_reduce($planets, fn ($sum, Planet $p) => $sum += $p->resources); + $optimalInfluence = array_reduce($planets, fn ($sum, Planet $p) => $sum += $p->optimalInfluence); + $optimalResources = array_reduce($planets, fn ($sum, Planet $p) => $sum += $p->optimalResources); + + $slice = new Slice([ + TileFactory::make([$planets[0], $planets[1]]), + TileFactory::make([$planets[2], $planets[3]]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $this->assertSame($totalResources, $slice->totalResources); + $this->assertSame($totalInfluence, $slice->totalInfluence); + $this->assertSame($optimalResources, $slice->optimalResources); + $this->assertSame($optimalInfluence, $slice->optimalInfluence); + $this->assertSame($optimalResources + $optimalInfluence, $slice->optimalTotal); + } + + public static function tileConfigurations(): iterable + { + yield 'When it has no anomalies' => [ + 'tiles' => [ + TileFactory::make([], [], null), + TileFactory::make([], [], null), + TileFactory::make([], [], null), + TileFactory::make([], [], null), + TileFactory::make([], [], null), + ], + 'canBeArranged' => true, + ]; + yield 'When it has some anomalies' => [ + 'tiles' => [ + TileFactory::make([], [], 'nebula'), + TileFactory::make([], [], 'asteroid field'), + TileFactory::make([], [], null), + TileFactory::make([], [], null), + TileFactory::make([], [], null), + ], + 'canBeArranged' => true, + ]; + yield 'When it has too many anomalies' => [ + 'tiles' => [ + TileFactory::make([], [], 'nebula'), + TileFactory::make([], [], 'asteroid field'), + TileFactory::make([], [], 'gravity-rift'), + TileFactory::make([], [], 'supernova'), + TileFactory::make([], [], null), + ], + 'canBeArranged' => false, + ]; + } + + #[DataProvider('tileConfigurations')] + #[Test] + public function itCanArrangeTiles(array $tiles, bool $canBeArranged): void + { + $slice = new Slice($tiles); + $seed = new Seed(1); + + $arranged = $slice->arrange($seed); + + $this->assertSame($canBeArranged, $arranged); + $this->assertSame($canBeArranged, $slice->tileArrangementIsValid()); + } + + #[Test] + public function itWontAllowSlicesWithTooManyWormholes(): void + { + $slice = new Slice([ + TileFactory::make([], [Wormhole::ALPHA]), + TileFactory::make([], [Wormhole::ALPHA]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate(0, 0, 0, 0, true); + + $this->assertFalse($valid); + } + + #[Test] + public function itWontAllowSlicesWithTooManyLegendaryPlanets(): void + { + $slice = new Slice([ + TileFactory::make([PlanetFactory::make(['legendary' => 'Yes'])]), + TileFactory::make([PlanetFactory::make(['legendary' => 'Yes'])]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate(0, 0, 0, 0, false); + + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateMaxWormholes(): void + { + $slice = new Slice([ + TileFactory::make([], [Wormhole::ALPHA]), + TileFactory::make([], [Wormhole::BETA]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate(0, 0, 0, 0, true); + + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateMinimumOptimalInfluence(): void + { + $slice = new Slice([ + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 2, + 'resources' => 3, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 1, + 'resources' => 0, + ]), + ]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate( + 2, + 0, + 0, + 0, + false, + ); + + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateMinimumOptimalResources(): void + { + $slice = new Slice([ + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 5, + 'resources' => 2, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 1, + 'resources' => 1, + ]), + ]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate( + 0, + 3, + 0, + 0, + false, + ); + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateMinimumOptimalTotal(): void + { + $slice = new Slice([ + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 4, + 'resources' => 2, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 1, + 'resources' => 1, + ]), + ]), + TileFactory::make(), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate( + 0, + 0, + 5, + 0, + false, + ); + + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateMaximumOptimalTotal(): void + { + $slice = new Slice([ + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 2, + 'resources' => 4, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 2, + 'resources' => 1, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 3, + 'resources' => 1, + ]), + ]), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate( + 0, + 0, + 0, + 4, + false, + ); + $this->assertFalse($valid); + } + + #[Test] + public function itCanValidateAValidSlice(): void + { + $slice = new Slice([ + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 2, + 'resources' => 3, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 2, + 'resources' => 1, + ]), + ]), + TileFactory::make([ + PlanetFactory::make([ + 'influence' => 1, + 'resources' => 1, + ]), + PlanetFactory::make([ + 'influence' => 1, + 'resources' => 1, + ]), + ]), + TileFactory::make(), + TileFactory::make(), + ]); + + $valid = $slice->validate( + 1, + 3, + 5, + 7, + false, + ); + + $this->assertTrue($valid); + } +} \ No newline at end of file diff --git a/app/Draft/TilePool.php b/app/Draft/TilePool.php new file mode 100644 index 0000000..451a3b2 --- /dev/null +++ b/app/Draft/TilePool.php @@ -0,0 +1,46 @@ + $highTier */ + public array $highTier, + /** @var array $midTier */ + public array $midTier, + /** @var array $lowTier */ + public array $lowTier, + /** @var array $redTier */ + public array $redTier, + ) { + } + + public function shuffle(): void + { + shuffle($this->highTier); + shuffle($this->midTier); + shuffle($this->lowTier); + shuffle($this->redTier); + } + + public function slice(int $numberOfSlices): TilePool + { + return new TilePool( + array_slice($this->highTier, 0, $numberOfSlices), + array_slice($this->midTier, 0, $numberOfSlices), + array_slice($this->lowTier, 0, $numberOfSlices), + array_slice($this->redTier, 0, $numberOfSlices * 2), + ); + } + + /** + * @return array + */ + public function allIds(): array + { + return array_merge($this->highTier, $this->midTier, $this->lowTier, $this->redTier); + } +} \ No newline at end of file diff --git a/app/Generator.php b/app/Generator.php deleted file mode 100644 index 9df3de0..0000000 --- a/app/Generator.php +++ /dev/null @@ -1,304 +0,0 @@ - 100) { - return_error("Selection contains no valid slices. This happens occasionally to valid configurations but it probably means that the parameters are impossible."); - } - - if ($config->seed !== null) { - mt_srand($config->seed + self::SEED_OFFSET_SLICES + $previous_tries); - } - - // Gather Tiles - $all_tiles = self::gather_tiles($config); - - if ($config->custom_slices != null) { - $tile_data = self::import_tile_data(); - - $slices = []; - - foreach ($config->custom_slices as $slice_data) { - $tiles = []; - foreach ($slice_data as $tile_id) { - $tiles[] = $tile_data[$tile_id]; - } - $slices[] = new Slice($tiles); - } - - return self::convert_slices_data($slices); - // return $slices; - - } else { - $selected_tiles = self::select_tiles($all_tiles, $config); - $slices = self::slicesFromTiles($selected_tiles, $config); - - if ($slices == false) { - // can't make slices with this selection - return self::slices($config, $previous_tries + 1); - } else { - return self::convert_slices_data($slices); - } - } - } - - private static function convert_slices_data($slices) - { - $data = []; - - foreach ($slices as $slice) { - $data[] = $slice->toJson(); - } - - return $data; - } - - /** - * @param $tiles - * @param GeneratorConfig $config - * @param int $previous_tries - * @return mixed - */ - private static function slicesFromTiles($tiles, $config, $previous_tries = 0) - { - $slices = []; - - if ($previous_tries > 1000) { - return false; - } - - if ($config->seed !== null && $previous_tries > 0) { - mt_srand($config->seed + self::SEED_OFFSET_SLICES + $previous_tries); - } - - // reshuffle - shuffle($tiles["high"]); - shuffle($tiles["mid"]); - shuffle($tiles["low"]); - shuffle($tiles["red"]); - - for ($i = 0; $i < $config->num_slices; $i++) { - // grab some tiles - - $slice = new Slice([ - $tiles['high'][$i], - $tiles['mid'][$i], - $tiles['low'][$i], - $tiles['red'][2 * $i], - $tiles['red'][(2 * $i) + 1], - ]); - - if (!$slice->validate($config)) { - return self::slicesFromTiles($tiles, $config, $previous_tries + 1); - } - - if ($slice->arrange() == false) { - // impossible slice, retry - return self::slicesFromTiles($tiles, $config, $previous_tries + 1); - } - - // all good! - $slices[] = $slice; - } - - return $slices; - } - - - /** - * Make a tile selection based on tier-listing - * - * @param array $tiles - * @param GeneratorConfig $config - * @param int $previous_tries - * @return array - */ - private static function select_tiles($tiles, $config, $previous_tries = 0) - { - $selection_valid = false; - - if ($previous_tries > 2000) { - return_error("Max. number of tries exceeded: no valid tile selection found"); - } - - if ($config->seed !== null && $previous_tries > 0) { - mt_srand($config->seed + self::SEED_OFFSET_SLICES + $previous_tries); - } - - shuffle($tiles['high']); - shuffle($tiles['mid']); - shuffle($tiles['low']); - shuffle($tiles['red']); - - $selection = [ - 'high' => array_slice($tiles["high"], 0, $config->num_slices), - 'mid' => array_slice($tiles["mid"], 0, $config->num_slices), - 'low' => array_slice($tiles["low"], 0, $config->num_slices), - 'red' => array_slice($tiles["red"], 0, $config->num_slices * 2), - ]; - - - $all = array_merge($selection["high"], $selection["mid"], $selection["low"], $selection["red"]); - - // check if the wormhole/legendary count is high enough - $counts = Tile::countSpecials($all); - - // validate against minimums - if ($counts["alpha"] < $config->min_wormholes || $counts["beta"] < $config->min_wormholes || $counts["legendary"] < $config->min_legendaries) { - // try again - return self::select_tiles($tiles, $config, $previous_tries + 1); - } else { - return $selection; - } - } - - /** - * Import tile tier-listings - * - * @param GeneratorConfig $config - */ - private static function gather_tiles($config) - { - $tile_tiers = json_decode(file_get_contents('data/tile-selection.json'), true); - $tile_data = self::import_tile_data(); - - $all_tiles = [ - 'high' => [], - 'mid' => [], - 'low' => [], - 'red' => [], - ]; - - foreach ($tile_tiers['tiers'] as $tier => $tiles) { - foreach ($tiles as $tile_id) { - $all_tiles[$tier][] = $tile_data[$tile_id]; - } - } - - if ($config->include_pok) { - foreach ($tile_tiers['pokTiers'] as $tier => $tiles) { - foreach ($tiles as $tile_id) { - $all_tiles[$tier][] = $tile_data[$tile_id]; - } - } - } - - if ($config->include_ds_tiles) { - foreach ($tile_tiers['DSTiers'] as $tier => $tiles) { - foreach ($tiles as $tile_id) { - $all_tiles[$tier][] = $tile_data[$tile_id]; - } - } - } - - if ($config->include_te_tiles) { - foreach ($tile_tiers['TETiers'] as $tier => $tiles) { - foreach ($tiles as $tile_id) { - $all_tiles[$tier][] = $tile_data[$tile_id]; - } - } - } - - return $all_tiles; - } - - - private static function import_faction_data() - { - return json_decode(file_get_contents('data/factions.json'), true); - } - - /** - * @param GeneratorConfig $config - */ - public static function factions($config) - { - - if ($config->seed !== null) { - mt_srand($config->seed + self::SEED_OFFSET_FACTIONS); - } - - $possible_factions = self::filtered_factions($config); - - - if ($config->custom_factions != null) { - $factions = []; - - foreach ($config->custom_factions as $f) { - $factions[] = $f; - } - - - // add some more boys and girls untill we reach the magic number - $i = 0; - while (count($factions) < $config->num_factions) { - $f = $possible_factions[$i]; - - if (!in_array($f, $factions)) { - $factions[] = $f; - } - - $i++; - } - - - shuffle($factions); - } else { - $factions = $possible_factions; - }; - - - return array_slice($factions, 0, $config->num_factions); - } - - public static function filtered_factions($config) - { - - $faction_data = self::import_faction_data(); - $factions = []; - foreach ($faction_data as $faction => $data) { - if ($data["set"] == "base" && $config->include_base_factions) { - $factions[] = $faction; - } - if ($data["set"] == "pok" && $config->include_pok_factions) { - $factions[] = $faction; - } - if ($data["set"] == "keleres" && $config->include_keleres) { - $factions[] = $faction; - } - if ($data["set"] == "discordant" && $config->include_discordant) { - $factions[] = $faction; - } - if ($data["set"] == "discordantexp" && $config->include_discordantexp) { - $factions[] = $faction; - } - if ($data["set"] == "te" && $config->include_te_factions) { - $factions[] = $faction; - } - } - shuffle($factions); - return $factions; - } - - - private static function import_tile_data() - { - $data = json_decode(file_get_contents('data/tiles.json'), true); - $tiles = []; - - - foreach ($data as $i => $tile_data) { - $tiles[$i] = new Tile($i, $tile_data); - } - - return $tiles; - } - -} diff --git a/app/GeneratorConfig.php b/app/GeneratorConfig.php deleted file mode 100644 index 8048e7c..0000000 --- a/app/GeneratorConfig.php +++ /dev/null @@ -1,196 +0,0 @@ -players = array_filter(array_map('htmlentities', get('player', []))); - if ((int) get('num_players') != count($this->players)) { - return_error('Number of players does not match number of names'); - } - - $this->name = get('game_name', ''); - if (trim($this->name) == '') $this->name = $this->generateName(); - else $this->name = htmlentities($this->name); - $this->num_slices = (int) get('num_slices'); - $this->num_factions = (int) get('num_factions'); - $this->include_pok = get('include_pok') == true; - $this->include_ds_tiles = get('include_ds_tiles') == true; - $this->include_te_tiles = get('include_te_tiles') == true; - $this->include_base_factions = get('include_base_factions') == true; - $this->include_pok_factions = get('include_pok_factions') == true; - $this->include_keleres = get('include_keleres') == true; - $this->include_discordant = get('include_discordant') == true; - $this->include_discordantexp = get('include_discordantexp') == true; - $this->include_te_factions = get('include_te_factions') == true; - $this->preset_draft_order = get('preset_draft_order', false) == true; - - $this->max_1_wormhole = get('max_wormhole') == true; - $this->min_wormholes = (get('wormholes') == true) ? 2 : 0; - $this->min_legendaries = (int) get('min_legendaries'); - - $this->minimum_optimal_influence = (float) get('min_inf'); - $this->minimum_optimal_resources = (float) get('min_res'); - $this->minimum_optimal_total = (float) get('min_total'); - $this->maximum_optimal_total = (float) get('max_total'); - - if (!empty(get('custom_factions', []))) { - $this->custom_factions = get('custom_factions'); - } - - if (get('custom_slices') != '') { - $slice_data = explode("\n", get('custom_slices')); - $this->custom_slices = []; - foreach ($slice_data as $s) { - $slice = []; - $t = explode(',', $s); - foreach ($t as $tile) { - $tile = trim($tile); - $slice[] = $tile; - } - $this->custom_slices[] = $slice; - } - } - - if ((bool) get('alliance_on', false)) { - $this->alliance = []; - $this->alliance["alliance_teams"] = get('alliance_teams'); - $this->alliance["alliance_teams_position"] = get('alliance_teams_position'); - $this->alliance["force_double_picks"] = get('force_double_picks') == 'true'; - } - - $seed_input = get('seed', ''); - if ($seed_input !== '' && $seed_input !== null) { - $this->seed = (int) $seed_input; - } else { - $this->seed = mt_rand(1, self::MAX_SEED_VALUE); - } - - $this->validate(); - } - } - - public static function fromArray(array $array): GeneratorConfig - { - $config = new GeneratorConfig(false); - - foreach ($array as $key => $value) { - $config->$key = $value; - } - - $config->validate(); - - return $config; - } - - private function validate(): void - { - - if (count($this->players) > count(array_filter($this->players))) return_error('Some players names are not filled out'); - if (count(array_unique($this->players)) != count($this->players)) return_error('Players should all have unique names'); - $num_tiles = self::NUM_BASE_BLUE + $this->include_pok*self::NUM_POK_BLUE + $this->include_ds_tiles*self::NUM_DS_BLUE + $this->include_te_tiles*self::NUM_TE_BLUE; - $num_red = self::NUM_BASE_RED + $this->include_pok*self::NUM_POK_RED + $this->include_ds_tiles*self::NUM_DS_RED + $this->include_te_tiles*self::NUM_TE_RED; - // maximum number of possible slices, 3 blue and 2 red tiles per slice - $max_slices = min(floor($num_tiles/3), floor($num_red/2)); - if ($max_slices < $this->num_slices) return_error('Can only draft up to ' . $max_slices . ' slices with the selected tiles. (And by extension you can only do drafts up to ' . $max_slices . ' players)'); - if (count($this->players) < 3) return_error('Please enter at least 3 players'); - if ($this->num_factions < count($this->players)) return_error("Can't have less factions than players"); - if ($this->num_slices < count($this->players)) return_error("Can't have less slices than players"); - if ($this->maximum_optimal_total < $this->minimum_optimal_total) return_error("Maximum optimal can't be less than minimum"); - $max_legendaries = $this->include_pok*self::NUM_POK_LEGENDARIES + $this->include_ds_tiles*self::NUM_DS_LEGENDARIES + $this->include_te_tiles*self::NUM_TE_LEGENDARIES; - if ($max_legendaries < $this->min_legendaries) return_error('Cannot include ' . $this->min_legendaries . ' legendaries, maximum number available is ' . $max_legendaries); - if ($this->min_legendaries > $this->num_slices) return_error('Cannot include more legendaries than slices'); - if ($this->seed !== null && ($this->seed < 1 || $this->seed > self::MAX_SEED_VALUE)) return_error('Seed must be between 1 and ' . self::MAX_SEED_VALUE); - // Must include at least 1 of base, pok, discordant, or discordant expansion to have enough factions to use - if (!($this->include_base_factions || $this->include_pok_factions || $this->include_discordant || $this->include_discordantexp || $this->include_te_factions)) return_error("Not enough factions selected."); - // if($this->custom_factions != null && count($this->custom_factions) < count($this->players)) return_error("Not enough custom factions for number of players"); - if ($this->custom_slices != null) { - if (count($this->custom_slices) < count($this->players)) return_error("Not enough custom slices for number of players"); - foreach ($this->custom_slices as $s) { - if (count($s) != 5) return_error('Some of the custom slices have the wrong number of tiles. (each should have five)'); - } - } - } - - private function generateName(): string - { - $adjectives = [ - 'adventurous', 'aggressive', 'angry', 'arrogant', 'beautiful', 'bloody', 'blushing', 'brave', - 'clever', 'clumsy', 'combative', 'confused', 'crazy', 'curious', 'defiant', 'difficult', 'disgusted', 'doubtful', 'easy', - 'famous', 'fantastic', 'filthy', 'frightened', 'funny', 'glamorous', 'gleaming', 'glorious', - 'grumpy', 'homeless', 'hilarious', 'impossible', 'itchy', 'imperial', 'jealous', 'long', 'magnificent', 'lucky', - 'modern', 'mysterious', 'naughty', 'old-fashioned', 'outstanding', 'outrageous', 'perfect', - 'poisoned', 'puzzled', 'rich', 'smiling', 'super', 'tasty', 'terrible', 'wandering', 'zealous' - ]; - $nouns = [ - 'people', 'history', 'art', 'world', 'space', 'universe', 'galaxy', 'story', - 'map', 'game', 'family', 'government', 'system', 'method', 'computer', 'problem', - 'theory', 'law', 'power', 'knowledge', 'control', 'ability', 'love', 'science', - 'fact', 'idea', 'area', 'society', 'industry', 'player', 'security', 'country', - 'equipment', 'analysis', 'policy', 'thought', 'strategy', 'direction', 'technology', - 'army', 'fight', 'war', 'freedom', 'failure', 'night', 'day', 'energy', 'nation', - 'moment', 'politics', 'empire', 'president', 'council', 'effort', 'situation', - 'resource', 'influence', 'agreement', 'union', 'religion', 'virus', 'republic', - 'drama', 'tension', 'suspense', 'friendship', 'twilight', 'imperium', 'leadership', - 'operation', 'disaster', 'leader', 'speaker', 'diplomacy', 'politics', 'warfare', 'construction', - 'trade', 'proposal', 'revolution', 'negotiation' - ]; - - return 'Operation ' . ucfirst($adjectives[rand(0, count($adjectives) - 1)]) . ' ' . ucfirst($nouns[rand(0, count($nouns) - 1)]); - } - - public function toJson(): array - { - return get_object_vars($this); - } -} diff --git a/app/Http/ErrorResponse.php b/app/Http/ErrorResponse.php new file mode 100644 index 0000000..4abee5a --- /dev/null +++ b/app/Http/ErrorResponse.php @@ -0,0 +1,39 @@ + $this->error, + ], $code); + } + + public function getBody(): string + { + if ($this->showErrorPage) { + return HtmlResponse::renderTemplate('templates/error.php', [ + 'error' => $this->error, + ]); + } else { + // return json + return parent::getBody(); + } + } + + public function getContentType(): string + { + if ($this->showErrorPage) { + return 'text/html'; + } else { + return parent::getContentType(); + } + } +} \ No newline at end of file diff --git a/app/Http/ErrorResponseTest.php b/app/Http/ErrorResponseTest.php new file mode 100644 index 0000000..d269920 --- /dev/null +++ b/app/Http/ErrorResponseTest.php @@ -0,0 +1,20 @@ +assertSame(json_encode([ + 'error' => 'foo', + ]), $response->getBody()); + } +} \ No newline at end of file diff --git a/app/Http/HtmlResponse.php b/app/Http/HtmlResponse.php new file mode 100644 index 0000000..1068b58 --- /dev/null +++ b/app/Http/HtmlResponse.php @@ -0,0 +1,44 @@ +code); + } + + public function code(): int + { + return $this->code; + } + + public function getBody(): string + { + return $this->html; + } + + /** + * @todo put this somewhere else + */ + public static function renderTemplate($template, $data): string + { + ob_start(); + extract($data); + include $template; + + return ob_get_clean(); + } + + public function getContentType(): string + { + return self::CONTENT_TYPE; + } +} \ No newline at end of file diff --git a/app/Http/HttpRequest.php b/app/Http/HttpRequest.php new file mode 100644 index 0000000..b674aa7 --- /dev/null +++ b/app/Http/HttpRequest.php @@ -0,0 +1,40 @@ +urlParameters[$key])) { + return $this->urlParameters[$key]; + } + if (isset($this->getParameters[$key])) { + return $this->getParameters[$key]; + } + if (isset($this->postParameters[$key])) { + return $this->postParameters[$key]; + } + + return $defaultValue; + } + +} \ No newline at end of file diff --git a/app/Http/HttpRequestTest.php b/app/Http/HttpRequestTest.php new file mode 100644 index 0000000..25dade5 --- /dev/null +++ b/app/Http/HttpRequestTest.php @@ -0,0 +1,69 @@ + [ + 'param' => 'key', + 'get' => [ + 'key' => 'value', + ], + 'post' => [], + 'expectedValue' => 'value', + ]; + yield 'When set in post' => [ + 'param' => 'key', + 'post' => [ + 'key' => 'value', + ], + 'get' => [], + 'expectedValue' => 'value', + ]; + yield 'When not set anywhere' => [ + 'param' => 'key', + 'post' => [], + 'get' => [], + 'expectedValue' => null, + ]; + } + + #[DataProvider('requestParameters')] + #[Test] + public function itCanRetrieveParameters(string $param, array $get, array $post, $expectedValue): void + { + $request = new HttpRequest($get, $post, []); + $this->assertSame($expectedValue, $request->get($param)); + } + + #[Test] + public function itCanReturnDefaultValueForParameter(): void + { + $request = new HttpRequest([], [], []); + $this->assertSame('bar', $request->get('foo', 'bar')); + } + + #[Test] + public function itCanBeInitialisedFromGetRequest(): void + { + $_GET['foo'] = 'bar'; + $request = HttpRequest::fromRequest(); + $this->assertSame('bar', $request->get('foo')); + } + + #[Test] + public function itCanBeInitialisedFromPostRequest(): void + { + $_POST['foo'] = 'bar'; + $request = HttpRequest::fromRequest(); + $this->assertSame('bar', $request->get('foo')); + } +} \ No newline at end of file diff --git a/app/Http/HttpResponse.php b/app/Http/HttpResponse.php new file mode 100644 index 0000000..63f0ef1 --- /dev/null +++ b/app/Http/HttpResponse.php @@ -0,0 +1,17 @@ +code); + } + + public function code(): int + { + return $this->code; + } + + public function getBody(): string + { + return json_encode($this->data); + } + + public function getContentType(): string + { + return 'application/json'; + } +} \ No newline at end of file diff --git a/app/Http/JsonResponseTest.php b/app/Http/JsonResponseTest.php new file mode 100644 index 0000000..e3ef9d4 --- /dev/null +++ b/app/Http/JsonResponseTest.php @@ -0,0 +1,20 @@ + 'bar', + ]; + $response = new JsonResponse($data); + $this->assertSame(json_encode($data), $response->getBody()); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandler.php b/app/Http/RequestHandler.php new file mode 100644 index 0000000..d8e63eb --- /dev/null +++ b/app/Http/RequestHandler.php @@ -0,0 +1,35 @@ +repository->load($this->request->get($urlKey)); + } catch (DraftRepositoryException $e) { + return null; + } + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequest.php b/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequest.php new file mode 100644 index 0000000..d53a8de --- /dev/null +++ b/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequest.php @@ -0,0 +1,47 @@ +loadDraftByUrlId('draft'); + + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + $playerId = PlayerId::fromString($this->request->get('player')); + $unclaim = $this->request->get('unclaim') == 1; + + $player = $draft->playerById($playerId); + + if ($unclaim) { + dispatch(new UnclaimPlayer($draft, $playerId)); + } else { + $secret = dispatch(new ClaimPlayer($draft, $playerId)); + } + + return $this->json([ + 'draft' => $draft->toArray(), + 'player' => $playerId->value, + 'success' => true, + 'secret' => $unclaim ? null : $secret, + ]); + + } catch (DraftRepositoryException $e) { + return new ErrorResponse('Draft not found', 404); + } + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequestTest.php b/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequestTest.php new file mode 100644 index 0000000..b9871de --- /dev/null +++ b/app/Http/RequestHandlers/HandleClaimOrUnclaimPlayerRequestTest.php @@ -0,0 +1,66 @@ +assertIsConfiguredAsHandlerForRoute('/api/claim'); + } + + #[Test] + public function itReturnsJson(): void + { + $response = $this->handleRequest(['draft' => $this->testDraft->id, 'player' => array_keys($this->testDraft->players)[0]]); + $this->assertResponseOk($response); + $this->assertResponseJson($response); + } + + #[Test] + public function itReturnsErrorIfDraftNotFound(): void + { + + $response = $this->handleRequest(['draft' => '123', 'player' => '123']); + $this->assertResponseNotFound($response); + $this->assertResponseJson($response); + $this->assertJsonResponseSame(['error' => 'Draft not found'], $response); + } + + #[Test] + public function itCanClaimAPlayer(): void + { + $this->setExpectedReturnValue('1234'); + $playerId = array_keys($this->testDraft->players)[0]; + $this->handleRequest(['draft' => $this->testDraft->id, 'player' => $playerId]); + $this->assertCommandWasDispatchedWith(ClaimPlayer::class, function (ClaimPlayer $cmd) use ($playerId): void { + $this->assertSame($this->testDraft->id, $cmd->draft->id); + $this->assertSame($playerId, $cmd->playerId->value); + }); + } + + #[Test] + public function itCanUnclaimAPlayer(): void + { + $playerId = array_keys($this->testDraft->players)[0]; + $this->handleRequest(['draft' => $this->testDraft->id, 'player' => $playerId, 'unclaim' => 1]); + $this->assertCommandWasDispatchedWith(UnclaimPlayer::class, function (UnclaimPlayer $cmd) use ($playerId): void { + $this->assertSame($this->testDraft->id, $cmd->draft->id); + $this->assertSame($playerId, $cmd->playerId->value); + }); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleGenerateDraftRequest.php b/app/Http/RequestHandlers/HandleGenerateDraftRequest.php new file mode 100644 index 0000000..e6c1c3c --- /dev/null +++ b/app/Http/RequestHandlers/HandleGenerateDraftRequest.php @@ -0,0 +1,126 @@ +settings = $this->settingsFromRequest(); + } + + public function handle(): HttpResponse + { + try { + $this->settings->validate(); + } catch (InvalidDraftSettingsException $e) { + return $this->error($e->getMessage(), 400); + } + + $draft = dispatch(new GenerateDraft($this->settingsFromRequest())); + + app()->repository->save($draft); + + return $this->json([ + 'id' => $draft->id, + 'admin' => $draft->secrets->adminSecret, + ]); + } + + private function settingsFromRequest(): Settings + { + $playerNames = []; + for ($i = 0; $i < $this->request->get('num_players'); $i++) { + $playerNames[] = trim($this->request->get('player')[$i] ?? ''); + } + + $allianceMode = (bool) $this->request->get('alliance_on', false); + + $customSlices = []; + if ($this->request->get('custom_slices', '') != '') { + $sliceData = explode("\n", $this->request->get('custom_slices')); + foreach ($sliceData as $s) { + $slice = []; + $t = explode(',', $s); + foreach ($t as $tile) { + $tile = trim($tile); + $slice[] = $tile; + } + $customSlices[] = $slice; + } + } + + return new Settings( + $playerNames, + $this->request->get('preset_draft_order') == 'on', + new Name($this->request->get('name')), + new Seed($this->request->get('seed') != null ? (int) $this->request->get('seed') : null), + (int) $this->request->get('num_slices'), + (int) $this->request->get('num_factions'), + $this->tileSetsFromRequest(), + $this->factionSetsFromRequest(), + $this->request->get('include_keleres') == 'on', + $this->request->get('wormholes', 0) == 1, + $this->request->get('max_wormhole') == 'on', + (int) $this->request->get('min_legendaries'), + (float) $this->request->get('min_inf'), + (float) $this->request->get('min_res'), + (float) $this->request->get('min_total'), + (float) $this->request->get('max_total'), + $this->request->get('custom_factions') ?? [], + $customSlices, + $allianceMode, + $allianceMode ? AllianceTeamMode::from($this->request->get('alliance_teams')) : null, + $allianceMode ? AllianceTeamPosition::from($this->request->get('alliance_teams_position')) : null, + $allianceMode ? $this->request->get('force_double_picks') == 'on' : null, + ); + } + + protected function tileSetsFromRequest() + { + $sets = [Edition::BASE_GAME]; + foreach($this->request->get('tileSets', []) as $key => $value) { + $edition = Edition::from($key); + if ($value == 'on' && ! in_array($edition, $sets)) { + $sets[] = $edition; + } + } + + return $sets; + } + + protected function factionSetsFromRequest() + { + $sets = []; + foreach($this->request->get('factionSets', []) as $key => $value) { + if ($value == 'on') { + $sets[] = Edition::from($key); + } + } + + return $sets; + } + + /** used for tests */ + public function settingValue($field) { + return $this->settings->$field; + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleGenerateDraftRequestTest.php b/app/Http/RequestHandlers/HandleGenerateDraftRequestTest.php new file mode 100644 index 0000000..e234439 --- /dev/null +++ b/app/Http/RequestHandlers/HandleGenerateDraftRequestTest.php @@ -0,0 +1,278 @@ +assertIsConfiguredAsHandlerForRoute('/api/generate'); + } + + #[Test] + public function itReturnsErrorWhenSettingsAreInvalid(): void + { + $response = $this->handleRequest([], [ + 'seed' => -1, + ]); + + $this->assertSame($response->code, 400); + $this->assertJsonResponseSame(['error' => InvalidDraftSettingsException::invalidSeed()->getMessage()], $response); + } + + public static function settingsPayload() + { + yield 'Player Names' => [ + 'postData' => [ + 'num_players' => 4, + 'player' => [ + 'John', 'Paul', 'George', 'Ringo', + ], + ], + 'field' => 'playerNames', + 'expected' => ['John', 'Paul', 'George', 'Ringo'], + 'expectedWhenNotSet' => [], + ]; + yield 'Player Names containing empties' => [ + 'postData' => [ + 'num_players' => 6, + 'player' => [ + 'John', 'Paul', 'George', 'Ringo', '', '', + ], + ], + 'field' => 'playerNames', + 'expected' => ['John', 'Paul', 'George', 'Ringo', '', ''], + 'expectedWhenNotSet' => [], + ]; + yield 'Alliance Mode' => [ + 'postData' => [ + 'alliance_on' => true, + 'alliance_teams' => AllianceTeamMode::RANDOM->value, + 'alliance_teams_position' => AllianceTeamPosition::NONE->value, + ], + 'field' => 'allianceMode', + 'expected' => true, + 'expectedWhenNotSet' => false, + ]; + yield 'Custom Slices' => [ + 'postData' => [ + 'custom_slices' => "1,2,3,4,5\n6,7,8,9,10\n11,12,13,14,15", + ], + 'field' => 'customSlices', + 'expected' => [ + ['1', '2', '3', '4', '5'], + ['6', '7', '8', '9', '10'], + ['11', '12', '13', '14', '15'], + ], + 'expectedWhenNotSet' => [], + ]; + yield 'Preset Draft Order' => [ + 'postData' => [ + 'preset_draft_order' => 'on', + ], + 'field' => 'presetDraftOrder', + 'expected' => true, + 'expectedWhenNotSet' => false, + ]; + yield 'Number of slices' => [ + 'postData' => [ + 'num_slices' => '8', + ], + 'field' => 'numberOfSlices', + 'expected' => 8, + 'expectedWhenNotSet' => 0, + ]; + yield 'Number of factions' => [ + 'postData' => [ + 'num_factions' => '7', + ], + 'field' => 'numberOfFactions', + 'expected' => 7, + 'expectedWhenNotSet' => 0, + ]; + yield 'Tile sets (official)' => [ + 'postData' => [ + 'tileSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on'], + ], + 'field' => 'tileSets', + 'expected' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'expectedWhenNotSet' => [Edition::BASE_GAME], + ]; + yield 'Tile sets (everything)' => [ + 'postData' => [ + 'tileSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on', 'DSPlus' => 'on'], + ], + 'field' => 'tileSets', + 'expected' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE, Edition::DISCORDANT_STARS_PLUS], + 'expectedWhenNotSet' => [Edition::BASE_GAME], + ]; + yield 'Faction sets (base only)' => [ + 'postData' => [ + 'factionSets' => ['BaseGame' => 'on'], + ], + 'field' => 'factionSets', + 'expected' => [Edition::BASE_GAME], + 'expectedWhenNotSet' => [], + ]; + yield 'Faction sets (official)' => [ + 'postData' => [ + 'factionSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on'], + ], + 'field' => 'factionSets', + 'expected' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'expectedWhenNotSet' => [], + ]; + yield 'Faction sets (all)' => [ + 'postData' => [ + 'factionSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on', 'DS' => 'on', 'DSPlus' => 'on'], + ], + 'field' => 'factionSets', + 'expected' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE, Edition::DISCORDANT_STARS, Edition::DISCORDANT_STARS_PLUS], + 'expectedWhenNotSet' => [], + ]; + yield 'Council Keleres' => [ + 'postData' => [ + 'include_keleres' => 'on', + ], + 'field' => 'includeCouncilKeleresFaction', + 'expected' => true, + 'expectedWhenNotSet' => false, + ]; + yield 'Minimum legendary planets' => [ + 'postData' => [ + 'min_legendaries' => '1', + ], + 'field' => 'minimumLegendaryPlanets', + 'expected' => 1, + 'expectedWhenNotSet' => 0, + ]; + yield 'Minimum optimal Influence' => [ + 'postData' => [ + 'min_inf' => '4.5', + ], + 'field' => 'minimumOptimalInfluence', + 'expected' => 4.5, + 'expectedWhenNotSet' => 0.0, + ]; + yield 'Minimum optimal Resources' => [ + 'postData' => [ + 'min_res' => '3', + ], + 'field' => 'minimumOptimalResources', + 'expected' => 3.0, + 'expectedWhenNotSet' => 0.0, + ]; + yield 'Minimum optimal total' => [ + 'postData' => [ + 'min_total' => '7.3', + ], + 'field' => 'minimumOptimalTotal', + 'expected' => 7.3, + 'expectedWhenNotSet' => 0.0, + ]; + yield 'Maximum optimal total' => [ + 'postData' => [ + 'min_total' => '13', + ], + 'field' => 'minimumOptimalTotal', + 'expected' => 13.0, + 'expectedWhenNotSet' => 0.0, + ]; + yield 'Custom Factions' => [ + 'postData' => [ + 'custom_factions' => ['Xxcha', 'Keleres'], + ], + 'field' => 'customFactions', + 'expected' => ['Xxcha', 'Keleres'], + 'expectedWhenNotSet' => [], + ]; + yield 'Alliance Team Mode' => [ + 'postData' => [ + 'alliance_on' => true, + 'alliance_teams' => 'random', + 'alliance_teams_position' => 'neighbors', + ], + 'field' => 'allianceTeamMode', + 'expected' => AllianceTeamMode::RANDOM, + 'expectedWhenNotSet' => null, + ]; + yield 'Alliance Team Position' => [ + 'postData' => [ + 'alliance_on' => true, + 'alliance_teams' => 'random', + 'alliance_teams_position' => 'neighbors', + ], + 'field' => 'allianceTeamPosition', + 'expected' => AllianceTeamPosition::NEIGHBORS, + 'expectedWhenNotSet' => null, + ]; + yield 'Alliance Force double picks' => [ + 'postData' => [ + 'alliance_on' => true, + 'force_double_picks' => 'on', + 'alliance_teams' => 'random', + 'alliance_teams_position' => 'neighbors', + ], + 'field' => 'allianceForceDoublePicks', + 'expected' => true, + 'expectedWhenNotSet' => null, + ]; + } + + #[Test] + #[DataProvider('settingsPayload')] + public function itParsesSettingsFromRequest($postData, $field, $expected, $expectedWhenNotSet): void + { + $handler = new HandleGenerateDraftRequest(new HttpRequest([], $postData, [])); + + $this->assertSame($expected, $handler->settingValue($field)); + } + + #[Test] + #[DataProvider('settingsPayload')] + public function itParsesSettingsFromRequestWhenNotSet($postData, $field, $expected, $expectedWhenNotSet): void + { + $handler = new HandleGenerateDraftRequest(new HttpRequest([], [], [])); + $this->assertSame($expectedWhenNotSet, $handler->settingValue($field)); + } + + #[Test] + public function itGeneratesADraft(): void + { + $this->setExpectedReturnValue($this->testDraft); + + $response = $this->handleRequest([ + 'num_players' => 4, + 'player' => ['John', 'Paul', 'George', 'Ringo'], + 'tileSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on'], + 'factionSets' => ['BaseGame' => 'on', 'PoK' => 'on', 'TE' => 'on'], + 'num_slices' => 4, + 'num_factions' => 4, + ]);; + + $this->assertCommandWasDispatched(GenerateDraft::class); + + $this->assertResponseOk($response); + $this->assertResponseJson($response); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleGetDraftRequest.php b/app/Http/RequestHandlers/HandleGetDraftRequest.php new file mode 100644 index 0000000..34519b6 --- /dev/null +++ b/app/Http/RequestHandlers/HandleGetDraftRequest.php @@ -0,0 +1,24 @@ +loadDraftByUrlId('id'); + + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + return new JsonResponse( + $draft->toArray(), + ); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleGetDraftRequestTest.php b/app/Http/RequestHandlers/HandleGetDraftRequestTest.php new file mode 100644 index 0000000..f599fa9 --- /dev/null +++ b/app/Http/RequestHandlers/HandleGetDraftRequestTest.php @@ -0,0 +1,41 @@ +assertIsConfiguredAsHandlerForRoute('/api/draft/123'); + } + + #[Test] + public function itReturnsErrorIfDraftNotFound(): void + { + $response = $this->handleRequest(['id' => '12344']); + + $this->assertSame(404, $response->code); + $this->assertResponseJson($response); + $this->assertJsonResponseSame(['error' => 'Draft not found'], $response); + } + + #[Test] + public function itCanReturnDraftData(): void + { + $response = $this->handleRequest(['id' => $this->testDraft->id]); + + $this->assertSame(200, $response->code); + $this->assertResponseJson($response); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandlePickRequest.php b/app/Http/RequestHandlers/HandlePickRequest.php new file mode 100644 index 0000000..e14b624 --- /dev/null +++ b/app/Http/RequestHandlers/HandlePickRequest.php @@ -0,0 +1,50 @@ +loadDraftByUrlId('id'); + + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + $playerId = PlayerId::fromString($this->request->get('player')); + + $isAdmin = $draft->secrets->checkAdminSecret($this->request->get('admin')); + + if (! $isAdmin && ! $draft->secrets->checkPlayerSecret($playerId, $this->request->get('secret'))) { + return $this->error('You are not allowed to do this.', 403); + } + + if ($this->request->get('index', 0) != count($draft->log)) { + return $this->error( + 'Draft data out of date, meaning: stuff has been picked or undone while this tab was open.', + 400, + ); + } + + dispatch(new PlayerPick($draft, new Pick( + $playerId, + PickCategory::from($this->request->get('category')), + $this->request->get('value'), + ))); + + return new JsonResponse([ + 'draft' => $draft->toArray(), + 'success' => true, + ]); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandlePickRequestTest.php b/app/Http/RequestHandlers/HandlePickRequestTest.php new file mode 100644 index 0000000..f5183f2 --- /dev/null +++ b/app/Http/RequestHandlers/HandlePickRequestTest.php @@ -0,0 +1,123 @@ +assertIsConfiguredAsHandlerForRoute('/api/pick'); + } + + #[Test] + public function itReturnsErrorIfDraftNotFound(): void + { + $response = $this->handleRequest(['id' => '12344']); + + $this->assertSame(404, $response->code); + $this->assertResponseJson($response); + $this->assertJsonResponseSame(['error' => 'Draft not found'], $response); + } + + #[Test] + public function itReturnsErrorIfPlayerSecretIsIncorrect(): void + { + $playerId = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + + $response = $this->handleRequest([ + 'id' => $this->testDraft->id, + 'player' => $playerId->value, + 'secret' => 'incorrect-value', + 'category' => PickCategory::SLICE->value, + 'value' => '3', + ]); + + $this->assertForbidden($response); + } + + #[Test] + public function itAllowsAdminsToMakePicksForOtherPlayers(): void + { + $playerId = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + + $response = $this->handleRequest([ + 'id' => $this->testDraft->id, + 'player' => $playerId->value, + 'admin' => $this->testDraft->secrets->adminSecret, + 'category' => PickCategory::SLICE->value, + 'value' => '3', + ]); + + $this->assertResponseOk($response); + } + + #[Test] + public function itReturnsErrorIfIndexIsIncorrect(): void + { + $playerId = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + + $response = $this->handleRequest([ + 'id' => $this->testDraft->id, + 'index' => 4, + 'player' => $playerId->value, + 'admin' => $this->testDraft->secrets->adminSecret, + 'category' => PickCategory::FACTION->value, + 'value' => 'Hacan', + ]); + + $this->assertResponseCode(400, $response); + $this->assertJsonResponseSame([ + 'error' => 'Draft data out of date, meaning: stuff has been picked or undone while this tab was open.', + ], $response); + } + + #[Test] + public function itDispatchesTheCommand(): void + { + $playerId = PlayerId::fromString(array_keys($this->testDraft->players)[0]); + + $secret = (new ClaimPlayer($this->testDraft, $playerId))->handle(); + + $category = PickCategory::FACTION->value; + $value = 'Hacan'; + + $response = $this->handleRequest([ + 'id' => $this->testDraft->id, + 'index' => 0, + 'player' => $playerId->value, + 'secret' => $secret, + 'category' => $category, + 'value' => $value, + ]); + + $this->assertResponseOk($response); + $this->assertResponseJson($response); + + $this->assertCommandWasDispatchedWith( + PlayerPick::class, + function (PlayerPick $cmd) use ($playerId, $category, $value): void { + $this->assertSame($cmd->draft->id, $this->testDraft->id); + $this->assertSame($cmd->pick->playerId->value, $playerId->value); + $this->assertSame($cmd->pick->category->value, $category); + $this->assertSame($cmd->pick->pickedOption, $value); + }, + ); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleRegenerateDraftRequest.php b/app/Http/RequestHandlers/HandleRegenerateDraftRequest.php new file mode 100644 index 0000000..a392994 --- /dev/null +++ b/app/Http/RequestHandlers/HandleRegenerateDraftRequest.php @@ -0,0 +1,37 @@ +loadDraftByUrlId(); + + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + $adminSecret = $this->request->get('admin'); + + if (! $draft->secrets->checkAdminSecret($adminSecret)) { + return $this->error('Only the admin can regenerate', 403); + } + + dispatch(new RegenerateDraft( + $draft, + $this->request->get('slices', false) === 'true', + $this->request->get('factions', false) === 'true', + $this->request->get('order', false) === 'true', + )); + + return $this->json([ + 'draft' => $draft->toArray(), + ]); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleRegenerateDraftRequestTest.php b/app/Http/RequestHandlers/HandleRegenerateDraftRequestTest.php new file mode 100644 index 0000000..3f7a638 --- /dev/null +++ b/app/Http/RequestHandlers/HandleRegenerateDraftRequestTest.php @@ -0,0 +1,86 @@ +assertIsConfiguredAsHandlerForRoute('/api/regenerate'); + } + + #[Test] + public function itReturnsErrorIfDraftNotFound(): void + { + $response = $this->handleRequest(['id' => '12344', 'secret' => '']); + + $this->assertResponseNotFound($response); + $this->assertResponseJson($response); + $this->assertJsonResponseSame(['error' => 'Draft not found'], $response); + } + + #[Test] + public function itReturnsErrorIfNotAdmin(): void + { + $response = $this->handleRequest(['id' => $this->testDraft->id, 'secret' => 'Not admin']); + $this->assertForbidden($response); + } + + public static function parameters() + { + yield 'When regenerating slices' => [ + 'slices' => 'true', + 'factions' => 'false', + 'order' => 'false', + ]; + + yield 'When regenerating factions' => [ + 'slices' => 'false', + 'factions' => 'false', + 'order' => 'true', + ]; + + yield 'When regenerating player order' => [ + 'slices' => 'false', + 'factions' => 'false', + 'order' => 'true', + ]; + } + + #[Test] + #[DataProvider('parameters')] + public function itDispatchesTheCommand($slices, $factions, $order): void + { + $response = $this->handleRequest([ + 'id' => $this->testDraft->id, + 'slices' => $slices, + 'factions' => $factions, + 'order' => $order, + 'admin' => $this->testDraft->secrets->adminSecret, + ]); + + $this->assertCommandWasDispatchedWith( + RegenerateDraft::class, + function (RegenerateDraft $cmd) use ($slices, $factions, $order): void { + $this->assertSame($cmd->regenerateSlices, $slices == 'true'); + $this->assertSame($cmd->regenerateFactions, $factions == 'true'); + $this->assertSame($cmd->regenerateOrder, $order == 'true'); + }, + ); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleRestoreClaimRequest.php b/app/Http/RequestHandlers/HandleRestoreClaimRequest.php new file mode 100644 index 0000000..9a5bb96 --- /dev/null +++ b/app/Http/RequestHandlers/HandleRestoreClaimRequest.php @@ -0,0 +1,41 @@ +loadDraftByUrlId('draft'); + + // @todo this should be in some shared thing for all DraftRequestHandlers + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + $secret = $this->request->get('secret', ''); + + if ($draft->secrets->checkAdminSecret($secret)) { + return $this->json([ + 'admin' => $secret, + 'success' => true, + ]); + } + + $playerId = $draft->secrets->playerIdBySecret($secret); + + if ($playerId == null) { + return $this->error('No player with that secret', 403); + } else { + return $this->json([ + 'player' => $playerId->value, + 'secret' => $secret, + 'success' => true, + ]); + } + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleRestoreClaimRequestTest.php b/app/Http/RequestHandlers/HandleRestoreClaimRequestTest.php new file mode 100644 index 0000000..04d5acd --- /dev/null +++ b/app/Http/RequestHandlers/HandleRestoreClaimRequestTest.php @@ -0,0 +1,75 @@ +assertIsConfiguredAsHandlerForRoute('/api/restore'); + } + + #[Test] + public function itReturnsJson(): void + { + $response = $this->handleRequest(['draft' => $this->testDraft->id, 'secret' => $this->testDraft->secrets->adminSecret]); + $this->assertResponseOk($response); + $this->assertResponseJson($response); + } + + #[Test] + public function itCanRestoreAnAdminClaim(): void + { + $adminSecret = $this->testDraft->secrets->adminSecret; + $response = $this->handleRequest(['draft' => $this->testDraft->id, 'secret' => $adminSecret]); + $this->assertResponseOk($response); + $this->assertJsonResponseSame([ + 'admin' => $adminSecret, + 'success' => true, + ], $response); + } + + #[Test] + public function itCanRestoreAPlayerClaim(): void + { + $playerId = array_keys($this->testDraft->players)[0]; + + (new ClaimPlayer($this->testDraft, PlayerId::fromString($playerId)))->handle(); + + $playerSecret = $this->testDraft->secrets->playerSecrets[$playerId]; + $response = $this->handleRequest(['draft' => $this->testDraft->id, 'secret' => $playerSecret]); + $this->assertResponseOk($response); + $this->assertJsonResponseSame([ + 'player' => $playerId, + 'secret' => $playerSecret, + 'success' => true, + ], $response); + } + + #[Test] + public function itThrowsAnErrorIfDraftDoesntExist(): void + { + $response = $this->handleRequest(['draft' => '1234', 'secret' => 'blabla']); + $this->assertResponseNotFound($response); + } + + #[Test] + public function itThrowsAnErrorIfNoPlayerWasFound(): void + { + $response = $this->handleRequest(['draft' => $this->testDraft->id, 'secret' => 'blabla']); + $this->assertForbidden($response); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleUndoRequest.php b/app/Http/RequestHandlers/HandleUndoRequest.php new file mode 100644 index 0000000..3734aa5 --- /dev/null +++ b/app/Http/RequestHandlers/HandleUndoRequest.php @@ -0,0 +1,34 @@ +loadDraftByUrlId(); + + if ($draft == null) { + return $this->error('Draft not found', 404); + } + + $adminSecret = $this->request->get('admin'); + + if (! $draft->secrets->checkAdminSecret($adminSecret)) { + return $this->error('Only the admin can undo picks', 403); + } + + dispatch(new UndoLastPick($draft)); + + return new JsonResponse([ + 'draft' => $draft->toArray(), + 'success' => true, + ]); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleUndoRequestTest.php b/app/Http/RequestHandlers/HandleUndoRequestTest.php new file mode 100644 index 0000000..482a66b --- /dev/null +++ b/app/Http/RequestHandlers/HandleUndoRequestTest.php @@ -0,0 +1,51 @@ +assertIsConfiguredAsHandlerForRoute('/api/undo'); + } + + #[Test] + public function itReturnsErrorIfDraftNotFound(): void + { + $response = $this->handleRequest(['id' => '12344']); + + $this->assertResponseNotFound($response); + $this->assertResponseJson($response); + $this->assertJsonResponseSame(['error' => 'Draft not found'], $response); + } + + #[Test] + public function itReturnsErrorIfNotAdmin(): void + { + $response = $this->handleRequest(['id' => $this->testDraft->id, 'admin' => 'not-the-correct-secret']); + $this->assertForbidden($response); + } + + #[Test] + public function itDispatchesTheCommand(): void + { + $response = $this->handleRequest(['id' => $this->testDraft->id, 'admin' => $this->testDraft->secrets->adminSecret]); + $this->assertResponseOk($response); + $this->assertResponseJson($response); + $this->assertCommandWasDispatched(UndoLastPick::class); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleViewDraftRequest.php b/app/Http/RequestHandlers/HandleViewDraftRequest.php new file mode 100644 index 0000000..b6390ff --- /dev/null +++ b/app/Http/RequestHandlers/HandleViewDraftRequest.php @@ -0,0 +1,26 @@ +loadDraftByUrlId(); + + if ($draft == null) { + return $this->error('Draft not found', 404, true); + } + + return $this->html( + 'templates/draft.php', + [ + 'draft' => $draft, + ], + ); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleViewDraftRequestTest.php b/app/Http/RequestHandlers/HandleViewDraftRequestTest.php new file mode 100644 index 0000000..b4f9527 --- /dev/null +++ b/app/Http/RequestHandlers/HandleViewDraftRequestTest.php @@ -0,0 +1,46 @@ +assertIsConfiguredAsHandlerForRoute('/d/123'); + } + + #[Test] + public function itCanFetchDraft(): void + { + $handler = new HandleViewDraftRequest(new HttpRequest([], ['id' => $this->testDraft->id], [])); + + $response = $handler->handle(); + + $this->assertSame(200, $response->code); + $this->assertNotSame(HtmlResponse::CONTENT_TYPE, $response->code); + } + + #[Test] + public function itShowsAnErrorPageWhenDraftIsNotFound(): void + { + $handler = new HandleViewDraftRequest(new HttpRequest([], ['id' => '123'], [])); + $response = $handler->handle(); + + $this->assertSame(404, $response->code); + $this->assertNotSame(HtmlResponse::CONTENT_TYPE, $response->code); + } + +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleViewFormRequest.php b/app/Http/RequestHandlers/HandleViewFormRequest.php new file mode 100644 index 0000000..fc57f62 --- /dev/null +++ b/app/Http/RequestHandlers/HandleViewFormRequest.php @@ -0,0 +1,16 @@ +html('templates/generate.php'); + } +} \ No newline at end of file diff --git a/app/Http/RequestHandlers/HandleViewFormRequestTest.php b/app/Http/RequestHandlers/HandleViewFormRequestTest.php new file mode 100644 index 0000000..3e40ffb --- /dev/null +++ b/app/Http/RequestHandlers/HandleViewFormRequestTest.php @@ -0,0 +1,29 @@ +assertIsConfiguredAsHandlerForRoute('/'); + } + + #[Test] + public function itReturnsTheForm(): void + { + $response = $this->handleRequest(); + + $this->assertResponseHtml($response); + $this->assertResponseOk($response); + } +} \ No newline at end of file diff --git a/app/Http/Route.php b/app/Http/Route.php new file mode 100644 index 0000000..22baaf7 --- /dev/null +++ b/app/Http/Route.php @@ -0,0 +1,59 @@ +routeChunks = explode('/', $this->route); + } + + /** + * @param string $path + * @return ?RouteMatch + */ + public function match(string $path): ?RouteMatch + { + $pathChunks = explode('/', $path); + + // remove trailing slash + if ($path != '/' && $pathChunks[count($pathChunks) - 1] == '') { + $pathChunks = array_slice($pathChunks, 0, count($pathChunks) - 1); + } + + // if it's not a match on chunk size, already discard + if (count($this->routeChunks) != count($pathChunks)) { + return null; + } + + $parameters = []; + $allChunksMatch = true; + foreach($this->routeChunks as $i => $chunk) { + + preg_match('/^\{(\w+)}$/', $chunk, $matches); + if (count($matches) > 0) { + $parameters[$matches[1]] = $pathChunks[$i]; + } else { + if ($pathChunks[$i] != $chunk) { + $allChunksMatch = false; + } + } + } + + if (! $allChunksMatch) { + return null; + } + + return new RouteMatch( + $this->handlerClass, + $parameters, + ); + } +} \ No newline at end of file diff --git a/app/Http/RouteMatch.php b/app/Http/RouteMatch.php new file mode 100644 index 0000000..f4b2d9b --- /dev/null +++ b/app/Http/RouteMatch.php @@ -0,0 +1,14 @@ +match('/hello/world'); + + $this->assertNotNull($result); + $this->assertSame('SomeClass', $result->requestHandlerClass); + $this->assertEmpty($result->requestParameters); + } + + #[Test] + public function itCanCaptureUrlParameters(): void + { + $route = new Route('/slug/{foo}/edit/{bar}', 'SomeClass'); + + $result = $route->match('/slug/123/edit/abc1234'); + + $this->assertSame('SomeClass', $result->requestHandlerClass); + $this->assertSame('123', $result->requestParameters['foo']); + $this->assertSame('abc1234', $result->requestParameters['bar']); + } + + #[Test] + public function itCanDealWithATrailingSlash(): void + { + $route = new Route('/slug/{id}', 'SomeClass'); + + $result = $route->match('/slug/123/'); + + $this->assertSame('SomeClass', $result->requestHandlerClass); + $this->assertSame('123', $result->requestParameters['id']); + } + + #[Test] + public function itCanRouteToIndex(): void + { + $route = new Route('/', 'IndexClass'); + + $result = $route->match('/'); + + $this->assertSame('IndexClass', $result->requestHandlerClass); + } +} \ No newline at end of file diff --git a/app/Planet.php b/app/Planet.php deleted file mode 100644 index 17a72ec..0000000 --- a/app/Planet.php +++ /dev/null @@ -1,40 +0,0 @@ -name = $json_data['name']; - $this->influence = $json_data['influence']; - $this->resources = $json_data['resources']; - $this->legendary = $json_data['legendary']; - $this->trait = isset($json_data["trait"]) ? $json_data['trait'] : null; - $this->specialties = $json_data["specialties"]; - - // pre-calculate the optimals - if ($this->influence > $this->resources) { - $this->optimal_influence = $this->influence; - $this->optimal_resources = 0; - } elseif ($this->resources > $this->influence) { - $this->optimal_influence = 0; - $this->optimal_resources = $this->resources; - } elseif ($this->resources == $this->influence) { - $this->optimal_influence = $this->resources / 2; - $this->optimal_resources = $this->resources / 2; - } - - $this->optimal_total = $this->optimal_resources + $this->optimal_influence; - } -} diff --git a/app/Shared/Command.php b/app/Shared/Command.php new file mode 100644 index 0000000..8a1efdc --- /dev/null +++ b/app/Shared/Command.php @@ -0,0 +1,10 @@ +value) == '') { + throw InvalidIdStringExcepion::emptyId(self::class); + } + } + + public static function fromString(string $value): self + { + return new self(trim($value)); + } + + public function __toString(): string + { + return $this->value; + } + + public function equals(\Stringable $other): bool + { + return $other->__toString() === $this->__toString(); + } +} \ No newline at end of file diff --git a/app/Shared/InvalidIdStringExcepion.php b/app/Shared/InvalidIdStringExcepion.php new file mode 100644 index 0000000..6608254 --- /dev/null +++ b/app/Shared/InvalidIdStringExcepion.php @@ -0,0 +1,13 @@ +tiles = $tiles; - foreach ($tiles as $tile) { - - $this->tile_ids[] = $tile->id; - $this->total_influcence += $tile->total_influence; - $this->total_resources += $tile->total_resources; - $this->optimal_influence += $tile->optimal_influence; - $this->optimal_resources += $tile->optimal_resources; - - if ($tile->wormhole != null) $this->wormholes[] = $tile->wormhole; - - foreach ($tile->planets as $planet) { - foreach ($planet->specialties as $spec) { - $this->specialties[] = $spec; - } - if ($planet->legendary) { - $this->legendaries[] = $planet->legendary; - } - } - } - - $this->total_optimal = $this->optimal_resources + $this->optimal_influence; - } - - public function toJson(): array - { - return [ - 'tiles' => $this->tile_ids, - 'specialties' => $this->specialties, - 'wormholes' => $this->wormholes, - 'has_legendaries' => Tile::countSpecials($this->tiles)['legendary'] > 0, - 'legendaries' => $this->legendaries, - 'total_influence' => $this->total_influcence, - 'total_resources' => $this->total_resources, - 'optimal_influence' => $this->optimal_influence, - 'optimal_resources' => $this->optimal_resources - ]; - } - - /** - * @param GeneratorConfig $config - * @return bool - */ - function validate(GeneratorConfig $config): bool - { - $special_count = Tile::countSpecials($this->tiles); - - // can't have 2 alpha, beta or legendaries - if ($special_count['alpha'] > 1 || $special_count['beta'] > 1 || $special_count['legendary'] > 1) { - return false; - } - - // has the right minimum optimal values? - if ($this->optimal_influence < $config->minimum_optimal_influence || $this->optimal_resources < $config->minimum_optimal_resources) { - //echo "not enough minimum
"; - return false; - } - - if ($special_count['alpha'] > 0 && $special_count['beta'] > 0 && $config->max_1_wormhole) { - return false; - } - - // has the right total optimal value? (not too much, not too little) - if ($this->total_optimal < $config->minimum_optimal_total || $this->total_optimal > $config->maximum_optimal_total) { - return false; - } - - return true; - } - - function arrange($previous_tries = 0) - { - // miltydraft.com only shuffles it 12 times max, no idea why but we're gonna assume they have a reason - if ($previous_tries > 12) { - return false; - } - - shuffle($this->tiles); - // tiles are laid out like this: - // 4 - // 3 - // 1 - // 0 2 - // H - // so for example, tile #1 neighbours #0, #3 and #4. #2 only neighbours #1 - - $neighbours = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [3, 4]]; - - foreach ($neighbours as $edge) { - // can't have two neighbouring anomalies - if ($this->tiles[$edge[0]]->hasAnomaly() && $this->tiles[$edge[1]]->hasAnomaly()) { - return $this->arrange($previous_tries + 1); - } - } - - // if we're all good at this point then we should fix the order of the tile ids - $this->tile_ids = []; - foreach ($this->tiles as $t) { - $this->tile_ids[] = $t->id; - } - - return true; - } - -} diff --git a/app/Station.php b/app/Station.php deleted file mode 100644 index 4e7bada..0000000 --- a/app/Station.php +++ /dev/null @@ -1,34 +0,0 @@ -name = $json_data['name']; - $this->influence = $json_data['influence']; - $this->resources = $json_data['resources']; - - // pre-calculate the optimals (same logic as planets) - if ($this->influence > $this->resources) { - $this->optimal_influence = $this->influence; - $this->optimal_resources = 0; - } elseif ($this->resources > $this->influence) { - $this->optimal_influence = 0; - $this->optimal_resources = $this->resources; - } elseif ($this->resources == $this->influence) { - $this->optimal_influence = $this->resources / 2; - $this->optimal_resources = $this->resources / 2; - } - - $this->optimal_total = $this->optimal_resources + $this->optimal_influence; - } -} diff --git a/app/Testing/DispatcherSpy.php b/app/Testing/DispatcherSpy.php new file mode 100644 index 0000000..5d5ef7d --- /dev/null +++ b/app/Testing/DispatcherSpy.php @@ -0,0 +1,24 @@ +dispatchedCommands[] = $command; + + return $this->commandReturnValue; + } +} \ No newline at end of file diff --git a/app/Testing/Factories/DraftSettingsFactory.php b/app/Testing/Factories/DraftSettingsFactory.php new file mode 100644 index 0000000..c2af53c --- /dev/null +++ b/app/Testing/Factories/DraftSettingsFactory.php @@ -0,0 +1,66 @@ + $faker->name(), range(1, $numberOfPlayers)); + + $allianceMode = $properties['allianceMode'] ?? false; + + return new Settings( + $names, + $properties['presetDraftOrder'] ?? $faker->boolean(), + new Name($properties['name'] ?? null), + new Seed($properties['seed'] ?? null), + $properties['numberOfSlices'] ?? $numberOfPlayers + 2, + $properties['numberOfFactions'] ?? $numberOfPlayers + 2, + $properties['tileSets'] ?? [ + Edition::BASE_GAME, + Edition::PROPHECY_OF_KINGS, + Edition::THUNDERS_EDGE, + ], + $properties['factionSets'] ?? [ + Edition::BASE_GAME, + Edition::PROPHECY_OF_KINGS, + Edition::THUNDERS_EDGE, + ], + $properties['includeCouncilKeleresFaction'] ?? false, + $properties['minimumTwoAlphaBetaWormholes'] ?? $faker->boolean(), + $properties['maxOneWormholePerSlice'] ?? $faker->boolean(), + $properties['minimumLegendaryPlanets'] ?? $faker->numberBetween(0, 1), + $properties['minimumOptimalInfluence'] ?? 4, + $properties['minimumOptimalResources'] ?? 2.5, + $properties['minimumOptimalTotal'] ?? 9, + $properties['maximumOptimalTotal'] ?? 13, + $properties['customFactions'] ?? [], + $properties['customSlices'] ?? [], + $allianceMode, + $allianceMode ? $properties['allianceTeamMode'] ?? AllianceTeamMode::RANDOM : null, + $allianceMode ? $properties['allianceTeamPosition'] ?? AllianceTeamPosition::OPPOSITES : null, + $allianceMode ? $properties['allianceForceDoublePicks'] ?? false : null, + ); + } +} \ No newline at end of file diff --git a/app/Testing/Factories/PlanetFactory.php b/app/Testing/Factories/PlanetFactory.php new file mode 100644 index 0000000..afa7876 --- /dev/null +++ b/app/Testing/Factories/PlanetFactory.php @@ -0,0 +1,36 @@ +word(), + $properties['resources'] ?? $faker->numberBetween(0, 4), + $properties['influence'] ?? $faker->numberBetween(0, 4), + $properties['legendary'] ?? null, + $properties['traits'] ?? $faker->randomElements([ + PlanetTrait::INDUSTRIAL, + PlanetTrait::HAZARDOUS, + PlanetTrait::CULTURAL, + ], 1), + $properties['specialties'] ?? $faker->randomElements([ + TechSpecialties::WARFARE, + TechSpecialties::PROPULSION, + TechSpecialties::CYBERNETIC, + TechSpecialties::BIOTIC, + ], $faker->numberBetween(0, 2)), + ); + } +} \ No newline at end of file diff --git a/app/Testing/Factories/TileFactory.php b/app/Testing/Factories/TileFactory.php new file mode 100644 index 0000000..e4dba23 --- /dev/null +++ b/app/Testing/Factories/TileFactory.php @@ -0,0 +1,40 @@ + $planets + * @param array $wormholes + * @param string|null $anomaly + * @return Tile + */ + public static function make( + array $planets = [], + array $wormholes = [], + ?string $anomaly = null, + TileTier $tier = TileTier::MEDIUM, + Edition $edition = Edition::BASE_GAME, + ): Tile { + return new Tile( + 'tile-' . bin2hex(random_bytes(2)), + TileType::BLUE, + $tier, + $edition, + $planets, + [], + $wormholes, + $anomaly, + ); + } +} \ No newline at end of file diff --git a/app/Testing/FakesCommands.php b/app/Testing/FakesCommands.php new file mode 100644 index 0000000..53d1e46 --- /dev/null +++ b/app/Testing/FakesCommands.php @@ -0,0 +1,48 @@ +spyOnDispatcher(); + } + + #[After] + public function teardownSpy(): void + { + app()->dontSpyOnDispatcher(); + } + + public function setExpectedReturnValue($return = null): void + { + app()->spyOnDispatcher($return); + } + + public function assertCommandWasDispatched($class, $times = 1): void + { + $dispatched = array_filter( + app()->spy->dispatchedCommands, + fn (Command $cmd) => get_class($cmd) == $class, + ); + Assert::assertSame($times, count($dispatched)); + } + + public function assertCommandWasDispatchedWith($class, $callback, $times = 1): void + { + $dispatched = array_filter( + app()->spy->dispatchedCommands, + $callback, + ); + } +} \ No newline at end of file diff --git a/app/Testing/FakesData.php b/app/Testing/FakesData.php new file mode 100644 index 0000000..486bd11 --- /dev/null +++ b/app/Testing/FakesData.php @@ -0,0 +1,25 @@ +faker = Factory::create(); + } + + protected function faker(): Generator { + if (! isset($this->faker)) { + $this->bootFaker(); + } + + return $this->faker; + } +} \ No newline at end of file diff --git a/app/Testing/RequestHandlerTestCase.php b/app/Testing/RequestHandlerTestCase.php new file mode 100644 index 0000000..41534d2 --- /dev/null +++ b/app/Testing/RequestHandlerTestCase.php @@ -0,0 +1,86 @@ +application = new Application(); + } + + #[After] + public function unsetApplication(): void + { + unset($this->application); + } + + public function assertIsConfiguredAsHandlerForRoute($route): void + { + $determinedHandler = $this->application->handlerForRequest($route); + $this->assertInstanceOf($this->requestHandlerClass, $determinedHandler); + } + + public function handleRequest($getParameters = [], $postParameters = [], $urlParameters = []): HttpResponse + { + $handler = new $this->requestHandlerClass(new HttpRequest($getParameters, $postParameters, $urlParameters)); + + return $handler->handle(); + } + + public function assertJsonResponseSame(array $expected, HttpResponse $response): void + { + $this->assertSame($expected, json_decode($response->getBody(), true)); + } + + public function assertResponseContentType(string $expected, HttpResponse $response): void + { + $this->assertSame($expected, $response->getContentType()); + } + + public function assertResponseJson(HttpResponse $response): void + { + $this->assertResponseContentType(JsonResponse::CONTENT_TYPE, $response); + } + + public function assertResponseHtml(HttpResponse $response): void + { + $this->assertResponseContentType(HtmlResponse::CONTENT_TYPE, $response); + } + + public function assertResponseCode(int $expected, HttpResponse $response): void + { + $this->assertSame($expected, $response->code); + } + + public function assertResponseOk(HttpResponse $response): void + { + $this->assertResponseCode(200, $response); + } + + public function assertResponseNotFound(HttpResponse $response): void + { + $this->assertResponseCode(404, $response); + } + + public function assertForbidden(HttpResponse $response): void + { + $this->assertResponseCode(403, $response); + } +} \ No newline at end of file diff --git a/app/Testing/TestCase.php b/app/Testing/TestCase.php new file mode 100644 index 0000000..4ceee47 --- /dev/null +++ b/app/Testing/TestCase.php @@ -0,0 +1,12 @@ +name => [ + 'data' => self::loadDraftByFilename($case->value), + ]; + } + } + + public static function provideSingleTestDraft(): iterable + { + yield 'When using a finished draft' => [ + 'data' => self::loadDraftByFilename(self::FINISHED_ALL_CHECKBOXES->value), + ]; + } +} \ No newline at end of file diff --git a/app/Testing/TestSets.php b/app/Testing/TestSets.php new file mode 100644 index 0000000..c4a7828 --- /dev/null +++ b/app/Testing/TestSets.php @@ -0,0 +1,51 @@ + [ + 'sets' => [Edition::BASE_GAME], + ]; + + yield 'For base game + Discordant' => [ + 'sets' => [Edition::BASE_GAME, Edition::DISCORDANT_STARS, Edition::DISCORDANT_STARS_PLUS], + ]; + + yield 'For base game + POK' => [ + 'sets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS], + ]; + + yield "For base game + Thunder's edge" => [ + 'sets' => [Edition::BASE_GAME, Edition::THUNDERS_EDGE], + ]; + + yield 'For all official editions' => [ + 'sets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + ]; + + yield 'For base game + POK + Discordant' => [ + 'sets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::DISCORDANT_STARS, Edition::DISCORDANT_STARS_PLUS], + ]; + + yield 'For the whole shebang' => [ + 'sets' => [ + Edition::BASE_GAME, + Edition::PROPHECY_OF_KINGS, + Edition::THUNDERS_EDGE, + Edition::DISCORDANT_STARS, + Edition::DISCORDANT_STARS_PLUS, + ], + ]; + } +} \ No newline at end of file diff --git a/app/Testing/UsesTestDraft.php b/app/Testing/UsesTestDraft.php new file mode 100644 index 0000000..8b7c018 --- /dev/null +++ b/app/Testing/UsesTestDraft.php @@ -0,0 +1,48 @@ + 6, + 'tileSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'factionSets' => [Edition::BASE_GAME, Edition::PROPHECY_OF_KINGS, Edition::THUNDERS_EDGE], + 'minimumTwoAlphaBetaWormholes' => false, + 'minimumLegendaryPlanets' => 0, + 'maxOneWormholePerSlice' => true, + ]); + } + + $this->testDraft = (new GenerateDraft($settings))->handle(); + app()->repository->save($this->testDraft); + } + + public function reloadDraft(): void + { + $this->testDraft = app()->repository->load($this->testDraft->id); + } + + #[After] + public function deleteTestDraft(): void + { + app()->repository->delete($this->testDraft->id); + unset($this->testDraft); + } +} \ No newline at end of file diff --git a/app/Tile.php b/app/Tile.php deleted file mode 100644 index 51a6eb3..0000000 --- a/app/Tile.php +++ /dev/null @@ -1,107 +0,0 @@ -id = $id; - $this->type = $json_data['type']; - $this->wormhole = $json_data['wormhole']; - $this->hyperlanes = isset($json_data['hyperlanes']) ? $json_data['hyperlanes'] : null; - $this->anomaly = isset($json_data['anomaly']) ? $json_data['anomaly'] : null; - $this->planets = []; - foreach ($json_data['planets'] as $p) { - $planet = new Planet($p); - $this->total_influence += $planet->influence; - $this->total_resources += $planet->resources; - $this->optimal_influence += $planet->optimal_influence; - $this->optimal_resources += $planet->optimal_resources; - $this->planets[] = $planet; - } - - // Process stations if they exist - $this->stations = []; - if (isset($json_data['stations'])) { - foreach ($json_data['stations'] as $s) { - $station = new Station($s); - $this->total_influence += $station->influence; - $this->total_resources += $station->resources; - $this->optimal_influence += $station->optimal_influence; - $this->optimal_resources += $station->optimal_resources; - $this->stations[] = $station; - } - } - - $this->optimal_total = $this->optimal_resources + $this->optimal_influence; - } - - function hasAnomaly() - { - return $this->anomaly != null; - } - - function hasWormhole($wormhole) - { - return $wormhole == $this->wormhole; - } - - function hasLegendary() - { - foreach ($this->planets as $p) { - if ($p->legendary) return true; - } - - return false; - } - - - /** - * @param Tile[] $tiles - * @return int[] - */ - public static function countSpecials(array $tiles) - { - $alpha_count = 0; - $beta_count = 0; - $legendary_count = 0; - - foreach ($tiles as $tile) { - if ($tile->hasWormhole("alpha")) $alpha_count++; - if ($tile->hasWormhole("beta")) $beta_count++; - if ($tile->hasWormhole("alpha-beta")) { - $alpha_count++; - $beta_count++; - } - if ($tile->hasLegendary()) $legendary_count++; - } - - return [ - 'alpha' => $alpha_count, - 'beta' => $beta_count, - 'legendary' => $legendary_count - ]; - } -} diff --git a/app/TwilightImperium/AllianceTeamMode.php b/app/TwilightImperium/AllianceTeamMode.php new file mode 100644 index 0000000..ce240f8 --- /dev/null +++ b/app/TwilightImperium/AllianceTeamMode.php @@ -0,0 +1,11 @@ + $mode->value, AllianceTeamMode::cases()); + + $this->assertContains('random', $values); + $this->assertContains('preset', $values); + } +} \ No newline at end of file diff --git a/app/TwilightImperium/AllianceTeamPosition.php b/app/TwilightImperium/AllianceTeamPosition.php new file mode 100644 index 0000000..08024e3 --- /dev/null +++ b/app/TwilightImperium/AllianceTeamPosition.php @@ -0,0 +1,12 @@ + $mode->value, AllianceTeamPosition::cases()); + + $this->assertContains('neighbors', $values); + $this->assertContains('opposites', $values); + $this->assertContains('none', $values); + } +} \ No newline at end of file diff --git a/app/TwilightImperium/Edition.php b/app/TwilightImperium/Edition.php new file mode 100644 index 0000000..5906555 --- /dev/null +++ b/app/TwilightImperium/Edition.php @@ -0,0 +1,88 @@ + 'Base Game', + Edition::PROPHECY_OF_KINGS => 'Prophecy of Kings', + Edition::THUNDERS_EDGE => "Thunder's Edge", + Edition::DISCORDANT_STARS => 'Discordant Stars', + Edition::DISCORDANT_STARS_PLUS => 'Discordant Stars Plus', + }; + } + + /** + * For now, only discordant stars doesn't have a dedicated tileset, but that might change + * + * @return array + */ + private static function editionsWithoutTiles(): array + { + return [ + self::DISCORDANT_STARS, + ]; + } + + public function hasValidTileSet(): bool + { + return ! in_array($this, self::editionsWithoutTiles()); + } + + // @todo move to tileset class? + public function blueTileCount(): int + { + return match($this) { + Edition::BASE_GAME => 20, + Edition::PROPHECY_OF_KINGS => 16, + Edition::THUNDERS_EDGE => 15, + Edition::DISCORDANT_STARS => 0, + Edition::DISCORDANT_STARS_PLUS => 16, + }; + } + + public function redTileCount(): int + { + return match($this) { + Edition::BASE_GAME => 12, + Edition::PROPHECY_OF_KINGS => 6, + Edition::THUNDERS_EDGE => 5, + Edition::DISCORDANT_STARS => 0, + Edition::DISCORDANT_STARS_PLUS => 8, + }; + } + + public function legendaryPlanetCount(): int + { + return match($this) { + Edition::BASE_GAME => 0, + Edition::PROPHECY_OF_KINGS => 2, + Edition::THUNDERS_EDGE => 5, + Edition::DISCORDANT_STARS => 0, + Edition::DISCORDANT_STARS_PLUS => 5, + }; + } + + public function factionCount(): int + { + return match($this) { + Edition::BASE_GAME => 17, + Edition::PROPHECY_OF_KINGS => 7, + Edition::THUNDERS_EDGE => 5, + Edition::DISCORDANT_STARS => 24, + Edition::DISCORDANT_STARS_PLUS => 10, + }; + } +} \ No newline at end of file diff --git a/app/TwilightImperium/EditionTest.php b/app/TwilightImperium/EditionTest.php new file mode 100644 index 0000000..3b7f7d7 --- /dev/null +++ b/app/TwilightImperium/EditionTest.php @@ -0,0 +1,69 @@ +assertFalse($edition->hasValidTileSet()); + } else { + $this->assertTrue($edition->hasValidTileSet()); + } + } + } + + #[Test] + public function itReturnsTheCorrectNumbersForBaseGame(): void + { + $this->assertSame(20, Edition::BASE_GAME->blueTileCount()); + $this->assertSame(12, Edition::BASE_GAME->redTileCount()); + $this->assertSame(0, Edition::BASE_GAME->legendaryPlanetCount()); + $this->assertSame(17, Edition::BASE_GAME->factionCount()); + } + + #[Test] + public function itReturnsTheCorrectNumbersForPoK(): void + { + $this->assertSame(16, Edition::PROPHECY_OF_KINGS->blueTileCount()); + $this->assertSame(6, Edition::PROPHECY_OF_KINGS->redTileCount()); + $this->assertSame(2, Edition::PROPHECY_OF_KINGS->legendaryPlanetCount()); + $this->assertSame(7, Edition::PROPHECY_OF_KINGS->factionCount()); + } + + #[Test] + public function itReturnsTheCorrectNumbersForThundersEdge(): void + { + $this->assertSame(15, Edition::THUNDERS_EDGE->blueTileCount()); + $this->assertSame(5, Edition::THUNDERS_EDGE->redTileCount()); + $this->assertSame(5, Edition::THUNDERS_EDGE->legendaryPlanetCount()); + $this->assertSame(5, Edition::THUNDERS_EDGE->factionCount()); + } + + #[Test] + public function itReturnsTheCorrectNumbersForDiscordantStars(): void + { + $this->assertSame(0, Edition::DISCORDANT_STARS->blueTileCount()); + $this->assertSame(0, Edition::DISCORDANT_STARS->redTileCount()); + $this->assertSame(0, Edition::DISCORDANT_STARS->legendaryPlanetCount()); + $this->assertSame(24, Edition::DISCORDANT_STARS->factionCount()); + } + + #[Test] + public function itReturnsTheCorrectNumbersForDiscordantStarsPlus(): void + { + $this->assertSame(16, Edition::DISCORDANT_STARS_PLUS->blueTileCount()); + $this->assertSame(8, Edition::DISCORDANT_STARS_PLUS->redTileCount()); + $this->assertSame(5, Edition::DISCORDANT_STARS_PLUS->legendaryPlanetCount()); + $this->assertSame(10, Edition::DISCORDANT_STARS_PLUS->factionCount()); + } + +} \ No newline at end of file diff --git a/app/TwilightImperium/Faction.php b/app/TwilightImperium/Faction.php new file mode 100644 index 0000000..3c4c102 --- /dev/null +++ b/app/TwilightImperium/Faction.php @@ -0,0 +1,76 @@ + + */ + private static array $allFactionData; + + public function __construct( + public readonly string $name, + public readonly string $id, + public readonly string $homeSystemTileNumber, + public readonly string $linkToWiki, + public readonly Edition $edition, + ) { + } + + public static function fromJson($data) + { + return new self( + $data['name'], + $data['id'], + $data['homesystem'], + $data['wiki'], + self::editionFromFactionJson($data['set']), + ); + } + + /** + * @return array + */ + public static function all(): array + { + if (! isset(self::$allFactionData)) { + $rawData = json_decode(file_get_contents('data/factions.json'), true); + self::$allFactionData = array_map(fn ($factionData) => self::fromJson($factionData), $rawData); + } + + return self::$allFactionData; + } + + // + private static function editionFromFactionJson($factionEdition): Edition + { + return match ($factionEdition) { + 'base' => Edition::BASE_GAME, + 'pok' => Edition::PROPHECY_OF_KINGS, + 'te' => Edition::THUNDERS_EDGE, + 'keleres' => Edition::THUNDERS_EDGE, + 'discordant' => Edition::DISCORDANT_STARS, + 'discordantexp' => Edition::DISCORDANT_STARS_PLUS, + default => throw new \Exception('Faction has invalid set'), + }; + } + + /** + * @todo fix this mess + */ + public function homesystem(): string + { + if(in_array($this->edition, [Edition::DISCORDANT_STARS, Edition::DISCORDANT_STARS_PLUS])) { + return 'DS_' . $this->id; + } else { + return $this->homeSystemTileNumber; + } + } +} \ No newline at end of file diff --git a/app/TwilightImperium/FactionTest.php b/app/TwilightImperium/FactionTest.php new file mode 100644 index 0000000..5773880 --- /dev/null +++ b/app/TwilightImperium/FactionTest.php @@ -0,0 +1,30 @@ + $data) { + $faction = $factions[$key]; + + $this->assertSame($faction->name, $data['name']); + $this->assertSame($faction->id, $data['id']); + $this->assertSame($faction->homeSystemTileNumber, $data['homesystem']); + $this->assertSame($faction->linkToWiki, $data['wiki']); + } + } +} \ No newline at end of file diff --git a/app/TwilightImperium/Planet.php b/app/TwilightImperium/Planet.php new file mode 100644 index 0000000..ed4d5b4 --- /dev/null +++ b/app/TwilightImperium/Planet.php @@ -0,0 +1,72 @@ + + */ + public array $traits = [], + /** + * @var array + */ + public array $specialties = [], + ) { + parent::__construct($resources, $influence); + } + + public static function fromJsonData(array $data): self + { + return new self( + $data['name'], + $data['resources'], + $data['influence'], + // @todo refactor tiles.json to use legendary: null instead of false + ($data['legendary'] !== false) ? $data['legendary'] : null, + self::traitsFromJsonData($data['trait']), + self::techSpecialtiesFromJsonData($data['specialties']), + ); + } + + // @todo update the tiles.json so that all planets just have an array of traits + /** + * @param string|array|null $data + * @return array + */ + private static function traitsFromJsonData(string|array|null $data): array + { + if ($data == null) { + return []; + } else { + // process array of traits, even if it's just one string + return array_map( + fn (string $str) => PlanetTrait::from($str), + is_array($data) ? $data : [$data], + ); + } + } + + /** + * @param array $data + * @return array + */ + private static function techSpecialtiesFromJsonData(array $data): array + { + return array_map( + fn (string $str) => TechSpecialties::from($str), + $data, + ); + } + + public function isLegendary(): bool { + return $this->legendary != null; + } +} diff --git a/app/TwilightImperium/PlanetTest.php b/app/TwilightImperium/PlanetTest.php new file mode 100644 index 0000000..087e72e --- /dev/null +++ b/app/TwilightImperium/PlanetTest.php @@ -0,0 +1,140 @@ + [ + 'planet' => new Planet( + 'Legendplanet', + 0, + 0, + 'Some string value', + ), + 'expected' => true, + ]; + yield 'For a regular planet' => [ + 'planet' => new Planet( + 'RegularJoePlanet', + 0, + 0, + null, + ), + 'expected' => false, + ]; + } + + public static function jsonData(): iterable { + $baseJsonData = [ + 'name' => 'Tinnes', + 'resources' => 2, + 'influence' => 1, + ]; + + yield 'A planet without a legendary' => [ + 'jsonData' => $baseJsonData + [ + 'trait' => 'hazardous', + 'legendary' => false, + 'specialties' => [ + 'biotic', + 'cybernetic', + ], + ], + 'expectedLegendary' => null, + 'expectedTraits' => [ + PlanetTrait::HAZARDOUS, + ], + 'expectedTechSpecialties' => [ + TechSpecialties::BIOTIC, + TechSpecialties::CYBERNETIC, + ], + ]; + yield 'A planet with a legendary' => [ + 'jsonData' => $baseJsonData + [ + 'trait' => 'cultural', + 'legendary' => 'I am legend', + 'specialties' => [ + 'propulsion', + 'warfare', + ], + ], + 'expectedLegendary' => 'I am legend', + 'expectedTraits' => [ + PlanetTrait::CULTURAL, + ], + 'expectedTechSpecialties' => [ + TechSpecialties::PROPULSION, + TechSpecialties::WARFARE, + ], + ]; + + yield 'A planet with legendary false' => [ + 'jsonData' => $baseJsonData + [ + 'trait' => 'industrial', + 'legendary' => false, + 'specialties' => [], + ], + 'expectedLegendary' => null, + 'expectedTraits' => [ + PlanetTrait::INDUSTRIAL, + ], + 'expectedTechSpecialties' => [], + ]; + yield 'A planet with multiple traits' => [ + 'jsonData' => $baseJsonData + [ + 'trait' => ['cultural', 'hazardous'], + 'legendary' => null, + 'specialties' => [], + ], + 'expectedLegendary' => null, + 'expectedTraits' => [ + PlanetTrait::CULTURAL, + PlanetTrait::HAZARDOUS, + ], + 'expectedTechSpecialties' => [], + ]; + yield 'A planet with no traits' => [ + 'jsonData' => $baseJsonData + [ + 'trait' => null, + 'legendary' => null, + 'specialties' => [], + ], + 'expectedLegendary' => null, + 'expectedTraits' => [], + 'expectedTechSpecialties' => [], + ]; + } + + #[DataProvider('jsonData')] + #[Test] + public function itcanCreateAPlanetFromJsonData( + array $jsonData, + ?string $expectedLegendary, + array $expectedTraits, + array $expectedTechSpecialties, + ): void { + $planet = Planet::fromJsonData($jsonData); + + $this->assertSame($jsonData['name'], $planet->name); + $this->assertSame($jsonData['resources'], $planet->resources); + $this->assertSame($jsonData['influence'], $planet->influence); + $this->assertSame($expectedTraits, $planet->traits); + $this->assertSame($expectedTechSpecialties, $planet->specialties); + $this->assertSame($expectedLegendary, $planet->legendary); + } + + #[DataProvider('planets')] + #[Test] + public function itExposesHasLegendaryMethod(Planet $planet, bool $expected): void { + $this->assertSame($expected, $planet->isLegendary()); + } +} \ No newline at end of file diff --git a/app/TwilightImperium/PlanetTrait.php b/app/TwilightImperium/PlanetTrait.php new file mode 100644 index 0000000..f8a9a21 --- /dev/null +++ b/app/TwilightImperium/PlanetTrait.php @@ -0,0 +1,12 @@ +influence > $this->resources) { + $this->optimalInfluence = $this->influence; + } elseif ($this->resources > $this->influence) { + $this->optimalResources = $this->resources; + } elseif ($this->resources == $this->influence) { + $this->optimalInfluence = $this->influence / 2; + $this->optimalResources = $this->resources / 2; + } + + $this->optimalTotal = $this->optimalResources + $this->optimalInfluence; + } +} \ No newline at end of file diff --git a/app/TwilightImperium/SpaceObjectTest.php b/app/TwilightImperium/SpaceObjectTest.php new file mode 100644 index 0000000..b62bace --- /dev/null +++ b/app/TwilightImperium/SpaceObjectTest.php @@ -0,0 +1,53 @@ + [ + 'resources' => 3, + 'influence' => 1, + 'expectedOptimalResources' => 3.0, + 'expectedOptimalInfluence' => 0.0, + ]; + yield 'when resource value is lower than influence' => [ + 'resources' => 2, + 'influence' => 4, + 'expectedOptimalResources' => 0.0, + 'expectedOptimalInfluence' => 4.0, + ]; + yield 'when resource value equals influence' => [ + 'resources' => 3, + 'influence' => 3, + 'expectedOptimalResources' => 1.5, + 'expectedOptimalInfluence' => 1.5, + ]; + } + + #[DataProvider('values')] + #[Test] + public function itCalculatesOptimalValues( + int $resources, + int $influence, + float $expectedOptimalResources, + float $expectedOptimalInfluence, + ): void + { + $entity = new SpaceObject( + $resources, + $influence, + ); + + $this->assertSame($expectedOptimalResources, $entity->optimalResources); + $this->assertSame($expectedOptimalInfluence, $entity->optimalInfluence); + $this->assertSame($expectedOptimalInfluence + $expectedOptimalResources, $entity->optimalTotal); + } +} \ No newline at end of file diff --git a/app/TwilightImperium/SpaceStation.php b/app/TwilightImperium/SpaceStation.php new file mode 100644 index 0000000..fd15ba3 --- /dev/null +++ b/app/TwilightImperium/SpaceStation.php @@ -0,0 +1,26 @@ + 'Oluz Station', + 'resources' => 1, + 'influence' => 1, + ]; + $spaceStation = SpaceStation::fromJsonData($jsonData); + + $this->assertSame($jsonData['name'], $spaceStation->name); + $this->assertSame($jsonData['resources'], $spaceStation->resources); + $this->assertSame($jsonData['influence'], $spaceStation->influence); + } +} \ No newline at end of file diff --git a/app/TwilightImperium/TechSpecialties.php b/app/TwilightImperium/TechSpecialties.php new file mode 100644 index 0000000..6807018 --- /dev/null +++ b/app/TwilightImperium/TechSpecialties.php @@ -0,0 +1,16 @@ + + */ + private static array $allTileData; + + public function __construct( + public string $id, + public TileType $tileType, + public TileTier $tier, + public Edition $edition, + /** + * @var array + */ + public array $planets = [], + /** + * @var array + */ + public array $spaceStations = [], + /** + * @var array + */ + public array $wormholes = [], + // @todo anomaly enum, but not priority because it doesn't influence generator + public ?string $anomaly = null, + // @todo make a Hyperlane class, but not priority because it doesn't influence generator + public array $hyperlanes = [], + ) { + // calculate total and optimal values + foreach (array_merge($this->planets, $this->spaceStations) as $entity) { + $this->totalInfluence += $entity->influence; + $this->totalResources += $entity->resources; + $this->optimalResources += $entity->optimalResources; + $this->optimalInfluence += $entity->optimalInfluence; + $this->optimalTotal += $entity->optimalTotal; + } + } + + public static function fromJsonData( + string $id, + TileTier $tier, + array $data, + ): self { + return new self( + $id, + TileType::from($data['type']), + $tier, + Edition::from($data['set']), + array_map(fn(array $planetData) => Planet::fromJsonData($planetData), $data['planets'] ?? []), + array_map(fn(array $stationData) => SpaceStation::fromJsonData($stationData), $data['stations'] ?? []), + Wormhole::fromJsonData($data['wormhole']), + $data['anomaly'] ?? null, + $data['hyperlanes'] ?? [], + ); + } + + function hasAnomaly() + { + return $this->anomaly != null; + } + + function hasWormhole($wormhole) + { + return in_array($wormhole, $this->wormholes); + } + + function hasLegendaryPlanet() + { + foreach ($this->planets as $p) { + if ($p->isLegendary()) return true; + } + + return false; + } + + /** + * @todo deprecate + * + * @param Tile[] $tiles + * @return int[] + */ + public static function countSpecials(array $tiles) + { + $count = [ + 'legendary' => 0, + ]; + foreach(Wormhole::cases() as $wormhole) { + $count[$wormhole->value] = 0; + } + + foreach ($tiles as $tile) { + foreach ($tile->wormholes as $w) [ + $count[$w->value]++ + ]; + + if ($tile->hasLegendaryPlanet()) $count['legendary']++; + } + + return $count; + } + + /** + * @return array + */ + public static function tierData(): array + { + $tierData = json_decode(file_get_contents('data/tile-selection.json')); + $tileTiers = []; + + foreach($tierData as $tierLists) { + foreach($tierLists as $level => $list) { + foreach($list as $id) { + $tileTiers[$id] = TileTier::from($level); + } + } + } + + return $tileTiers; + } + + /** + * @return array + */ + public static function all(): array + { + if (! isset(self::$allTileData)) { + $allTileData = json_decode(file_get_contents('data/tiles.json'), true); + $tileTiers = self::tierData(); + /** @var array $tiles */ + $tiles = []; + + // merge tier and tile data + // We're keeping it in separate files for maintainability + foreach ($allTileData as $tileId => $tileData) { + + $nonDraftable = isset($tileData['nonDraftable']) && $tileData['nonDraftable'] == true; + + if ($nonDraftable) { + $tier = TileTier::NONE; + } else { + $tier = match($tileData['type']) { + 'red' => TileTier::RED, + 'blue' => $tileTiers[$tileId], + default => TileTier::NONE, + }; + } + + $tile = Tile::fromJsonData((string) $tileId, $tier, $tileData); + + $tiles[$tileId] = $tile; + } + self::$allTileData = $tiles; + } + + return self::$allTileData; + } +} diff --git a/app/TwilightImperium/TileTest.php b/app/TwilightImperium/TileTest.php new file mode 100644 index 0000000..f2873d3 --- /dev/null +++ b/app/TwilightImperium/TileTest.php @@ -0,0 +1,313 @@ +assertSame(7, $tile->totalResources); + $this->assertSame(5, $tile->totalInfluence); + $this->assertSame(1.5, $tile->optimalInfluence); + $this->assertSame(5.5, $tile->optimalResources); + $this->assertSame(7.0, $tile->optimalTotal); + } + + public static function jsonData() { + yield 'For a regular tile' => [ + 'jsonData' => [ + 'type' => 'red', + 'wormhole' => null, + 'anomaly' => null, + 'planets' => [], + 'stations' => [], + 'set' => Edition::BASE_GAME->value, + ], + 'expectedWormholes' => [], + ]; + yield 'For a tile with a wormhole' => [ + 'jsonData' => [ + 'type' => 'blue', + 'wormhole' => 'gamma', + 'anomaly' => null, + 'planets' => [], + 'stations' => [], + 'set' => Edition::PROPHECY_OF_KINGS->value, + ], + 'expectedWormholes' => [Wormhole::GAMMA], + ]; + yield 'For a tile with an anomaly' => [ + 'jsonData' => [ + 'type' => 'green', + 'wormhole' => null, + 'anomaly' => 'nebula', + 'planets' => [], + 'stations' => [], + 'set' => Edition::THUNDERS_EDGE->value, + ], + 'expectedWormholes' => [], + ]; + yield 'For a tile with no stations property' => [ + 'jsonData' => [ + 'type' => 'red', + 'wormhole' => null, + 'anomaly' => null, + 'planets' => [], + 'set' => Edition::DISCORDANT_STARS->value, + ], + 'expectedWormholes' => [], + ]; + yield 'For a tile with planets' => [ + 'jsonData' => [ + 'type' => 'red', + 'wormhole' => null, + 'anomaly' => null, + 'planets' => [ + [ + 'name' => 'Tinnes', + 'resources' => 2, + 'influence' => 1, + 'trait' => 'hazardous', + 'legendary' => false, + 'specialties' => [], + ], + [ + 'name' => 'Tinnes 2', + 'resources' => 1, + 'influence' => 1, + 'trait' => 'industrial', + 'legendary' => false, + 'specialties' => [], + ], + ], + 'set' => Edition::DISCORDANT_STARS_PLUS->value, + ], + 'expectedWormholes' => [], + ]; + yield 'For a tile with stations' => [ + 'jsonData' => [ + 'type' => 'red', + 'wormhole' => null, + 'anomaly' => null, + 'planets' => [], + 'stations' => [ + [ + 'name' => 'Tinnes', + 'resources' => 2, + 'influence' => 1, + ], + [ + 'name' => 'Tinnes 2', + 'resources' => 1, + 'influence' => 1, + ], + ], + 'set' => Edition::THUNDERS_EDGE->value, + ], + 'expectedWormholes' => [], + ]; + yield 'For a tile with hyperlanes' => [ + 'jsonData' => [ + 'type' => 'red', + 'wormhole' => null, + 'anomaly' => null, + 'planets' => [], + 'hyperlanes' => [ + [ + 0, + 3, + ], + [ + 0, + 2, + ], + ], + 'set' => Edition::PROPHECY_OF_KINGS->value, + ], + 'expectedWormholes' => [], + ]; + } + + #[DataProvider('jsonData')] + #[Test] + public function itCanBeInitializedFromJsonData(array $jsonData, array $expectedWormholes): void { + $id = 'tile-id'; + + $tile = Tile::fromJsonData($id, TileTier::MEDIUM, $jsonData); + $this->assertSame($id, $tile->id); + $this->assertSame($jsonData['anomaly'], $tile->anomaly); + $this->assertSame($jsonData['hyperlanes'] ?? [], $tile->hyperlanes); + $this->assertSame($expectedWormholes, $tile->wormholes); + } + + public static function anomalies() { + yield 'When tile has anomaly' => [ + 'anomaly' => 'nebula', + 'expected' => true, + ]; + yield 'When tile has no anomaly' => [ + 'anomaly' => null, + 'expected' => false, + ]; + } + + #[DataProvider('anomalies')] + #[Test] + public function itCanCheckForAnomalies(?string $anomaly, bool $expected): void { + $tile = TileFactory::make([], [], $anomaly); + + $this->assertSame($expected, $tile->hasAnomaly()); + } + + public static function wormholeTiles() { + yield 'When tile has wormhole' => [ + 'lookingFor' => Wormhole::ALPHA, + 'hasWormholes' => [Wormhole::ALPHA], + 'expected' => true, + ]; + yield 'When tile has multiple wormholes' => [ + 'lookingFor' => Wormhole::BETA, + 'hasWormholes' => [Wormhole::ALPHA, Wormhole::BETA], + 'expected' => true, + ]; + yield 'When tile does not have wormhole' => [ + 'lookingFor' => Wormhole::EPSILON, + 'hasWormholes' => [Wormhole::GAMMA], + 'expected' => false, + ]; + yield 'When tile has no wormholes' => [ + 'lookingFor' => Wormhole::DELTA, + 'hasWormholes' => [], + 'expected' => false, + ]; + } + + #[DataProvider('wormholeTiles')] + #[Test] + public function itCanCheckForWormholes(Wormhole $lookingFor, array $hasWormholes, bool $expected): void { + $tile = TileFactory::make([], $hasWormholes); + + $this->assertSame($expected, $tile->hasWormhole($lookingFor)); + } + + #[Test] + public function itCanCheckForLegendaryPlanets(): void { + $regularPlanet = new Planet('regular', 1, 1); + $legendaryPlanet = new Planet('legendary', 3, 3, 'Legend has it...'); + + $tileWithLegendary = TileFactory::make([ + $regularPlanet, + $legendaryPlanet, + ]); + $tileWithoutLegendary = TileFactory::make([ + $regularPlanet, + ]); + + $this->assertTrue($tileWithLegendary->hasLegendaryPlanet()); + $this->assertFalse($tileWithoutLegendary->hasLegendaryPlanet()); + } + + public static function tiles() + { + yield 'When tile has nothing special' => [ + 'tile' => TileFactory::make(), + 'expected' => [ + 'alpha' => 0, + 'beta' => 0, + 'legendary' => 0, + ], + ]; + yield 'When tile has wormhole' => [ + 'tile' => TileFactory::make([], [Wormhole::ALPHA]), + 'expected' => [ + 'alpha' => 1, + 'beta' => 0, + 'legendary' => 0, + ], + ]; + yield 'When tile has multiple wormholes' => [ + 'tile' => TileFactory::make([], [Wormhole::ALPHA, Wormhole::BETA]), + 'expected' => [ + 'alpha' => 1, + 'beta' => 1, + 'legendary' => 0, + ], + ]; + yield 'When tile has legendary planet' => [ + 'tile' => TileFactory::make([ + new Planet('test', 0, 0, 'yes'), + ]), + 'expected' => [ + 'alpha' => 0, + 'beta' => 0, + 'legendary' => 1, + ], + ]; + yield 'When tile has wormhole and legendary' => [ + 'tile' => TileFactory::make( + [new Planet('test', 0, 0, 'yes')], + [Wormhole::BETA], + ), + 'expected' => [ + 'alpha' => 0, + 'beta' => 1, + 'legendary' => 1, + ], + ]; + } + + #[DataProvider('tiles')] + #[Test] + public function itCanCountSpecials(Tile $tile, array $expected): void { + $count = Tile::countSpecials([$tile]); + $this->assertSame($expected['alpha'], $count['alpha']); + $this->assertSame($expected['beta'], $count['beta']); + $this->assertSame($expected['legendary'], $count['legendary']); + } + + // just to be sure + #[DataProvider('tiles')] + #[Test] + public function itCanCountSpecialsForMultipleTiles(Tile $tile, array $expected): void { + $count = Tile::countSpecials([$tile, $tile]); + $this->assertSame($expected['alpha'] * 2, $count['alpha']); + $this->assertSame($expected['beta'] * 2, $count['beta']); + $this->assertSame($expected['legendary'] * 2, $count['legendary']); + } + + public static function allJsonTiles(): iterable + { + $tileJsonData = json_decode(file_get_contents('data/tiles.json'), true); + foreach($tileJsonData as $key => $tileData) { + yield 'Tile #' . $key => [ + 'key' => $key, + 'tileData' => $tileData, + ]; + } + } + + #[Test] + #[DataProvider('allJsonTiles')] + public function tilesCanBeFetchedFromJson($key, $tileData): void + { + $tiles = Tile::all(); + $this->assertArrayHasKey($key, $tiles); + // the "toJson" tests should cover the rest, we're just making sure it can fetch all the tiles + } + +} \ No newline at end of file diff --git a/app/TwilightImperium/TileTier.php b/app/TwilightImperium/TileTier.php new file mode 100644 index 0000000..cf18a72 --- /dev/null +++ b/app/TwilightImperium/TileTier.php @@ -0,0 +1,23 @@ + 'α', + self::BETA => 'β', + self::GAMMA => 'γ', + self::DELTA => 'δ', + self::EPSILON => '&eplison;', + }; + } + + /** + * @todo refactor tiles.json to use arrays instead of a single string + * + * @param string $wormhole + * @return array + */ + public static function fromJsonData(?string $wormhole): array + { + if ($wormhole == null) return []; + if ($wormhole == 'alpha-beta') { + return [ + self::ALPHA, + self::BETA, + ]; + } if ($wormhole == 'all') { + // Mallice + return [ + self::ALPHA, + self::BETA, + self::GAMMA, + ]; + } else { + return [ + self::from($wormhole), + ]; + } + } +} \ No newline at end of file diff --git a/app/TwilightImperium/WormholeTest.php b/app/TwilightImperium/WormholeTest.php new file mode 100644 index 0000000..f58622b --- /dev/null +++ b/app/TwilightImperium/WormholeTest.php @@ -0,0 +1,35 @@ + [ + 'wormhole' => 'alpha', + 'expected' => [Wormhole::ALPHA], + ]; + yield 'When slice has multiple wormholes' => [ + 'wormhole' => 'alpha-beta', + 'expected' => [Wormhole::ALPHA, Wormhole::BETA], + ]; + yield 'When slice has no wormholes' => [ + 'wormhole' => null, + 'expected' => [], + ]; + } + + #[DataProvider('jsonData')] + #[Test] + public function itCanGetWormholesFromJsonData(?string $wormhole, array $expected): void + { + $this->assertSame($expected, Wormhole::fromJsonData($wormhole)); + } +} \ No newline at end of file diff --git a/app/api/claim.php b/app/api/claim.php deleted file mode 100644 index b3b38d2..0000000 --- a/app/api/claim.php +++ /dev/null @@ -1,24 +0,0 @@ -isPlayerSecret($playerId, get('secret')) && !$draft->isAdminPass(get('admin'))) return_error('You are not allowed to do this!'); - $result = $draft->unclaim($playerId); -} else { - $result = $draft->claim($playerId); -} - -$data = [ - 'draft' => $draft, - 'player' => $playerId, - 'success' => $result -]; - -if ($unclaim == false) { - $data['secret'] = $draft->getPlayerSecret($playerId); -} - -return_data($data); diff --git a/app/api/data.php b/app/api/data.php deleted file mode 100644 index 4ffd276..0000000 --- a/app/api/data.php +++ /dev/null @@ -1,10 +0,0 @@ - $draft, - 'success' => true -]); diff --git a/app/api/generate.php b/app/api/generate.php deleted file mode 100644 index 375d932..0000000 --- a/app/api/generate.php +++ /dev/null @@ -1,29 +0,0 @@ -isAdminPass(get('admin'))) return_error('You are not allowed to do this'); - if (!empty($draft->log())) return_error('Draft already in progress'); - - $regen_slices = get('shuffle_slices', "false") == "true"; - $regen_factions = get('shuffle_factions', "false") == "true"; - $regen_order = get('shuffle_order', "false") == "true"; - - $draft->regenerate($regen_slices, $regen_factions, $regen_order); - - return_data([ - 'ok' => true - ]); -} else { - $config = new GeneratorConfig(true); - $draft = Draft::createFromConfig($config); - $draft->save(); - return_data([ - 'id' => $draft->getId(), - 'admin' => $draft->getAdminPass() - ]); -} diff --git a/app/api/pick.php b/app/api/pick.php deleted file mode 100644 index 4ec956a..0000000 --- a/app/api/pick.php +++ /dev/null @@ -1,27 +0,0 @@ -isAdminPass(get('admin')); -if ($draft == null) return_error('draft not found'); -if ($player != $draft->currentPlayer() && !$is_admin) return_error('Not your turn!'); - -// Not enforcing this here yet because it would break for older drafts -if (!$is_admin && !$draft->isPlayerSecret($player, get('secret'))) return_error('You are not allowed to do this!'); - -if ($index != count($draft->log())) { - return_error('Draft data out of date, meaning: stuff has been picked or undone while this tab was open.'); -} - -$draft->pick($player, $category, $value); - -return_data([ - 'draft' => $draft, - 'success' => true -]); diff --git a/app/api/restore.php b/app/api/restore.php deleted file mode 100644 index b701aeb..0000000 --- a/app/api/restore.php +++ /dev/null @@ -1,21 +0,0 @@ -isAdminPass($secret)) { - return return_data([ - 'admin' => $secret, - 'success' => true - ]); -} - -$playerId = $draft->getPlayerIdBySecret($secret); - -if (!$playerId) return return_error('No session found with that passkey'); - -return_data([ - 'player' => $playerId, - 'secret' => $secret, - 'success' => true -]); diff --git a/app/api/undo.php b/app/api/undo.php deleted file mode 100644 index c25f6f4..0000000 --- a/app/api/undo.php +++ /dev/null @@ -1,15 +0,0 @@ -isAdminPass(get('admin')); - -if (!$is_admin) return_error("Only the admin can undo"); -if (!count($draft->log())) return_error("Nothing to undo"); - -$draft->undoLastAction(); - -return_data([ - 'draft' => $draft, - 'success' => true -]); diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..50eb78c --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,154 @@ +'; + foreach($variables as $v) { + var_dump($v); + } + echo ''; + } +} + +if (! function_exists('app')) { + function app():App\Application + { + return App\Application::getInstance(); + } +} + +if (! function_exists('dispatch')) { + function dispatch(App\Shared\Command $command): mixed + { + return app()->handle($command); + } +} + +if (! function_exists('dd')) { + function dd(...$variables): void + { + echo '
';
+        foreach ($variables as $var) {
+            var_dump($var);
+        }
+        die('
'); + } +} + +if (! function_exists('e')) { + function e($condition, $yes, $no = ''): void + { + if ($condition) echo $yes; + else echo $no; + } +} + +if (! function_exists('yesno')) { + /** + * return "yes" or "no" based on condition + * + * @param $condition + * @return string + */ + function yesno($condition): string + { + return $condition ? 'yes' : 'no'; + } +} + +if (! function_exists('env')) { + function env($key, $defaultValue = null) + { + return $_ENV[$key] ?? $defaultValue; + } +} + +if (! function_exists('human_filesize')) { + function human_filesize($bytes, $dec = 2): string { + + $size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + $factor = (int) floor((strlen($bytes) - 1) / 3); + if ($factor == 0) $dec = 0; + + return sprintf("%.{$dec}f %s", $bytes / (1024 ** $factor), $size[$factor]); + } +} + +if (! function_exists('get')) { + function get($key, $default = null): mixed + { + return $_GET[$key] ?? $default; + } +} + +if (! function_exists('url')) { + function url($uri): string + { + return env('URL', 'https://milty.shenanigans.be/') . $uri; + } +} + +if (! function_exists('asset_url')) { + function asset_url($uri): string + { + return url($uri . '?v=' . (env('DEBUG', false) ? (string) time() : env('VERSION'))); + } +} + +if (! function_exists('ordinal')) { + function ordinal($number): string + { + $ends = ['th', 'st', 'nd', 'rd', 'th', 'th', 'th', 'th', 'th', 'th']; + if ((($number % 100) >= 11) && (($number % 100) <= 13)) + return $number . 'th'; + else + return $number . $ends[$number % 10]; + } +} + +if (! function_exists('class_uses_recursive')) { + /** + * Returns all traits used by a class, its parent classes and trait of their traits. + * ("Borrowed" from Laravel) + * + * @param object|string $class + * @return array + */ + function class_uses_recursive($class) + { + if (is_object($class)) { + $class = get_class($class); + } + + $results = []; + + foreach (array_reverse(class_parents($class) ?: []) + [$class => $class] as $class) { + $results += trait_uses_recursive($class); + } + + return array_unique($results); + } +} + +if (! function_exists('trait_uses_recursive')) { + /** + * Returns all traits used by a trait and its traits. + *("Borrowed" from Laravel) + * + * @param object|string $trait + * @return array + */ + function trait_uses_recursive($trait) + { + $traits = class_uses($trait) ?: []; + + foreach ($traits as $trait) { + $traits += trait_uses_recursive($trait); + } + + return $traits; + } +} diff --git a/app/routes.php b/app/routes.php new file mode 100644 index 0000000..5420392 --- /dev/null +++ b/app/routes.php @@ -0,0 +1,15 @@ + App\Http\RequestHandlers\HandleViewFormRequest::class, + '/d/{id}' => App\Http\RequestHandlers\HandleViewDraftRequest::class, + '/api/generate' => App\Http\RequestHandlers\HandleGenerateDraftRequest::class, + '/api/regenerate' => App\Http\RequestHandlers\HandleRegenerateDraftRequest::class, + '/api/pick' => App\Http\RequestHandlers\HandlePickRequest::class, + '/api/claim' => App\Http\RequestHandlers\HandleClaimOrUnclaimPlayerRequest::class, + '/api/restore' => App\Http\RequestHandlers\HandleRestoreClaimRequest::class, + '/api/undo' => App\Http\RequestHandlers\HandleUndoRequest::class, + '/api/draft/{id}' => App\Http\RequestHandlers\HandleGetDraftRequest::class, +]; diff --git a/bootstrap/boot.php b/bootstrap/boot.php index a4f13be..267e43b 100644 --- a/bootstrap/boot.php +++ b/bootstrap/boot.php @@ -5,51 +5,7 @@ } require_once 'vendor/autoload.php'; -require_once 'bootstrap/helpers.php'; -function app() { - try { - $requestPath = $_SERVER['REQUEST_URI']; - - $requestChunks = explode('?', $requestPath); - - if($requestChunks[0] == '/') { - require_once 'templates/generate.php'; - } else { - $pathChunks = explode('/', substr($requestChunks[0], 1)); - - if(count($pathChunks) == 0) { - throw new Exception("Something went wrong decoding path"); - } - - if($pathChunks[0] == 'd') { - if(!isset($pathChunks[1])) { - abort(404, 'No draft specified'); - } - - define('DRAFT_ID', $pathChunks[1]); - require_once 'templates/draft.php'; - } elseif ($pathChunks[0] == 'api') { - - if(!isset($pathChunks[1])) { - abort(404, 'No API endpoint specified'); - } - - $apiFile = __DIR__ . '/../app/api/' . $pathChunks[1] . '.php'; - - if (file_exists($apiFile)) { - require_once $apiFile; - } else { - abort(404, 'Unknown API endpoint'); - } - } else { - abort(404, 'Unknown path'); - } - } - } catch (Exception $e) { - abort(500, $e->getMessage()); - } -} try { $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); @@ -76,4 +32,4 @@ function app() { die("

STORAGE_PATH does not exist or is not writeable.

"); } } -} +} \ No newline at end of file diff --git a/bootstrap/helpers.php b/bootstrap/helpers.php deleted file mode 100644 index 19cb358..0000000 --- a/bootstrap/helpers.php +++ /dev/null @@ -1,69 +0,0 @@ -'; - var_dump($var); - echo ''; -} - -function dd(...$variables) -{ - echo '
';
-    foreach($variables as $var) {
-        var_dump($var);
-    }
-    die('
'); -} - -function e($condition, $yes, $no = '') -{ - if ($condition) echo $yes; - else echo $no; -} - -function get($param, $default = null) -{ - if (isset($_POST[$param])) return $_POST[$param]; - if (isset($_GET[$param])) return $_GET[$param]; - return $default; -} - -function url($uri) -{ - return $_ENV['URL'] . $uri; -} - -function return_error($err) -{ - die(json_encode(['error' => $err])); -} - -function return_data($data) -{ - header('Access-Control-Allow-Origin: *'); - die(json_encode($data)); -} - -function ordinal($number) -{ - $ends = array('th', 'st', 'nd', 'rd', 'th', 'th', 'th', 'th', 'th', 'th'); - if ((($number % 100) >= 11) && (($number % 100) <= 13)) - return $number . 'th'; - else - return $number . $ends[$number % 10]; -} - -function abort($code, $message = null) { - http_response_code($code); - switch($code) { - case 404: - die($message ?? 'Not found'); - default: - if(!$_ENV['DEBUG']) { - die('Something went wrong'); - } else { - die($message ?? 'Something went wrong'); - } - } -} \ No newline at end of file diff --git a/composer.json b/composer.json index 1d02be2..5be925b 100644 --- a/composer.json +++ b/composer.json @@ -2,11 +2,31 @@ "require": { "vlucas/phpdotenv": "^5.4", "aws/aws-sdk-php": "^3.339", - "ext-json": "*" + "ext-json": "*", + "guzzlehttp/guzzle": "^7.9" }, "autoload": { + "files": [ + "app/helpers.php" + ], "psr-4": { "App\\": "app/" } + }, + "scripts": { + "phpstan": "vendor/bin/phpstan analyse --memory-limit=2G --ansi", + "paratest": [ + "Composer\\Config::disableProcessTimeout", + "vendor/bin/paratest -p12 --passthru-php=\"'-d' 'memory_limit=2G'\"" + ], + "cs:check": "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --verbose --dry-run", + "cs:fix": "PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --verbose", + "phpunit": "vendor/bin/phpunit $1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "brianium/paratest": "^7.8", + "fakerphp/faker": "^1.24", + "friendsofphp/php-cs-fixer": "^3.92" } } diff --git a/data/FactionDataTest.php b/data/FactionDataTest.php new file mode 100644 index 0000000..7f05146 --- /dev/null +++ b/data/FactionDataTest.php @@ -0,0 +1,62 @@ + $factionData) { + yield 'For Faction ' . $factionData['name'] => [ + 'key' => $key, + 'factionData' => $factionData + ]; + } + } + + #[Test] + #[DataProvider('allJsonFactions')] + public function eachFactionHasData($key, $factionData) { + $this->assertNotEmpty($factionData['set']); + if ($key != 'The Council Keleres') { + $this->assertNotEmpty($factionData['homesystem']); + } + $this->assertNotEmpty($factionData['name']); + $this->assertNotEmpty($factionData['wiki']); + } + + + #[Test] + #[DataProvider('allJsonFactions')] + public function eachFactionHasNameAsKey($key, $factionData) { + $this->assertSame($factionData['name'], $key); + } + + /** + * Changing the name of a faction would break old drafts + * + * @return void + */ + #[Test] + public function allHistoricFactionsHaveData() { + $historicFactions = json_decode(file_get_contents('data/historic-test-data/all-factions-ever.json')); + + $currentFactions = array_keys(self::getJsonData()); + + foreach($historicFactions as $name) { + $this->assertContains($name, $currentFactions); + } + } +} \ No newline at end of file diff --git a/data/TileDataTest.php b/data/TileDataTest.php new file mode 100644 index 0000000..19f68f9 --- /dev/null +++ b/data/TileDataTest.php @@ -0,0 +1,133 @@ + $tileData) { + yield "Tile #" . $key => [ + 'id' => $key, + 'data' => $tileData + ]; + } + } + + #[Test] + #[DataProvider('allJsonTiles')] + public function allPlanetsInTilesJsonAreValid($id, $data) { + $planetJsonData = []; + if (isset($data['planets'])) { + foreach($data['planets'] as $p) { + $planetJsonData[] = $p; + } + } + + $planets = array_map(fn (array $data) => Planet::fromJsonData($data), $planetJsonData); + + if (empty($planetJsonData)) { + $this->expectNotToPerformAssertions(); + } else { + $this->assertCount(count($planetJsonData), $planets); + } + + } + + #[Test] + #[DataProvider('allJsonTiles')] + public function allSpaceStationsInTilesJsonAreValid($id, $data) { + $spaceStationData = $data['stations'] ?? []; + + $spaceStations = array_map(fn (array $data) => SpaceStation::fromJsonData($data), $spaceStationData); + + if (empty($spaceStationData)) { + $this->expectNotToPerformAssertions(); + } else { + $this->assertCount(count($spaceStationData), $spaceStations); + } + } + + /** + * If we ever change tilenames again (bad idea, turns out) these next test should prevent us from breaking old drafts + * + * @return void + */ + #[Test] + public function allHistoricTileIdsHaveData() + { + $historicTileIds = json_decode(file_get_contents('data/historic-test-data/all-tiles-ever.json')); + + $currentTileIds = array_keys(self::getJsonData()); + + foreach($historicTileIds as $id) { + $this->assertContains($id, $currentTileIds); + } + } + + #[Test] + #[DataProvider('allJsonTiles')] + public function allDraftableBlueTilesAreInTiers($id, $data) + { + $tileTiers = Tile::tierData(); + + $nonDraftAble = (isset($data['nonDraftable']) && $data['nonDraftable'] == true); + + if ($data['type'] == "blue" && !$nonDraftAble) { + $this->assertArrayHasKey($id, $tileTiers); + } else { + $this->expectNotToPerformAssertions(); + } + } + + + #[Test] + #[DataProvider('allJsonTiles')] + public function allNonDraftableTilesAreMarked($id, $data) + { + $nonDraftAble = (isset($data['nonDraftable']) && $data['nonDraftable'] == true); + + if (in_array($id, ['18', '81', '112', '82'])) { + $this->assertTrue($nonDraftAble); + } else { + $this->assertFalse($nonDraftAble); + } + } + + #[Test] + #[DataProvider('allJsonTiles')] + public function tilesCanBeFetchedFromJson($id, $data) + { + // we're ignoring the tier for now, that's for TileTest + $tile = Tile::fromJsonData($id,TileTier::MEDIUM, $data); + $this->assertSame($data['anomaly'] ?? null, $tile->anomaly); + } + + /** + * This is a really useful test, but first the tilenames need to be sorted out + **/ +// public function allHistoricTileIdsHaveImages() { +// $historicTileIds = json_decode(file_get_contents('data/all-tiles-ever.json')); +// $tiles = self::getJsonData(); +// +// foreach($historicTileIds as $id) { +// if (!isset($tiles[$id]['faction'])) { +// $this->assertFileExists('img/tiles/ST_' . $id . '.png'); +// } +// } +// } +} \ No newline at end of file diff --git a/data/factions.json b/data/factions.json index a854251..c24923c 100644 --- a/data/factions.json +++ b/data/factions.json @@ -175,278 +175,277 @@ "homesystem": "0", "set": "keleres" }, + "Last Bastion": { + "id": "last_bastion", + "name": "Last Bastion", + "homesystem": "92", + "wiki": "https://twilight-imperium.fandom.com/wiki/Last_Bastion", + "set": "te" + }, + "The Ral Nel Consortium": { + "id": "ral_nel", + "name": "The Ral Nel Consortium", + "homesystem": "93", + "wiki": "https://twilight-imperium.fandom.com/wiki/The_Ral_Nel_Consortium", + "set": "te" + }, + "The Deepwrought Scholarate": { + "id": "deepwrought_scholarate", + "name": "The Deepwrought Scholarate", + "homesystem": "95", + "wiki": "https://twilight-imperium.fandom.com/wiki/The_Deepwrought_Scholarate", + "set": "te" + }, + "The Crimson Rebellion": { + "id": "crimson_rebellion", + "name": "The Crimson Rebellion", + "homesystem": "94", + "wiki": "https://twilight-imperium.fandom.com/wiki/The_Crimson_Rebellion", + "set": "te" + }, + "The Firmament / The Obsidian": { + "id": "firmament_obsidian", + "name": "The Firmament / The Obsidian", + "homesystem": "96a", + "wiki": "https://twilight-imperium.fandom.com/wiki/The_Firmament_/_The_Obsidian", + "set": "te" + }, "Augurs of Ilyxum": { "id": "ilyxum", "name": "Augurs of Ilyxum", - "homesystem": "0", + "homesystem": "4215", "wiki": "https://twilight-imperium.fandom.com/wiki/Augurs_of_Ilyxum_(UNOFFICIAL)", "set": "discordant" }, "Celdauri Trade Confederation": { "id": "celdauri", "name": "Celdauri Trade Confederation", - "homesystem": "0", + "homesystem": "4218", "wiki": "https://twilight-imperium.fandom.com/wiki/Celdauri_Trade_Confederation_(UNOFFICIAL)", "set": "discordant" }, "Dih-Mohn Flotilla": { "id": "dihmohn", "name": "Dih-Mohn Flotilla", - "homesystem": "0", + "homesystem": "4210", "wiki": "https://twilight-imperium.fandom.com/wiki/Dih-Mohn_Flotilla_(UNOFFICIAL)", "set": "discordant" }, "Florzen Profiteers": { "id": "florzen", "name": "Florzen Profiteers", - "homesystem": "0", + "homesystem": "4217", "wiki": "https://twilight-imperium.fandom.com/wiki/Florzen_Profiteers_(UNOFFICIAL)", "set": "discordant" }, "Free Systems Compact": { "id": "freesystems", "name": "Free Systems Compact", - "homesystem": "0", + "homesystem": "4222", "wiki": "https://twilight-imperium.fandom.com/wiki/Free_Systems_Compact_(UNOFFICIAL)", "set": "discordant" }, "Ghemina Raiders": { "id": "ghemina", "name": "Ghemina Raiders", - "homesystem": "0", + "homesystem": "4212", "wiki": "https://twilight-imperium.fandom.com/wiki/Ghemina_Raiders_(UNOFFICIAL)", "set": "discordant" }, "Glimmer of Mortheus": { "id": "mortheus", "name": "Glimmer of Mortheus", - "homesystem": "0", + "homesystem": "4214", "wiki": "https://twilight-imperium.fandom.com/wiki/Glimmer_of_Mortheus_(UNOFFICIAL)", "set": "discordant" }, "Kollecc Society": { "id": "kollecc", "name": "Kollecc Society", - "homesystem": "0", + "homesystem": "4202", "wiki": "https://twilight-imperium.fandom.com/wiki/Kollecc_Society_(UNOFFICIAL)", "set": "discordant" }, "Kortali Tribunal": { "id": "kortali", "name": "Kortali Tribunal", - "homesystem": "0", + "homesystem": "4220", "wiki": "https://twilight-imperium.fandom.com/wiki/Kortali_Tribunal_(UNOFFICIAL)", "set": "discordant" }, "Li-Zho Dynasty": { "id": "lizho", "name": "Li-Zho Dynasty", - "homesystem": "0", + "homesystem": "4223", "wiki": "https://twilight-imperium.fandom.com/wiki/Li-Zho_Dynasty_(UNOFFICIAL)", "set": "discordant" }, "L'Tokk Khrask": { "id": "khrask", "name": "L'Tokk Khrask", - "homesystem": "0", + "homesystem": "4201", "wiki": "https://twilight-imperium.fandom.com/wiki/L%27tokk_Khrask_(UNOFFICIAL)", "set": "discordant" }, "Mirveda Protectorate": { "id": "mirveda", "name": "Mirveda Protectorate", - "homesystem": "0", + "homesystem": "4219", "wiki": "https://twilight-imperium.fandom.com/wiki/Mirveda_Protectorate_(UNOFFICIAL)", "set": "discordant" }, "Myko-Mentori": { "id": "myko", "name": "Myko-Mentori", - "homesystem": "0", + "homesystem": "4206", "wiki": "https://twilight-imperium.fandom.com/wiki/Myko-Mentori_(UNOFFICIAL)", "set": "discordant" }, "Nivyn Star Kings": { "id": "nivyn", "name": "Nivyn Star Kings", - "homesystem": "0", + "homesystem": "4211", "wiki": "https://twilight-imperium.fandom.com/wiki/Nivyn_Star_Kings_(UNOFFICIAL)", "set": "discordant" }, "Olradin League": { "id": "olradin", "name": "Olradin League", - "homesystem": "0", + "homesystem": "4205", "wiki": "https://twilight-imperium.fandom.com/wiki/Olradin_League_(UNOFFICIAL)", "set": "discordant" }, "Roh'Dhna Mechatronics": { "id": "rohdina", "name": "Roh'Dhna Mechatronics", - "homesystem": "0", + "homesystem": "4208", "wiki": "https://twilight-imperium.fandom.com/wiki/Roh%27Dhna_Mechatronics_(UNOFFICIAL)", "set": "discordant" }, "Savages of Cymiae": { "id": "cymiae", "name": "Savages of Cymiae", - "homesystem": "0", + "homesystem": "4203", "wiki": "https://twilight-imperium.fandom.com/wiki/Savages_of_Cymiae_(UNOFFICIAL)", "set": "discordant" }, "Shipwrights of Axis": { "id": "axis", "name": "Shipwrights of Axis", - "homesystem": "0", + "homesystem": "4204", "wiki": "https://twilight-imperium.fandom.com/wiki/Shipwrights_of_Axis_(UNOFFICIAL)", "set": "discordant" }, "Tnelis Syndicate": { "id": "tnelis", "name": "Tnelis Syndicate", - "homesystem": "0", + "homesystem": "4207", "wiki": "https://twilight-imperium.fandom.com/wiki/Tnelis_Syndicate_(UNOFFICIAL)", "set": "discordant" }, "Vaden Banking Clans": { "id": "vaden", "name": "Vaden Banking Clans", - "homesystem": "0", + "homesystem": "4213", "wiki": "https://twilight-imperium.fandom.com/wiki/Vaden_Banking_Clans_(UNOFFICIAL)", "set": "discordant" }, "Vaylerian Scourge": { "id": "vaylerian", "name": "Vaylerian Scourge", - "homesystem": "0", + "homesystem": "4209", "wiki": "https://twilight-imperium.fandom.com/wiki/Vaylerian_Scourge_(UNOFFICIAL)", "set": "discordant" }, "Veldyr Sovereignty": { "id": "veldyr", "name": "Veldyr Sovereignty", - "homesystem": "0", + "homesystem": "4200", "wiki": "https://twilight-imperium.fandom.com/wiki/Veldyr_Sovereignty_(UNOFFICIAL)", "set": "discordant" }, "Zealots of Rhodun": { "id": "rhodun", "name": "Zealots of Rhodun", - "homesystem": "0", + "homesystem": "4221", "wiki": "https://twilight-imperium.fandom.com/wiki/Zealots_of_Rhodun_(UNOFFICIAL)", "set": "discordant" }, "Zelian Purifier": { "id": "zelian", "name": "Zelian Purifier", - "homesystem": "0", + "homesystem": "4216", "wiki": "https://twilight-imperium.fandom.com/wiki/Zelian_Purifier_(UNOFFICIAL)", "set": "discordant" }, "Bentor Conglomerate": { "id": "bentor", "name": "Bentor Conglomerate", - "homesystem": "0", + "homesystem": "4230", "wiki": "https://twilight-imperium.fandom.com/wiki/Bentor_Conglomerate_(UNOFFICIAL)", "set": "discordantexp" }, "Berserkers of Kjalengard": { "id": "kjalengard", "name": "Berserkers of Kjalengard", - "homesystem": "0", + "homesystem": "4235", "wiki": "https://twilight-imperium.fandom.com/wiki/Berserkers_of_Kjalengard_(UNOFFICIAL)", "set": "discordantexp" }, "Cheiran Hordes": { "id": "cheiran", "name": "Cheiran Hordes", - "homesystem": "0", + "homesystem": "4234", "wiki": "https://twilight-imperium.fandom.com/wiki/Cheiran_Hordes_(UNOFFICIAL)", "set": "discordantexp" }, "Edyn Mandate": { "id": "edyn", "name": "Edyn Mandate", - "homesystem": "0", + "homesystem": "4236", "wiki": "https://twilight-imperium.fandom.com/wiki/Edyn_Mandate_(UNOFFICIAL)", "set": "discordantexp" }, "Ghoti Wayfarers": { "id": "ghoti", "name": "Ghoti Wayfarers", - "homesystem": "0", + "homesystem": "4227", "wiki": "https://twilight-imperium.fandom.com/wiki/Ghoti_Wayfarers_(UNOFFICIAL)", "set": "discordantexp" }, "Gledge Union": { "id": "gledge", "name": "Gledge Union", - "homesystem": "0", + "homesystem": "4228", "wiki": "https://twilight-imperium.fandom.com/wiki/Gledge_Union_(UNOFFICIAL)", "set": "discordantexp" }, "Kyro Sodality": { "id": "kyro", "name": "Kyro Sodality", - "homesystem": "0", + "homesystem": "4229", "wiki": "https://twilight-imperium.fandom.com/wiki/Kyro_Sodality_(UNOFFICIAL)", "set": "discordantexp" }, "Lanefir Remnants": { "id": "lanefir", "name": "Lanefir Remnants", - "homesystem": "0", + "homesystem": "4232", "wiki": "https://twilight-imperium.fandom.com/wiki/Lanefir_Remnants_(UNOFFICIAL)", "set": "discordantexp" }, "The Monks of Kolume": { "id": "kolume", "name": "The Monks of Kolume", - "homesystem": "0", + "homesystem": "4233", "wiki": "https://twilight-imperium.fandom.com/wiki/Monks_of_Kolume_(UNOFFICIAL)", "set": "discordantexp" }, "Nokar Sellships": { "id": "nokar", "name": "Nokar Sellships", - "homesystem": "0", + "homesystem": "4231", "wiki": "https://twilight-imperium.fandom.com/wiki/Nokar_Sellships_(UNOFFICIAL)", "set": "discordantexp" - }, - "Last Bastion": { - "id": "last_bastion", - "name": "Last Bastion", - "homesystem": "92", - "wiki": "https://twilight-imperium.fandom.com/wiki/Last_Bastion", - "set": "te" - }, - "The Ral Nel Consortium": { - "id": "ral_nel", - "name": "The Ral Nel Consortium", - "homesystem": "93", - "wiki": "https://twilight-imperium.fandom.com/wiki/The_Ral_Nel_Consortium", - "set": "te" - }, - "The Deepwrought Scholarate": { - "id": "deepwrought_scholarate", - "name": "The Deepwrought Scholarate", - "homesystem": "95", - "wiki": "https://twilight-imperium.fandom.com/wiki/The_Deepwrought_Scholarate", - "set": "te" - }, - "The Crimson Rebellion": { - "id": "crimson_rebellion", - "name": "The Crimson Rebellion", - "homesystem": "94", - "wiki": "https://twilight-imperium.fandom.com/wiki/The_Crimson_Rebellion", - "set": "te" - }, - "The Firmament / The Obsidian": { - "id": "firmament_obsidian", - "name": "The Firmament / The Obsidian", - "homesystem": "96a", - "wiki": "https://twilight-imperium.fandom.com/wiki/The_Firmament_/_The_Obsidian", - "set": "te" } - } diff --git a/data/historic-test-data/all-factions-ever.json b/data/historic-test-data/all-factions-ever.json new file mode 100644 index 0000000..1fd2afd --- /dev/null +++ b/data/historic-test-data/all-factions-ever.json @@ -0,0 +1,66 @@ +[ + "Sardakk N'orr", + "The Arborec", + "The Barony of Letnev", + "The Clan of Saar", + "The Embers of Muaat", + "The Emirates of Hacan", + "The Federation of Sol", + "The Ghosts of Creuss", + "The L1z1x Mindnet", + "The Mentak Coalition", + "The Naalu Collective", + "The Nekro Virus", + "The Universities of Jol-Nar", + "The Winnu", + "The Xxcha Kingdom", + "The Yin Brotherhood", + "The Yssaril Tribes", + "The Argent Flight", + "The Empyrean", + "The Mahact Gene-sorcerers", + "The Naaz-Rokha Alliance", + "The Nomad", + "The Titans of Ul", + "The Vuil'raith Cabal", + "The Council Keleres", + "Augurs of Ilyxum", + "Celdauri Trade Confederation", + "Dih-Mohn Flotilla", + "Florzen Profiteers", + "Free Systems Compact", + "Ghemina Raiders", + "Glimmer of Mortheus", + "Kollecc Society", + "Kortali Tribunal", + "Li-Zho Dynasty", + "L'Tokk Khrask", + "Mirveda Protectorate", + "Myko-Mentori", + "Nivyn Star Kings", + "Olradin League", + "Roh'Dhna Mechatronics", + "Savages of Cymiae", + "Shipwrights of Axis", + "Tnelis Syndicate", + "Vaden Banking Clans", + "Vaylerian Scourge", + "Veldyr Sovereignty", + "Zealots of Rhodun", + "Zelian Purifier", + "Bentor Conglomerate", + "Berserkers of Kjalengard", + "Cheiran Hordes", + "Edyn Mandate", + "Ghoti Wayfarers", + "Gledge Union", + "Kyro Sodality", + "Lanefir Remnants", + "The Monks of Kolume", + "Nokar Sellships", + "Last Bastion", + "The Ral Nel Consortium", + "The Deepwrought Scholarate", + "The Crimson Rebellion", + "The Firmament \/ The Obsidian" +] \ No newline at end of file diff --git a/data/historic-test-data/all-tiles-ever.json b/data/historic-test-data/all-tiles-ever.json new file mode 100644 index 0000000..9de81e1 --- /dev/null +++ b/data/historic-test-data/all-tiles-ever.json @@ -0,0 +1,190 @@ +[ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + "83A", + "83B", + "84A", + "84B", + "85A", + "85B", + "86A", + "86B", + "87A", + "87B", + "88A", + "88B", + "89A", + "89B", + "90A", + "90B", + "91A", + "91B", + 92, + 93, + 94, + 95, + "96a", + "96b", + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 4200, + 4222, + 4223, + 4201, + 4212, + 4213, + 4214, + 4215, + 4204, + 4205, + 4206, + 4207, + 4203, + 4208, + 4216, + 4209, + 4217, + 4210, + 4218, + 4211, + 4219, + 4220, + 4202, + 4221, + 4224, + 4225, + 4230, + 4231, + 4228, + 4232, + 4229, + 4227, + 4233, + 4234, + 4236, + 4235, + 4253, + 4254, + 4255, + 4256, + 4257, + 4258, + 4259, + 4260, + 4261, + 4262, + 4263, + 4264, + 4265, + 4266, + 4267, + 4268, + 4269, + 4270, + 4271, + 4272, + 4273, + 4274, + 4275, + 4276 +] \ No newline at end of file diff --git a/data/test-drafts/draft.march2024.pre-alliance-pre-secret.json b/data/test-drafts/draft.march2024.pre-alliance-pre-secret.json new file mode 100644 index 0000000..fbc5aac --- /dev/null +++ b/data/test-drafts/draft.march2024.pre-alliance-pre-secret.json @@ -0,0 +1,253 @@ +{ + "done": false, + "id": "693e9b3fb8452", + "draft": { + "players": { + "p_564cbfb050046007faea784931962140": { + "id": "p_564cbfb050046007faea784931962140", + "name": "Ben", + "claimed": false, + "position": null, + "slice": null, + "faction": "Free Systems Compact" + }, + "p_ffbfdb5608320d54d904e07eae678c2a": { + "id": "p_ffbfdb5608320d54d904e07eae678c2a", + "name": "Esther", + "claimed": false, + "position": null, + "slice": null, + "faction": "Last Bastion" + }, + "p_67161c4c41518e0e1cfc4e2c044269e2": { + "id": "p_67161c4c41518e0e1cfc4e2c044269e2", + "name": "Frank", + "claimed": false, + "position": null, + "slice": null, + "faction": "Gledge Union" + }, + "p_e6556957358fe40ec9c0bf4a9ddab0ac": { + "id": "p_e6556957358fe40ec9c0bf4a9ddab0ac", + "name": "Desmond", + "claimed": false, + "position": null, + "slice": "1", + "faction": null + }, + "p_404e5780ee88e69cab1602093431c2a8": { + "id": "p_404e5780ee88e69cab1602093431c2a8", + "name": "Amy", + "claimed": false, + "position": null, + "slice": null, + "faction": null + }, + "p_e37356083346f2cb785de89c36faff1e": { + "id": "p_e37356083346f2cb785de89c36faff1e", + "name": "Charlie", + "claimed": false, + "position": null, + "slice": null, + "faction": null + } + }, + "log": [ + { + "player": "p_564cbfb050046007faea784931962140", + "category": "faction", + "value": "Free Systems Compact" + }, + { + "player": "p_ffbfdb5608320d54d904e07eae678c2a", + "category": "faction", + "value": "Last Bastion" + }, + { + "player": "p_67161c4c41518e0e1cfc4e2c044269e2", + "category": "faction", + "value": "Gledge Union" + }, + { + "player": "p_e6556957358fe40ec9c0bf4a9ddab0ac", + "category": "slice", + "value": "1" + } + ], + "current": "p_404e5780ee88e69cab1602093431c2a8" + }, + "slices": [ + { + "tiles": [ + 61, + 48, + 59, + 49, + 40 + ], + "specialties": [ + "warfare", + "propulsion" + ], + "wormholes": [ + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 3, + "total_resources": 3, + "optimal_influence": 3, + "optimal_resources": 2 + }, + { + "tiles": [ + 60, + 72, + 39, + 68, + 25 + ], + "specialties": [ + "warfare" + ], + "wormholes": [ + "alpha", + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 6, + "total_resources": 11, + "optimal_influence": 1, + "optimal_resources": 10 + }, + { + "tiles": [ + 27, + 44, + 76, + 79, + 35 + ], + "specialties": [ + "biotic", + "biotic" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 10, + "total_resources": 11, + "optimal_influence": 7, + "optimal_resources": 7 + }, + { + "tiles": [ + 45, + 34, + 46, + 11, + 36 + ], + "specialties": [ + "propulsion" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 8, + "optimal_influence": 5.5, + "optimal_resources": 5.5 + }, + { + "tiles": [ + 30, + 12, + 50, + 22, + 17 + ], + "specialties": [ + "biotic" + ], + "wormholes": [ + "delta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 9, + "total_resources": 7, + "optimal_influence": 7.5, + "optimal_resources": 3.5 + }, + { + "tiles": [ + 70, + 73, + 12, + 77, + 21 + ], + "specialties": [ + "cybernetic", + "propulsion" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 12, + "total_resources": 6, + "optimal_influence": 10.5, + "optimal_resources": 2.5 + } + ], + "factions": [ + "Veldyr Sovereignty", + "Free Systems Compact", + "Kyro Sodality", + "Ghoti Wayfarers", + "Zelian Purifier", + "Gledge Union", + "Nokar Sellships", + "Last Bastion", + "Savages of Cymiae" + ], + "config": { + "players": [ + "Ben", + "Esther", + "Frank", + "Desmond", + "Amy", + "Charlie" + ], + "name": "Pre-alliance draft", + "num_slices": 6, + "num_factions": 9, + "include_pok": true, + "include_ds_tiles": true, + "include_te_tiles": true, + "include_base_factions": true, + "include_pok_factions": true, + "include_keleres": true, + "include_discordant": true, + "include_discordantexp": true, + "include_te_factions": true, + "preset_draft_order": false, + "min_wormholes": 0, + "min_legendaries": 0, + "max_1_wormhole": false, + "minimum_optimal_influence": 4, + "minimum_optimal_resources": 2.5, + "minimum_optimal_total": 9, + "maximum_optimal_total": 13, + "custom_factions": null, + "custom_slices": null, + "alliance": null, + "seed": 896134222637712 + }, + "name": "Pre-alliance draft" +} \ No newline at end of file diff --git a/data/test-drafts/draft.november2025.alliance.json b/data/test-drafts/draft.november2025.alliance.json new file mode 100644 index 0000000..6e95d43 --- /dev/null +++ b/data/test-drafts/draft.november2025.alliance.json @@ -0,0 +1,242 @@ +{ + "done": false, + "id": "693e9b8424c14", + "secrets": { + "admin_pass": "84fea5bc6fef48ac892782bcc1d2d865" + }, + "draft": { + "players": { + "p_9d9b8e01cad9f44e964b31d7a6795882": { + "id": "p_9d9b8e01cad9f44e964b31d7a6795882", + "name": "Ben", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": "B" + }, + "p_bd2260ba98e0234d4df6b87595e4843c": { + "id": "p_bd2260ba98e0234d4df6b87595e4843c", + "name": "Charlie", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": "B" + }, + "p_5171f5f7525f25642e9293a4dd4832ee": { + "id": "p_5171f5f7525f25642e9293a4dd4832ee", + "name": "Amy", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": "A" + }, + "p_47144de1e821f459df3f088a950e8fc2": { + "id": "p_47144de1e821f459df3f088a950e8fc2", + "name": "Desmond", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": "A" + } + }, + "log": [], + "current": "p_9d9b8e01cad9f44e964b31d7a6795882" + }, + "slices": [ + { + "tiles": [ + 29, + 40, + 60, + 46, + 76 + ], + "specialties": [ + "biotic" + ], + "wormholes": [ + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 10, + "total_resources": 5, + "optimal_influence": 8.5, + "optimal_resources": 2.5 + }, + { + "tiles": [ + 64, + 59, + 67, + 42, + 36 + ], + "specialties": [ + "propulsion" + ], + "wormholes": [ + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 7, + "total_resources": 9, + "optimal_influence": 5, + "optimal_resources": 7 + }, + { + "tiles": [ + 38, + 37, + 49, + 50, + 61 + ], + "specialties": [ + "warfare", + "warfare" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 6, + "total_resources": 8, + "optimal_influence": 6, + "optimal_resources": 7 + }, + { + "tiles": [ + 63, + 78, + 32, + 77, + 72 + ], + "specialties": [ + "warfare", + "biotic" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 5, + "optimal_influence": 5.5, + "optimal_resources": 3.5 + }, + { + "tiles": [ + 20, + 71, + 80, + 73, + 44 + ], + "specialties": [ + "cybernetic" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 6, + "optimal_influence": 4.5, + "optimal_resources": 4.5 + }, + { + "tiles": [ + 26, + 45, + 19, + 75, + 47 + ], + "specialties": [ + "cybernetic" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 5, + "total_resources": 8, + "optimal_influence": 4, + "optimal_resources": 6 + }, + { + "tiles": [ + 43, + 66, + 41, + 24, + 30 + ], + "specialties": [ + "warfare" + ], + "wormholes": [], + "has_legendaries": true, + "legendaries": [ + "You may exhaust this card at the end of your turn to place 1 mech from your reinforcements on any planet you control, or draw 1 action card" + ], + "total_influence": 6, + "total_resources": 7, + "optimal_influence": 5, + "optimal_resources": 6 + } + ], + "factions": [ + "The Naalu Collective", + "The Titans of Ul", + "The L1z1x Mindnet", + "The Federation of Sol", + "The Barony of Letnev", + "The Xxcha Kingdom", + "The Universities of Jol-Nar", + "The Mentak Coalition", + "The Empyrean" + ], + "config": { + "players": [ + "Ben", + "Charlie", + "Amy", + "Desmond" + ], + "name": "Operation Super War", + "num_slices": 7, + "num_factions": 9, + "include_pok": true, + "include_ds_tiles": false, + "include_te_tiles": false, + "include_base_factions": true, + "include_pok_factions": true, + "include_keleres": false, + "include_discordant": false, + "include_discordantexp": false, + "include_te_factions": false, + "preset_draft_order": false, + "min_wormholes": 0, + "min_legendaries": 0, + "max_1_wormhole": false, + "minimum_optimal_influence": 4, + "minimum_optimal_resources": 2.5, + "minimum_optimal_total": 9, + "maximum_optimal_total": 13, + "custom_factions": null, + "custom_slices": null, + "alliance": { + "alliance_teams": "random", + "alliance_teams_position": "neighbors", + "force_double_picks": true + }, + "seed": 330899005632699 + }, + "name": "Operation Super War" +} \ No newline at end of file diff --git a/data/test-drafts/draft.november2025.custom.json b/data/test-drafts/draft.november2025.custom.json new file mode 100644 index 0000000..71e5b0e --- /dev/null +++ b/data/test-drafts/draft.november2025.custom.json @@ -0,0 +1,370 @@ +{ + "done": false, + "id": "693e9b3fb8452", + "secrets": { + "admin_pass": "cbe0c4bed337c012996d9ee876510363" + }, + "draft": { + "players": { + "p_564cbfb050046007faea784931962140": { + "id": "p_564cbfb050046007faea784931962140", + "name": "Ben", + "claimed": false, + "position": null, + "slice": null, + "faction": "Free Systems Compact", + "team": null + }, + "p_ffbfdb5608320d54d904e07eae678c2a": { + "id": "p_ffbfdb5608320d54d904e07eae678c2a", + "name": "Esther", + "claimed": false, + "position": null, + "slice": null, + "faction": "Last Bastion", + "team": null + }, + "p_67161c4c41518e0e1cfc4e2c044269e2": { + "id": "p_67161c4c41518e0e1cfc4e2c044269e2", + "name": "Frank", + "claimed": false, + "position": null, + "slice": null, + "faction": "Gledge Union", + "team": null + }, + "p_e6556957358fe40ec9c0bf4a9ddab0ac": { + "id": "p_e6556957358fe40ec9c0bf4a9ddab0ac", + "name": "Desmond", + "claimed": false, + "position": null, + "slice": "1", + "faction": null, + "team": null + }, + "p_404e5780ee88e69cab1602093431c2a8": { + "id": "p_404e5780ee88e69cab1602093431c2a8", + "name": "Amy", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": null + }, + "p_e37356083346f2cb785de89c36faff1e": { + "id": "p_e37356083346f2cb785de89c36faff1e", + "name": "Charlie", + "claimed": false, + "position": null, + "slice": null, + "faction": null, + "team": null + } + }, + "log": [ + { + "player": "p_564cbfb050046007faea784931962140", + "category": "faction", + "value": "Free Systems Compact" + }, + { + "player": "p_ffbfdb5608320d54d904e07eae678c2a", + "category": "faction", + "value": "Last Bastion" + }, + { + "player": "p_67161c4c41518e0e1cfc4e2c044269e2", + "category": "faction", + "value": "Gledge Union" + }, + { + "player": "p_e6556957358fe40ec9c0bf4a9ddab0ac", + "category": "slice", + "value": "1" + } + ], + "current": "p_404e5780ee88e69cab1602093431c2a8" + }, + "slices": [ + { + "tiles": [ + 61, + 48, + 59, + 49, + 40 + ], + "specialties": [ + "warfare", + "propulsion" + ], + "wormholes": [ + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 3, + "total_resources": 3, + "optimal_influence": 3, + "optimal_resources": 2 + }, + { + "tiles": [ + 60, + 72, + 39, + 68, + 25 + ], + "specialties": [ + "warfare" + ], + "wormholes": [ + "alpha", + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 6, + "total_resources": 11, + "optimal_influence": 1, + "optimal_resources": 10 + }, + { + "tiles": [ + 27, + 44, + 76, + 79, + 35 + ], + "specialties": [ + "biotic", + "biotic" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 10, + "total_resources": 11, + "optimal_influence": 7, + "optimal_resources": 7 + }, + { + "tiles": [ + 45, + 34, + 46, + 11, + 36 + ], + "specialties": [ + "propulsion" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 8, + "optimal_influence": 5.5, + "optimal_resources": 5.5 + }, + { + "tiles": [ + 30, + 12, + 50, + 22, + 17 + ], + "specialties": [ + "biotic" + ], + "wormholes": [ + "delta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 9, + "total_resources": 7, + "optimal_influence": 7.5, + "optimal_resources": 3.5 + }, + { + "tiles": [ + 70, + 73, + 12, + 77, + 21 + ], + "specialties": [ + "cybernetic", + "propulsion" + ], + "wormholes": [], + "has_legendaries": false, + "legendaries": [], + "total_influence": 12, + "total_resources": 6, + "optimal_influence": 10.5, + "optimal_resources": 2.5 + } + ], + "factions": [ + "Veldyr Sovereignty", + "Free Systems Compact", + "Kyro Sodality", + "Ghoti Wayfarers", + "Zelian Purifier", + "Gledge Union", + "Nokar Sellships", + "Last Bastion", + "Savages of Cymiae" + ], + "config": { + "players": [ + "Ben", + "Esther", + "Frank", + "Desmond", + "Amy", + "Charlie" + ], + "name": "Custom Slices and Factions", + "num_slices": 6, + "num_factions": 9, + "include_pok": true, + "include_ds_tiles": true, + "include_te_tiles": true, + "include_base_factions": true, + "include_pok_factions": true, + "include_keleres": true, + "include_discordant": true, + "include_discordantexp": true, + "include_te_factions": true, + "preset_draft_order": false, + "min_wormholes": 0, + "min_legendaries": 0, + "max_1_wormhole": false, + "minimum_optimal_influence": 4, + "minimum_optimal_resources": 2.5, + "minimum_optimal_total": 9, + "maximum_optimal_total": 13, + "custom_factions": [ + "Sardakk N'orr", + "The Arborec", + "The Barony of Letnev", + "The Clan of Saar", + "The Embers of Muaat", + "The Emirates of Hacan", + "The Federation of Sol", + "The Ghosts of Creuss", + "The L1z1x Mindnet", + "The Mentak Coalition", + "The Naalu Collective", + "The Nekro Virus", + "The Universities of Jol-Nar", + "The Winnu", + "The Xxcha Kingdom", + "The Yin Brotherhood", + "The Yssaril Tribes", + "The Argent Flight", + "The Empyrean", + "The Mahact Gene-sorcerers", + "The Naaz-Rokha Alliance", + "The Nomad", + "The Titans of Ul", + "The Vuil'raith Cabal", + "The Council Keleres", + "Augurs of Ilyxum", + "Celdauri Trade Confederation", + "Dih-Mohn Flotilla", + "Florzen Profiteers", + "Free Systems Compact", + "Ghemina Raiders", + "Glimmer of Mortheus", + "Kollecc Society", + "Kortali Tribunal", + "Li-Zho Dynasty", + "L'Tokk Khrask", + "Mirveda Protectorate", + "Myko-Mentori", + "Nivyn Star Kings", + "Olradin League", + "Roh'Dhna Mechatronics", + "Savages of Cymiae", + "Shipwrights of Axis", + "Tnelis Syndicate", + "Vaden Banking Clans", + "Vaylerian Scourge", + "Veldyr Sovereignty", + "Zealots of Rhodun", + "Zelian Purifier", + "Bentor Conglomerate", + "Berserkers of Kjalengard", + "Cheiran Hordes", + "Edyn Mandate", + "Ghoti Wayfarers", + "Gledge Union", + "Kyro Sodality", + "Lanefir Remnants", + "The Monks of Kolume", + "Nokar Sellships", + "Last Bastion", + "The Ral Nel Consortium", + "The Deepwrought Scholarate", + "The Crimson Rebellion", + "The Firmament / The Obsidian" + ], + "custom_slices": [ + [ + "61", + "48", + "59", + "49", + "40" + ], + [ + "60", + "72", + "39", + "68", + "25" + ], + [ + "27", + "44", + "76", + "79", + "35" + ], + [ + "45", + "34", + "46", + "11", + "36" + ], + [ + "30", + "12", + "50", + "22", + "17" + ], + [ + "70", + "73", + "12", + "77", + "21" + ] + ], + "alliance": null, + "seed": 896134222637712 + }, + "name": "Custom Slices and Factions" +} \ No newline at end of file diff --git a/data/test-drafts/draft.november2025.finished.json b/data/test-drafts/draft.november2025.finished.json new file mode 100644 index 0000000..cb9e67b --- /dev/null +++ b/data/test-drafts/draft.november2025.finished.json @@ -0,0 +1,360 @@ +{ + "done": true, + "id": "693e979ca04c9", + "secrets": { + "admin_pass": "1d9f392001338a452efa9e27e843780c", + "p_b26d3bfd9f83d741cd71c8023cace30a": "1d57678d1c34aef831fd71fedf07d58e" + }, + "draft": { + "players": { + "p_b26d3bfd9f83d741cd71c8023cace30a": { + "id": "p_b26d3bfd9f83d741cd71c8023cace30a", + "name": "Amy", + "claimed": true, + "position": "3", + "slice": "3", + "faction": "Berserkers of Kjalengard", + "team": null + }, + "p_f57c3d5d815fd0b4aa2579ab15cf17aa": { + "id": "p_f57c3d5d815fd0b4aa2579ab15cf17aa", + "name": "Ben", + "claimed": false, + "position": "5", + "slice": "0", + "faction": "The Ral Nel Consortium", + "team": null + }, + "p_a25a5582f3c732d4e2daab7aa80e2c19": { + "id": "p_a25a5582f3c732d4e2daab7aa80e2c19", + "name": "Charlie", + "claimed": false, + "position": "4", + "slice": "1", + "faction": "The Vuil'raith Cabal", + "team": null + }, + "p_6ebcab571e776e0ae4692940b72872be": { + "id": "p_6ebcab571e776e0ae4692940b72872be", + "name": "Desmond", + "claimed": false, + "position": "0", + "slice": "2", + "faction": "The Yin Brotherhood", + "team": null + }, + "p_8ae9f03017af74dc88d267fcc56e308b": { + "id": "p_8ae9f03017af74dc88d267fcc56e308b", + "name": "Esther", + "claimed": false, + "position": "2", + "slice": "5", + "faction": "Myko-Mentori", + "team": null + }, + "p_d9e0f5382fb4da130428ca06936abfaf": { + "id": "p_d9e0f5382fb4da130428ca06936abfaf", + "name": "Frank", + "claimed": false, + "position": "1", + "slice": "6", + "faction": "Roh'Dhna Mechatronics", + "team": null + } + }, + "log": [ + { + "player": "p_b26d3bfd9f83d741cd71c8023cace30a", + "category": "faction", + "value": "Berserkers of Kjalengard" + }, + { + "player": "p_f57c3d5d815fd0b4aa2579ab15cf17aa", + "category": "faction", + "value": "The Ral Nel Consortium" + }, + { + "player": "p_a25a5582f3c732d4e2daab7aa80e2c19", + "category": "slice", + "value": "1" + }, + { + "player": "p_6ebcab571e776e0ae4692940b72872be", + "category": "slice", + "value": "2" + }, + { + "player": "p_8ae9f03017af74dc88d267fcc56e308b", + "category": "position", + "value": "2" + }, + { + "player": "p_d9e0f5382fb4da130428ca06936abfaf", + "category": "position", + "value": "1" + }, + { + "player": "p_d9e0f5382fb4da130428ca06936abfaf", + "category": "slice", + "value": "6" + }, + { + "player": "p_8ae9f03017af74dc88d267fcc56e308b", + "category": "slice", + "value": "5" + }, + { + "player": "p_6ebcab571e776e0ae4692940b72872be", + "category": "position", + "value": "0" + }, + { + "player": "p_a25a5582f3c732d4e2daab7aa80e2c19", + "category": "position", + "value": "4" + }, + { + "player": "p_f57c3d5d815fd0b4aa2579ab15cf17aa", + "category": "slice", + "value": "0" + }, + { + "player": "p_b26d3bfd9f83d741cd71c8023cace30a", + "category": "slice", + "value": "3" + }, + { + "player": "p_b26d3bfd9f83d741cd71c8023cace30a", + "category": "position", + "value": "3" + }, + { + "player": "p_f57c3d5d815fd0b4aa2579ab15cf17aa", + "category": "position", + "value": "5" + }, + { + "player": "p_a25a5582f3c732d4e2daab7aa80e2c19", + "category": "faction", + "value": "The Vuil'raith Cabal" + }, + { + "player": "p_6ebcab571e776e0ae4692940b72872be", + "category": "faction", + "value": "The Yin Brotherhood" + }, + { + "player": "p_8ae9f03017af74dc88d267fcc56e308b", + "category": "faction", + "value": "Myko-Mentori" + }, + { + "player": "p_d9e0f5382fb4da130428ca06936abfaf", + "category": "faction", + "value": "Roh'Dhna Mechatronics" + } + ], + "current": "p_d9e0f5382fb4da130428ca06936abfaf" + }, + "slices": [ + { + "tiles": [ + 4274, + 29, + 115, + 61, + 109 + ], + "specialties": [ + "warfare", + "warfare" + ], + "wormholes": [], + "has_legendaries": true, + "legendaries": [ + "You may exhaust this card when you pass to place 1 ship that matches a unit upgrade technology you owm from your reinforcements into a system that contains your ships." + ], + "total_influence": 8, + "total_resources": 7, + "optimal_influence": 7.5, + "optimal_resources": 4.5 + }, + { + "tiles": [ + 4273, + 102, + 77, + 76, + 75 + ], + "specialties": [ + "biotic", + "propulsion" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 7, + "total_resources": 7, + "optimal_influence": 6, + "optimal_resources": 4 + }, + { + "tiles": [ + 49, + 64, + 43, + 4262, + 20 + ], + "specialties": [ + "warfare" + ], + "wormholes": [ + "beta" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 8, + "optimal_influence": 6, + "optimal_resources": 4 + }, + { + "tiles": [ + 105, + 48, + 24, + 32, + 39 + ], + "specialties": [ + "biotic", + "biotic", + "warfare" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": false, + "legendaries": [], + "total_influence": 8, + "total_resources": 5, + "optimal_influence": 6, + "optimal_resources": 3 + }, + { + "tiles": [ + 117, + 38, + 4269, + 19, + 111 + ], + "specialties": [ + "cybernetic" + ], + "wormholes": [], + "has_legendaries": true, + "legendaries": [ + "You may exhaust this card at the end of your turn to remove 1 of your ships from the game board and place that unit in an adjacent system that does not contain another player’s ships." + ], + "total_influence": 7, + "total_resources": 11, + "optimal_influence": 5, + "optimal_resources": 8 + }, + { + "tiles": [ + 4256, + 28, + 113, + 104, + 42 + ], + "specialties": [], + "wormholes": [ + "beta" + ], + "has_legendaries": true, + "legendaries": [ + "You may exhaust this card at the end of your turn and purge a non-faction, non-unit upgrade technology you own to gain 1 technology with the same number of prerequisites." + ], + "total_influence": 7, + "total_resources": 4, + "optimal_influence": 6, + "optimal_resources": 4 + }, + { + "tiles": [ + 98, + 63, + 50, + 79, + 4268 + ], + "specialties": [ + "biotic" + ], + "wormholes": [ + "alpha" + ], + "has_legendaries": true, + "legendaries": [ + "You may exhaust this card when you pass to place 1 action card from the discard pile faceup on this card; you can purge cards on this card to play them as if they were in your hand." + ], + "total_influence": 6, + "total_resources": 5, + "optimal_influence": 4.5, + "optimal_resources": 4.5 + } + ], + "factions": [ + "Roh'Dhna Mechatronics", + "Berserkers of Kjalengard", + "The Yin Brotherhood", + "Savages of Cymiae", + "Myko-Mentori", + "Sardakk N'orr", + "Ghoti Wayfarers", + "The Ral Nel Consortium", + "The Vuil'raith Cabal" + ], + "config": { + "players": [ + "Amy", + "Ben", + "Charlie", + "Desmond", + "Esther", + "Frank" + ], + "name": "Game with every checkbox checked", + "num_slices": 7, + "num_factions": 9, + "include_pok": true, + "include_ds_tiles": true, + "include_te_tiles": true, + "include_base_factions": true, + "include_pok_factions": true, + "include_keleres": false, + "include_discordant": true, + "include_discordantexp": true, + "include_te_factions": true, + "preset_draft_order": true, + "min_wormholes": 2, + "min_legendaries": 1, + "max_1_wormhole": true, + "minimum_optimal_influence": 4, + "minimum_optimal_resources": 2.5, + "minimum_optimal_total": 9, + "maximum_optimal_total": 13, + "custom_factions": null, + "custom_slices": null, + "alliance": null, + "seed": 123 + }, + "name": "Game with every checkbox checked" +} \ No newline at end of file diff --git a/data/tile-selection.json b/data/tile-selection.json index 8f19a88..822f9ec 100644 --- a/data/tile-selection.json +++ b/data/tile-selection.json @@ -1,26 +1,22 @@ { - "tiers": { + "BaseGame": { "high": [28, 29, 30, 32, 33, 35, 36, 38], "mid": [26, 27, 31, 34, 37], - "low": [19, 20, 21, 22, 23, 24, 25], - "red": [39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50] + "low": [19, 20, 21, 22, 23, 24, 25] }, - "pokTiers": { + "PoK": { "high": [69, 70, 71, 75 ], "mid": [64, 65, 66, 72, 73, 74, 76], - "low": [59, 60, 61, 62, 63], - "red": [67, 68, 77, 78, 79, 80] + "low": [59, 60, 61, 62, 63] }, - "DSTiers": { + "DSPlus": { "high": [4261, 4262, 4263, 4264, 4266, 4268], "mid": [4253, 4254, 4255, 4256, 4257, 4258, 4259, 4260, 4267], - "low": [4265], - "red": [4269, 4270, 4271, 4272, 4273, 4274, 4275, 4276] + "low": [4265] }, - "TETiers": { + "TE": { "high": [110, 103, 97, 106, 101, 108], "mid": [105, 107, 109, 111, 99, 98], - "low": [104, 102, 100], - "red": [116, 115, 114, 113, 117] + "low": [104, 102, 100] } } diff --git a/data/tiles.json b/data/tiles.json index aafb19a..d4a2511 100644 --- a/data/tiles.json +++ b/data/tiles.json @@ -12,7 +12,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "2": { "type": "green", @@ -27,7 +28,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "3": { "type": "green", @@ -42,7 +44,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "4": { "type": "green", @@ -57,7 +60,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "5": { "type": "green", @@ -72,7 +76,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "6": { "type": "green", @@ -87,7 +92,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "7": { "type": "green", @@ -102,7 +108,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "8": { "type": "green", @@ -117,7 +124,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "9": { "type": "green", @@ -140,7 +148,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "10": { "type": "green", @@ -163,7 +172,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "11": { "type": "green", @@ -186,7 +196,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "12": { "type": "green", @@ -209,7 +220,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "13": { "type": "green", @@ -232,7 +244,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "14": { "type": "green", @@ -255,7 +268,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "15": { "type": "green", @@ -278,7 +292,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "16": { "type": "green", @@ -309,17 +324,20 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "17": { "type": "green", "faction": "The Ghosts of Creuss", "wormhole": "delta", - "planets": [] + "planets": [], + "set": "BaseGame" }, "18": { "type": "blue", "wormhole": null, + "nonDraftable": true, "planets": [ { "name": "Mecatol Rex", @@ -329,7 +347,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "19": { "type": "blue", @@ -345,7 +364,8 @@ "cybernetic" ] } - ] + ], + "set": "BaseGame" }, "20": { "type": "blue", @@ -359,7 +379,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "21": { "type": "blue", @@ -375,7 +396,8 @@ "propulsion" ] } - ] + ], + "set": "BaseGame" }, "22": { "type": "blue", @@ -391,7 +413,8 @@ "biotic" ] } - ] + ], + "set": "BaseGame" }, "23": { "type": "blue", @@ -405,7 +428,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "24": { "type": "blue", @@ -421,7 +445,8 @@ "warfare" ] } - ] + ], + "set": "BaseGame" }, "25": { "type": "blue", @@ -435,7 +460,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "26": { "type": "blue", @@ -449,7 +475,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "27": { "type": "blue", @@ -473,7 +500,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "28": { "type": "blue", @@ -495,7 +523,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "29": { "type": "blue", @@ -517,7 +546,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "30": { "type": "blue", @@ -539,7 +569,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "31": { "type": "blue", @@ -563,7 +594,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "32": { "type": "blue", @@ -585,7 +617,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "33": { "type": "blue", @@ -607,7 +640,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "34": { "type": "blue", @@ -631,7 +665,8 @@ "propulsion" ] } - ] + ], + "set": "BaseGame" }, "35": { "type": "blue", @@ -653,7 +688,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "36": { "type": "blue", @@ -675,7 +711,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "37": { "type": "blue", @@ -699,7 +736,8 @@ "warfare" ] } - ] + ], + "set": "BaseGame" }, "38": { "type": "blue", @@ -721,79 +759,92 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "39": { "type": "red", "wormhole": "alpha", "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "40": { "type": "red", "wormhole": "beta", "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "41": { "type": "red", "wormhole": null, "anomaly": "gravity-rift", - "planets": [] + "planets": [], + "set": "BaseGame" }, "42": { "type": "red", "wormhole": null, "anomaly": "nebula", - "planets": [] + "planets": [], + "set": "BaseGame" }, "43": { "type": "red", "wormhole": null, "anomaly": "supernova", - "planets": [] + "planets": [], + "set": "BaseGame" }, "44": { "type": "red", "wormhole": null, "anomaly": "asteroid-field", - "planets": [] + "planets": [], + "set": "BaseGame" }, "45": { "type": "red", "wormhole": null, "anomaly": "asteroid-field", - "planets": [] + "planets": [], + "set": "BaseGame" }, "46": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "47": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "48": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "49": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "50": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "BaseGame" }, "51": { "type": "green", @@ -803,10 +854,12 @@ "name": "Creuss", "resources": 4, "influence": 2, + "trait": [], "legendary": false, "specialties": [] } - ] + ], + "set": "BaseGame" }, "52": { "type": "green", @@ -821,7 +874,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "53": { "type": "green", @@ -836,7 +890,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "54": { "type": "green", @@ -851,7 +906,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "55": { "type": "green", @@ -866,7 +922,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "56": { "type": "green", @@ -881,7 +938,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "57": { "type": "green", @@ -904,7 +962,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "58": { "type": "green", @@ -935,7 +994,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "59": { "type": "blue", @@ -951,7 +1011,8 @@ "propulsion" ] } - ] + ], + "set": "PoK" }, "60": { "type": "blue", @@ -965,7 +1026,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "61": { "type": "blue", @@ -981,7 +1043,8 @@ "warfare" ] } - ] + ], + "set": "PoK" }, "62": { "type": "blue", @@ -997,7 +1060,8 @@ "cybernetic" ] } - ] + ], + "set": "PoK" }, "63": { "type": "blue", @@ -1013,7 +1077,8 @@ "biotic" ] } - ] + ], + "set": "PoK" }, "64": { "type": "blue", @@ -1027,7 +1092,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "65": { "type": "blue", @@ -1041,7 +1107,8 @@ "legendary": "You may exhaust this card at the end of your turn to place up to 2 infantry from your reinforcements on any planet you control", "specialties": [] } - ] + ], + "set": "PoK" }, "66": { "type": "blue", @@ -1055,7 +1122,8 @@ "legendary": "You may exhaust this card at the end of your turn to place 1 mech from your reinforcements on any planet you control, or draw 1 action card", "specialties": [] } - ] + ], + "set": "PoK" }, "67": { "type": "red", @@ -1070,7 +1138,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "68": { "type": "red", @@ -1085,7 +1154,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "69": { "type": "blue", @@ -1107,7 +1177,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "70": { "type": "blue", @@ -1129,7 +1200,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "71": { "type": "blue", @@ -1151,7 +1223,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "72": { "type": "blue", @@ -1175,7 +1248,8 @@ "warfare" ] } - ] + ], + "set": "PoK" }, "73": { "type": "blue", @@ -1199,7 +1273,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "74": { "type": "blue", @@ -1223,7 +1298,8 @@ "propulsion" ] } - ] + ], + "set": "PoK" }, "75": { "type": "blue", @@ -1253,7 +1329,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "PoK" }, "76": { "type": "blue", @@ -1285,39 +1362,47 @@ "biotic" ] } - ] + ], + "set": "PoK" }, "77": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "PoK" }, "78": { "type": "red", "wormhole": null, "anomaly": null, - "planets": [] + "planets": [], + "set": "PoK" }, "79": { - "type": "red", - "wormhole": "alpha", - "anomaly": "asteroid-field", - "planets": [] + "type": "red", + "wormhole": "alpha", + "anomaly": "asteroid-field", + "planets": [], + "set": "PoK" }, "80": { - "type": "red", - "wormhole": null, - "anomaly": "supernova", - "planets": [] + "type": "red", + "wormhole": null, + "anomaly": "supernova", + "planets": [], + "set": "PoK" }, "81": { - "type": "red", - "wormhole": null, - "anomaly": "muaat-supernova", - "planets": [] + "type": "red", + "wormhole": null, + "anomaly": "muaat-supernova", + "planets": [], + "set": "PoK", + "nonDraftable": true }, "82": { + "nonDraftable": true, "type": "blue", "wormhole": "all", "planets": [ @@ -1329,7 +1414,8 @@ "legendary": "You may exhaust this card at the end of your turn to gain 2 trade goods or convert all of your commodities into trade goods", "specialties": [] } - ] + ], + "set": "PoK" }, "83A": { "type": "hyperlane", @@ -1340,7 +1426,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "83B": { "type": "hyperlane", @@ -1359,7 +1446,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "84A": { "type": "hyperlane", @@ -1370,7 +1458,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "84B": { "type": "hyperlane", @@ -1389,7 +1478,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "85A": { "type": "hyperlane", @@ -1400,7 +1490,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "85B": { "type": "hyperlane", @@ -1419,7 +1510,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "86A": { "type": "hyperlane", @@ -1430,7 +1522,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "86B": { "type": "hyperlane", @@ -1449,7 +1542,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "87A": { "type": "hyperlane", @@ -1468,7 +1562,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "87B": { "type": "hyperlane", @@ -1483,7 +1578,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "88A": { "type": "hyperlane", @@ -1502,7 +1598,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "88B": { "type": "hyperlane", @@ -1521,7 +1618,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "89A": { "type": "hyperlane", @@ -1540,7 +1638,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "89B": { "type": "hyperlane", @@ -1555,7 +1654,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "90A": { "type": "hyperlane", @@ -1570,7 +1670,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "90B": { "type": "hyperlane", @@ -1585,7 +1686,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "91A": { "type": "hyperlane", @@ -1604,7 +1706,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "91B": { "type": "hyperlane", @@ -1619,7 +1722,8 @@ ] ], "wormhole": null, - "planets": [] + "planets": [], + "set": "PoK" }, "92": { "type": "green", @@ -1642,7 +1746,8 @@ "resources": 1, "influence": 2 } - ] + ], + "set": "TE" }, "93": { "type": "green", @@ -1665,13 +1770,15 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "94": { "type": "green", "faction": "The Crimson Rebellion", "wormhole": "epsilon", - "planets": [] + "planets": [], + "set": "TE" }, "95": { "type": "green", @@ -1686,7 +1793,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "96a": { "type": "green", @@ -1709,7 +1817,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "96b": { "type": "green", @@ -1732,7 +1841,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "97": { "type": "blue", @@ -1748,7 +1858,8 @@ "biotic" ] } - ] + ], + "set": "TE" }, "98": { "type": "blue", @@ -1762,7 +1873,8 @@ "legendary": "You may exhaust this card when you pass to place 1 action card from the discard pile faceup on this card; you can purge cards on this card to play them as if they were in your hand.", "specialties": [] } - ] + ], + "set": "TE" }, "99": { "type": "blue", @@ -1776,7 +1888,8 @@ "legendary": "You may exhaust this card at the end of your turn to ready another component that isn't a strategy card.", "specialties": [] } - ] + ], + "set": "TE" }, "100": { "type": "blue", @@ -1792,7 +1905,8 @@ "propulsion" ] } - ] + ], + "set": "TE" }, "101": { "type": "blue", @@ -1812,7 +1926,8 @@ "warfare" ] } - ] + ], + "set": "TE" }, "102": { "type": "blue", @@ -1828,7 +1943,8 @@ "propulsion" ] } - ] + ], + "set": "TE" }, "103": { "type": "blue", @@ -1845,7 +1961,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "104": { "type": "blue", @@ -1862,7 +1979,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "105": { "type": "blue", @@ -1891,7 +2009,8 @@ "biotic" ] } - ] + ], + "set": "TE" }, "106": { "type": "blue", @@ -1913,7 +2032,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "107": { "type": "blue", @@ -1938,7 +2058,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "108": { "type": "blue", @@ -1960,7 +2081,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "109": { "type": "blue", @@ -1981,7 +2103,8 @@ "resources": 1, "influence": 1 } - ] + ], + "set": "TE" }, "110": { "type": "blue", @@ -2011,7 +2134,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "111": { "type": "blue", @@ -2035,12 +2159,14 @@ "resources": 1, "influence": 1 } - ] + ], + "set": "TE" }, "112": { "type": "blue", "wormhole": null, "anomaly": null, + "nonDraftable": true, "planets": [ { "name": "Mecatol Rex", @@ -2050,19 +2176,22 @@ "legendary": "You may exhaust this card and discard 1 secret objective at the end of your turn to draw 1 secret objective.", "specialties": [] } - ] + ], + "set": "TE" }, "113": { "type": "red", "wormhole": "beta", "anomaly": "gravity rift", - "planets": [] + "planets": [], + "set": "TE" }, "114": { "type": "red", "wormhole": null, "anomaly": "entropic scar", - "planets": [] + "planets": [], + "set": "TE" }, "115": { "type": "red", @@ -2079,7 +2208,8 @@ "warfare" ] } - ] + ], + "set": "TE" }, "116": { "type": "red", @@ -2094,7 +2224,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "117": { "type": "red", @@ -2106,7 +2237,8 @@ "resources": 1, "influence": 1 } - ] + ], + "set": "TE" }, "118": { "type": "green", @@ -2120,11 +2252,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "TE" }, "4200": { "type": "green", - "faction": "The Veldyr Conglomerate", + "faction": "Veldyr Sovereignty", "wormhole": null, "planets": [ { @@ -2135,11 +2268,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4222": { "type": "green", - "faction": "The Free Systems Compact", + "faction": "Free Systems Compact", "wormhole": null, "planets": [ { @@ -2166,11 +2300,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4223": { "type": "green", - "faction": "The Li-Zho Dynasty", + "faction": "Li-Zho Dynasty", "wormhole": null, "planets": [ { @@ -2197,11 +2332,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4201": { "type": "green", - "faction": "The L'Tokk Khrask", + "faction": "L'Tokk Khrask", "wormhole": null, "planets": [ { @@ -2212,11 +2348,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4212": { "type": "green", - "faction": "The Ghemina Raiders", + "faction": "Ghemina Raiders", "wormhole": null, "planets": [ { @@ -2235,11 +2372,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4213": { "type": "green", - "faction": "The Vaden Banking Clans", + "faction": "Vaden Banking Clans", "wormhole": null, "planets": [ { @@ -2258,11 +2396,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4214": { "type": "green", - "faction": "The Glimmer of Mortheus", + "faction": "Glimmer of Mortheus", "wormhole": null, "planets": [ { @@ -2282,11 +2421,12 @@ "anomaly": "nebula", "specialties": [] } - ] + ], + "set": "DS" }, "4215": { "type": "green", - "faction": "The Augurs of Ilyxum", + "faction": "Augurs of Ilyxum", "wormhole": null, "planets": [ { @@ -2305,11 +2445,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4204": { "type": "green", - "faction": "The Shipwrights of Axis", + "faction": "Shipwrights of Axis", "wormhole": null, "planets": [ { @@ -2320,11 +2461,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4205": { "type": "green", - "faction": "The Olradin League", + "faction": "Olradin League", "wormhole": null, "planets": [ { @@ -2335,11 +2477,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4206": { "type": "green", - "faction": "The Myko-Mentori", + "faction": "Myko-Mentori", "wormhole": null, "planets": [ { @@ -2350,11 +2493,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4207": { "type": "green", - "faction": "The Tnelis Syndicate", + "faction": "Tnelis Syndicate", "wormhole": null, "planets": [ { @@ -2365,11 +2509,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4203": { "type": "green", - "faction": "The Savages of Cymiae", + "faction": "Savages of Cymiae", "wormhole": null, "planets": [ { @@ -2380,7 +2525,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4208": { "type": "green", @@ -2395,11 +2541,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4216": { "type": "green", - "faction": "The Zelian Purifier", + "faction": "Zelian Purifier", "wormhole": null, "planets": [ { @@ -2419,11 +2566,12 @@ "anomaly": "asteroid-field", "specialties": [] } - ] + ], + "set": "DS" }, "4209": { "type": "green", - "faction": "The Vaylerian Scourge", + "faction": "Vaylerian Scourge", "wormhole": null, "planets": [ { @@ -2434,11 +2582,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4217": { "type": "green", - "faction": "The Florzen Profiteers", + "faction": "Florzen Profiteers", "wormhole": null, "planets": [ { @@ -2457,11 +2606,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4210": { "type": "green", - "faction": "The Dih-Mohn Flotilla", + "faction": "Dih-Mohn Flotilla", "wormhole": null, "planets": [ { @@ -2472,11 +2622,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4218": { "type": "green", - "faction": "The Celdauri Trade Confederation", + "faction": "Celdauri Trade Confederation", "wormhole": null, "planets": [ { @@ -2495,11 +2646,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4211": { "type": "green", - "faction": "The Nivyn Star Kings", + "faction": "Nivyn Star Kings", "wormhole": null, "planets": [ { @@ -2511,11 +2663,12 @@ "anomaly": "gravity-rift", "specialties": [] } - ] + ], + "set": "DS" }, "4219": { "type": "green", - "faction": "The Mirveda Protectorate", + "faction": "Mirveda Protectorate", "wormhole": null, "planets": [ { @@ -2534,11 +2687,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4220": { "type": "green", - "faction": "The Kortali Tribunal", + "faction": "Kortali Tribunal", "wormhole": null, "planets": [ { @@ -2557,11 +2711,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4202": { "type": "green", - "faction": "The Kollecc Society", + "faction": "Kollecc Society", "wormhole": null, "planets": [ { @@ -2572,11 +2727,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4221": { "type": "green", - "faction": "The Zealots of Rhodun", + "faction": "Zealots of Rhodun", "wormhole": null, "planets": [ { @@ -2595,23 +2751,26 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DS" }, "4224": { "type": "red", "wormhole": null, "anomaly": "zelian-asteroid-field", - "planets": [] + "planets": [], + "set": "DS" }, "4225": { "type": "red", "wormhole": null, "anomaly": "myko-cataclysm", - "planets": [] + "planets": [], + "set": "DS" }, "4230": { "type": "green", - "faction": "The Bentor Conglomerate", + "faction": "Bentor Conglomerate", "wormhole": null, "planets": [ { @@ -2630,11 +2789,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4231": { "type": "green", - "faction": "The Nokar Sellships", + "faction": "Nokar Sellships", "wormhole": null, "planets": [ { @@ -2653,11 +2813,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4228": { "type": "green", - "faction": "The Gledge Union", + "faction": "Gledge Union", "wormhole": null, "planets": [ { @@ -2668,11 +2829,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4232": { "type": "green", - "faction": "The Lanefir Remnants", + "faction": "Lanefir Remnants", "wormhole": null, "planets": [ { @@ -2691,11 +2853,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4229": { "type": "green", - "faction": "The Kyro Sodality", + "faction": "Kyro Sodality", "wormhole": null, "planets": [ { @@ -2706,13 +2869,15 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4227": { "type": "green", - "faction": "The Ghoti Wayfarers", + "faction": "Ghoti Wayfarers", "wormhole": null, - "planets": [] + "planets": [], + "set": "DSPlus" }, "4233": { "type": "green", @@ -2735,11 +2900,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4234": { "type": "green", - "faction": "The Cheiran Hordes", + "faction": "Cheiran Hordes", "wormhole": null, "planets": [ { @@ -2758,11 +2924,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4236": { "type": "green", - "faction": "The Edyn Mandate", + "faction": "Edyn Mandate", "wormhole": null, "planets": [ { @@ -2789,11 +2956,12 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4235": { "type": "green", - "faction": "The Berserkers of Kjalengard", + "faction": "Berserkers of Kjalengard", "wormhole": null, "planets": [ { @@ -2812,7 +2980,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4253": { "type": "blue", @@ -2826,7 +2995,8 @@ "legendary": "You may exhaust this card at the end of your turn to place 1 cruiser from your reinforcements in any system that contains 1 or more of your ships.", "specialties": [] } - ] + ], + "set": "DSPlus" }, "4254": { "type": "blue", @@ -2840,7 +3010,8 @@ "legendary": "You may exhaust this card at the end of your turn to place 1 frontier token in a system that does not contain a planet.", "specialties": [] } - ] + ], + "set": "DSPlus" }, "4255": { "type": "blue", @@ -2854,7 +3025,8 @@ "legendary": "After an agenda is revealed, you may exhaust this card to predict aloud an outcome of that agenda. If your prediction is correct, draw 1 secret objective.", "specialties": [] } - ] + ], + "set": "DSPlus" }, "4256": { "type": "blue", @@ -2868,7 +3040,8 @@ "legendary": "You may exhaust this card at the end of your turn and purge a non-faction, non-unit upgrade technology you own to gain 1 technology with the same number of prerequisites.", "specialties": [] } - ] + ], + "set": "DSPlus" }, "4257": { "type": "blue", @@ -2882,7 +3055,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4258": { "type": "blue", @@ -2896,7 +3070,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4259": { "type": "blue", @@ -2910,7 +3085,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4260": { "type": "blue", @@ -2924,7 +3100,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4261": { "type": "blue", @@ -2948,7 +3125,8 @@ "cybernetic" ] } - ] + ], + "set": "DSPlus" }, "4262": { "type": "blue", @@ -2958,7 +3136,7 @@ "name": "Dorvok", "resources": 1, "influence": 2, - "trait": "industrial'", + "trait": "industrial", "legendary": false, "specialties": [ "warfare" @@ -2972,7 +3150,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4263": { "type": "blue", @@ -2982,7 +3161,7 @@ "name": "Rysaa", "resources": 1, "influence": 2, - "trait": "industrial'", + "trait": "industrial", "legendary": false, "specialties": [ "propulsion" @@ -2992,13 +3171,14 @@ "name": "Moln", "resources": 2, "influence": 0, - "trait": "hazardous'", + "trait": "hazardous", "legendary": false, "specialties": [ "biotic" ] } - ] + ], + "set": "DSPlus" }, "4264": { "type": "blue", @@ -3020,7 +3200,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4265": { "type": "blue", @@ -3042,7 +3223,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4266": { "type": "blue", @@ -3064,7 +3246,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4267": { "type": "blue", @@ -3094,7 +3277,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4268": { "type": "blue", @@ -3124,7 +3308,8 @@ "legendary": false, "specialties": [] } - ] + ], + "set": "DSPlus" }, "4269": { "type": "red", @@ -3139,46 +3324,54 @@ "legendary": "You may exhaust this card at the end of your turn to remove 1 of your ships from the game board and place that unit in an adjacent system that does not contain another player\u2019s ships.", "specialties": [] } - ] + ], + "set": "DSPlus" }, "4270": { "type": "red", "wormhole": null, - "planets": [] + "planets": [], + "set": "DSPlus" }, "4271": { "type": "red", "wormhole": null, - "planets": [] + "planets": [], + "set": "DSPlus" }, "4272": { "type": "red", "wormhole": "beta", "anomaly": "nebula", - "planets": [] + "planets": [], + "set": "DSPlus" }, "4273": { "type": "red", "wormhole": null, "anomaly": "asteroid-nebula", - "planets": [] + "planets": [], + "set": "DSPlus" }, "4274": { "type": "red", "wormhole": null, "anomaly": "asteroid-rift", - "planets": [] + "planets": [], + "set": "DSPlus" }, "4275": { "type": "red", "wormhole": "gamma", "anomaly": "gravity rift", - "planets": [] + "planets": [], + "set": "DSPlus" }, "4276": { "type": "red", "wormhole": "alpha-beta", "anomaly": "supernova", - "planets": [] + "planets": [], + "set": "DSPlus" } } diff --git a/deploy/app/caddy/Caddyfile b/deploy/app/caddy/Caddyfile index d8a28ac..48af454 100644 --- a/deploy/app/caddy/Caddyfile +++ b/deploy/app/caddy/Caddyfile @@ -1,28 +1,36 @@ -:80 { +(common_config) { + root /app/public log { - output stdout + output stdout } header { - X-Frame-Options DENY - Referrer-Policy no-referrer-when-downgrade - Access-Control-Allow-Origin * - } + X-Frame-Options DENY + Referrer-Policy no-referrer-when-downgrade + Access-Control-Allow-Origin * + } @options method OPTIONS respond @options 204 @svg { - file - path *.svg - } + file + path *.svg + } header @svg Content-Security-Policy "script-src 'none'" - php_fastcgi localhost:9000 # Adjust to your setup + php_fastcgi localhost:9000 file_server encode zstd gzip root * /code tls internal -} \ No newline at end of file +} + +:80 { + import common_config +} +milty.localhost { + import common_config +} diff --git a/deploy/app/php/php.ini b/deploy/app/php/php.ini index 32ca442..0caf0b0 100644 --- a/deploy/app/php/php.ini +++ b/deploy/app/php/php.ini @@ -1,2 +1,2 @@ post_max_size = 100M -upload_max_filesize = 1000M +upload_max_filesize = 1000M \ No newline at end of file diff --git a/deploy/app/setup.sh b/deploy/app/setup.sh index ecd0393..f6a82b6 100755 --- a/deploy/app/setup.sh +++ b/deploy/app/setup.sh @@ -13,4 +13,5 @@ apk --update --no-cache add \ g++ \ git \ libtool \ - make + make \ + linux-headers diff --git a/deploy/app/workspace.Dockerfile b/deploy/app/workspace.Dockerfile index c4e903b..69387b6 100644 --- a/deploy/app/workspace.Dockerfile +++ b/deploy/app/workspace.Dockerfile @@ -1,4 +1,4 @@ -FROM caddy:2.7-builder AS caddy-builder +FROM caddy:2.11-builder AS caddy-builder RUN xcaddy build \ --with github.com/baldinof/caddy-supervisor \ diff --git a/docker-compose.yml b/docker-compose.yml index 5aa83c5..44bf133 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,10 @@ services: restart: unless-stopped tty: true working_dir: /code - networks: - - backend ports: - - "${DOCKER_HTTP_PORT:-80}:80" + - "80:80" + - "443:443" volumes: - ./:/code:delegated - ./deploy/app/php/php.ini:/usr/local/etc/php/conf.d/php.ini -networks: - backend: - driver: bridge \ No newline at end of file + - ./deploy/app/Caddy/Caddyfile:/etc/caddy/Caddyfile \ No newline at end of file diff --git a/img/tiles/ST_-1.png b/img/tiles/ST_-1.png deleted file mode 100644 index f9aba0c..0000000 Binary files a/img/tiles/ST_-1.png and /dev/null differ diff --git a/img/tiles/ST_undefined.png b/img/tiles/ST_undefined.png deleted file mode 100644 index f9aba0c..0000000 Binary files a/img/tiles/ST_undefined.png and /dev/null differ diff --git a/index.php b/index.php index 4fd3054..a094fbc 100644 --- a/index.php +++ b/index.php @@ -1,3 +1,7 @@ run(); +?> \ No newline at end of file diff --git a/js/draft.js b/js/draft.js index fdda6d5..39b71f7 100644 --- a/js/draft.js +++ b/js/draft.js @@ -149,11 +149,11 @@ $(document).ready(function () { url: window.routes.regenerate, dataType: 'json', data: { - 'regen': draft.id, + 'id': draft.id, 'admin': localStorage.getItem('admin_' + draft.id), - 'shuffle_slices': $('#shuffle_slices').is(':checked'), - 'shuffle_factions': $('#shuffle_factions').is(':checked'), - 'shuffle_order': $('#shuffle_order').is(':checked'), + 'slices': $('#shuffle_slices').is(':checked'), + 'factions': $('#shuffle_factions').is(':checked'), + 'order': $('#shuffle_order').is(':checked'), }, success: function (resp) { if (resp.error) { @@ -176,7 +176,7 @@ $(document).ready(function () { url: window.routes.undo, dataType: 'json', data: { - 'draft': draft.id, + 'id': draft.id, 'admin': localStorage.getItem('admin_' + draft.id), }, success: function (resp) { @@ -331,16 +331,13 @@ function refreshData() { type: "GET", url: window.routes.data, dataType: 'json', - data: { - 'draft': draft.id, - }, success: function (resp) { if (resp.error) { $('#error-message').html(resp.error); $('#error-popup').show(); loading(false); } else { - window.draft = resp.draft; + window.draft = resp; refresh(); // if we're looking at the map, regen it @@ -419,15 +416,16 @@ function draft_status() { } $('#log-content').html(log); + $('.player').removeClass('active'); if (draft.done) { $('#turn').removeClass('show'); $('#done').addClass('show'); + return; } else { $('#turn').addClass('show'); } - $('.player').removeClass('active'); $('#player-' + current_player.id).addClass('active'); if (IS_ADMIN) { @@ -541,7 +539,6 @@ function session_status() { } function hide_regen() { - $('#tabs nav a[href="#regen"]').hide(); $('.regen-help').hide(); } diff --git a/js/main.js b/js/main.js index 6849268..f2330ab 100644 --- a/js/main.js +++ b/js/main.js @@ -1,10 +1,49 @@ let advanced_open = false; let alliance_mode = false; +const EDITIONS = { + BASEGAME: { + id: 'BaseGame', + maxFactions: 17, + maxSlices: 5, + maxLegendaries: 0, + }, + POK: { + id: 'PoK', + maxFactions: 7, + maxSlices: 4, + maxLegendaries: 2, + }, + TE: { + id: 'TE', + maxFactions: 7, + maxSlices: 3, + maxLegendaries: 5 + }, + DS: { + id: 'DS', + maxFactions: 24, + maxSlices: 0, + maxLegendaries: 0 + }, + DSPLUS: { + id: 'DSPlus', + maxFactions: 10, + maxSlices: 1, + maxLegendaries: 5 + }, +}; + $(document).ready(function () { + $('input[data-toggle-expansion]').each((_, el) => { + toggleExpansion($(el)); + }).on('change', (e) => { + toggleExpansion($(e.currentTarget)) + }); + + // @todo initial check + // @todo disable DS/DSPlus when Pok is disabled - pok_check(); - $('#pok').on('change', pok_check); $('.draft-faction').on('change', faction_check); $('#tabs nav a').on('click', function (e) { @@ -58,19 +97,21 @@ $(document).ready(function () { formData.append(values[i].name, values[i].value); } + // @todo json body const request = new XMLHttpRequest(); request.open("POST", routes.generate); request.onreadystatechange = function () { - if (request.readyState != 4 || request.status != 200) return; + if (request.readyState != 4) return; + + let data = JSON.parse(request.responseText); if (data.error) { $('#error').show().html(data.error); loading(false); - } else { + } else if (request.status == 200) { localStorage.setItem('admin_' + data.id, data.admin); - window.location.href = "d/" + data.id + '?fresh=1'; } // alert("Success: " + r.responseText); @@ -96,6 +137,54 @@ $(document).ready(function () { init_player_count(); }); +function toggleExpansion($checkbox) { + const expansion = $checkbox.data('toggle-expansion'); + $checkbox.is(':checked') ? enableExpansion(expansion) : disableExpansion(expansion); +} + +function enableExpansion(expansion) { + $('[data-expansion="' + expansion + '"]') + .prop('disabled', false) + .removeClass('disabled'); + $('.check[data-expansion="' + expansion + '"] input').each((_, el) => { + $checkbox = $(el); + $checkbox.prop('disabled', false) + $checkbox.prop('checked', $checkbox.hasClass('auto-enable')) + }); + + if (expansion == 'PoK') { + toggleDiscordantAvailability(true); + } +} + +function toggleDiscordantAvailability(pokIsEnabled) { + + if (pokIsEnabled) { + $('[data-toggle-expansion="DS"]').prop('disabled', false).parent().removeClass('disabled'); + $('[data-toggle-expansion="DSPlus"]').prop('disabled', false).parent().removeClass('disabled'); + } else { + $('[data-toggle-expansion="DS"]').prop('disabled', true).parent().addClass('disabled'); + $('[data-toggle-expansion="DSPlus"]').prop('disabled', true).parent().addClass('disabled'); + disableExpansion('DS'); + disableExpansion('DSPlus'); + } +} + +function disableExpansion(expansion) { + $('[data-expansion="' + expansion + '"]') + .prop('disabled', true) + .addClass('disabled'); + $('.check[data-expansion="' + expansion + '"] input').each((_, el) => { + $checkbox = $(el); + $checkbox.prop('disabled', true) + $checkbox.prop('checked', false) + }); + + if (expansion == 'PoK') { + toggleDiscordantAvailability(false); + } +} + function update_alliance_mode() { alliance_mode = $('#alliance_toggle').is(':checked'); @@ -135,62 +224,6 @@ function loading(loading = true) { } } -function pok_check() { - let $pokf = $('#pokf'); - let $keleres = $('#keleres'); - let $legendaries = $('#min_legendaries'); - let $DSTiles = $('#DSTiles'); - let $discordant = $('#discordant'); - let $discordantexp = $('#discordantexp'); - - if ($('#pok').is(':checked')) { - - // When POK is checked, allow POK dependant items to be selectable - $pokf.prop('disabled', false); - $pokf.parent().removeClass('disabled'); // I think these all share the same parent now, so additional lines might be moot. - - $keleres.prop('disabled', false); - $keleres.parent().removeClass('disabled'); - - - $legendaries.prop('disabled', false); - $legendaries.parent().removeClass('disabled'); - - $DSTiles.prop('disabled', false); - $DSTiles.parent().removeClass('disabled'); - - $discordant.prop('disabled', false); - $discordant.parent().removeClass('disabled'); - - $discordantexp.prop('disabled', false); - $discordantexp.parent().removeClass('disabled'); - } else { - - // When POK is not checked, disable options that depend on POK - $pokf.prop('checked', false) - .prop('disabled', true); - $pokf.parent().addClass('disabled'); - - $keleres.prop('checked', false) - .prop('disabled', true); - $keleres.parent().addClass('disabled'); - - $legendaries.val(0) - .prop('disabled', true); - $legendaries.parent().addClass('disabled'); - - $discordant.prop('checked', false) - .prop('disabled', true); - $discordant.parent().addClass('disabled'); - - $discordantexp.prop('checked', false) - .prop('disabled', true); - $discordantexp.parent().addClass('disabled'); - } - - faction_check(); -} - function faction_check() { let sets = []; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f2cb8d2 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + tmpDir: ./tmp/phpstan/ + inferPrivatePropertyTypeFromConstructor: true + level: 5 + paths: + - app + excludePaths: + - ./*/*Test.php + - ./app/Testing/* + - ./app/api/* \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6c4b087 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + ./app + + + ./data + + + + + + ./app + + + + + + + + + diff --git a/templates/draft.php b/templates/draft.php index 2c881e7..3e1e246 100644 --- a/templates/draft.php +++ b/templates/draft.php @@ -1,26 +1,21 @@ - - - <?= $draft ? $draft->name() . ' | ' : '' ?>TI4 - Milty Draft - + <?= $draft->settings->name ?> | TI4 - Milty Draft + - + @@ -33,13 +28,8 @@
- - -

name() ?>

-

Milty Draft

- -

Milty Draft

- +

settings->name ?>

+

Milty Draft

- -

Draft not found. (or something else went wrong)

- -
-

It's x's turn to draft something. You are the admin so you can do this for them.

-
-
-

This draft is over. View map

-
+
+

It's x's turn to draft something. You are the admin so you can do this for them.

+
+
+

This draft is over. View map

+
- log())) : ?> -

- Something not quite right? Untill the first draft-pick is done you can regenerate the draft options. -

- - -
- players()) as $i => $player) : ?> -
-

[Team ' . $player['team'] . ']' : '' ?>

- - you -

- Slice: ?
- Faction: ?
- Position: ? -

-

- - -

-
- -
+ canRegenerate()) : ?> +

+ Something not quite right? Untill the first draft-pick is done you can regenerate the draft options. +

+ -
-

Factions

-
- factions() as $f) : ?> - - - -
-
-
- -
- [reference] - [wiki]
- - -
+
+ players) as $i => $player) : ?> +
+

name ?> team ? '[Team ' . $player->team . ']' : '' ?>

+ + you +

+ Slice: ?
+ Faction: ?
+ Position: ? +

+

+ + +

+
+ +
+
+

Factions

+
+ factionPool as $faction) : ?> +
+
+
+ + name ?>
+ [reference] + [wiki]
+ +
- -
+ +
+
-
-

Slices

-
- slices() as $slice_id => $slice) : ?> -
-
-
- $tile) : ?> - - - - -
+
+
+

Slices

+
+ slicePool as $sliceId => $slice) : ?> +
+
+
+ tiles as $i => $tile) : ?> + + + +
+
+ +
+

Slice

+ +
+ specialties as $s) : ?> + <?= $s->value ?> + + -
-

Slice

- -
- - <?= $s ?> - - - - - - - - - - α - - β - - α - β - - γ - - -
- -

- Total: - - -

- -

- Optimal: - - -

- -

- - -

+ legendaryPlanets as $l) : ?> + + + + wormholes as $w) : ?> + symbol() ?> +
+ +

+ Total: + totalResources ?> + totalInfluence ?> +

+ +

+ Optimal: + optimalResources ?> + optimalInfluence ?> +

+ +

+ + +

- -
+
+
+
-
-

Positions

-
- players()); $i++) : ?> -
- - - SPEAKER - - - - +
+

Positions

+
+ players); $i++) : ?> +
+ + + SPEAKER + + + + - - -
- -
+ + +
+
+
- - - +
- config(); ?>
- log())) : ?> + canRegenerate()) : ?>

- +

@@ -228,75 +200,55 @@

Configuration used

- players()) ?> + players) ?>

- preset_draft_order == true, 'yes', 'no') ?> + settings->presetDraftOrder) ?>

- num_slices ?> + settings->numberOfSlices ?>

- num_factions ?> + settings->numberOfFactions ?>

- include_pok, 'yes', 'no') ?> + ", $draft->settings->factionSetNames()) ?>

- include_ds_tiles, 'yes', 'no') ?> + ", $draft->settings->tileSetNames()) ?>

- include_te_tiles, 'yes', 'no') ?> + settings->includeCouncilKeleresFaction) ?>

- include_base_factions, 'yes', 'no') ?> + settings->minimumTwoAlphaAndBetaWormholes) ?>

- include_pok_factions, 'yes', 'no') ?> + settings->minimumLegendaryPlanets ?>

- include_keleres, 'yes', 'no') ?> -

-

- include_te_factions, 'yes', 'no') ?> -

-

- include_discordant, 'yes', 'no') ?> -

-

- include_discordantexp, 'yes', 'no') ?> -

- min_legendaries)) : ?> -

- min_wormholes ?> -

-

- min_legendaries ?> -

- -

- max_1_wormhole) && $config->max_1_wormhole, 'yes', 'no') ?> + settings->maxOneWormholesPerSlice) ?>


- minimum_optimal_influence ?> + settings->minimumOptimalInfluence ?>

- minimum_optimal_resources ?> + settings->minimumOptimalResources ?>

- minimum_optimal_total ?> + settings->minimumOptimalTotal ?>

- maximum_optimal_total ?> + settings->maximumOptimalTotal ?>

- custom_factions != null) : ?> - custom_factions) ?> + settings->customFactions != null) : ?> + settings->customFactions) ?> no @@ -304,8 +256,8 @@

- custom_slices != null) : ?> - custom_slices as $slice) : ?> + settings->customSlices != null) : ?> + settings->customSlices as $slice) : ?>
@@ -314,27 +266,27 @@

- seed ?> + settings->seed->getValue() ?>

- slices() as $slice_id => $slice) : ?> -
+ slicePool as $slice_id => $slice) : ?> + tileIds()); ?>

- alliance) : ?> + settings->allianceMode) : ?>

Alliance Configuration

- alliance["alliance_teams"] ?? "") ?> + settings->allianceTeamMode->value) ?>

- alliance["alliance_teams_position"] ?? "") ?> + settings->allianceTeamPosition->value) ?>

- alliance["force_double_picks"], 'Yes', 'No') ?> + settings->allianceForceDoublePicks) ?>

@@ -478,16 +430,16 @@ window.routes = { "claim": "", "pick": "", - "regenerate": "", + "regenerate": "", "tile_images": "", - "data": "", + "data": "id) ?>", "undo": "", "restore": "" } - - - + + + diff --git a/templates/error.php b/templates/error.php new file mode 100644 index 0000000..384f462 --- /dev/null +++ b/templates/error.php @@ -0,0 +1,55 @@ + + + + + + + + TI4 - Milty Draft | Something went wrong + + + + + + + + + + + + + + + + + + +
+ +

Milty Draft

+ +
+ +
+
+

+

+ Back to Homesystem +

+
+
+ +
+
+ + + diff --git a/templates/factions.php b/templates/factions.php index edb5d83..32eed2c 100644 --- a/templates/factions.php +++ b/templates/factions.php @@ -2,8 +2,20 @@ $factions = json_decode(file_get_contents('data/factions.json'), true); +function translateSet($set): string +{ + return match($set) { + 'base' => \App\TwilightImperium\Edition::BASE_GAME->value, + 'pok' => \App\TwilightImperium\Edition::PROPHECY_OF_KINGS->value, + 'te' => \App\TwilightImperium\Edition::THUNDERS_EDGE->value, + 'discordant' => \App\TwilightImperium\Edition::DISCORDANT_STARS->value, + 'discordantexp' => \App\TwilightImperium\Edition::DISCORDANT_STARS_PLUS->value, + 'keleres' => \App\TwilightImperium\Edition::THUNDERS_EDGE->value, + }; +} + foreach ($factions as $f) { - $fact = '
@@ -456,8 +468,8 @@ } - - + +