Compare commits
10 Commits
f9d60a7d89
...
4d269f8fb0
Author | SHA1 | Date |
---|---|---|
Krzysztof Sikorski | 4d269f8fb0 | |
Krzysztof Sikorski | 59d2500271 | |
Krzysztof Sikorski | e74098c1a2 | |
Krzysztof Sikorski | 1b48d21e9f | |
Krzysztof Sikorski | 4ee90caaec | |
Krzysztof Sikorski | e6009d48b6 | |
Krzysztof Sikorski | 3909314bde | |
Krzysztof Sikorski | 1bbb8b5ebe | |
Krzysztof Sikorski | c6fba257cb | |
Krzysztof Sikorski | 0ef7fcd709 |
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -1,3 +1,13 @@
|
|||
# Version 1.0.0
|
||||
|
||||
- create interfaces and classes to represent leaderboard table as seen in game
|
||||
- create Doctrine entities to persist these leaderboard tables in database
|
||||
- implement general parser infrastructure to handle stored page views
|
||||
- implement parser for Breath 4 final leaderboards
|
||||
- implement a public page for browsing leaderboards
|
||||
- create and apply basic UI theme/styling, based on Tailwind CSS
|
||||
- some more internal code cleanups
|
||||
|
||||
# Version 0.5.0
|
||||
|
||||
- fix crash in token command on empty username input
|
||||
|
|
15
README.md
15
README.md
|
@ -7,7 +7,18 @@ The <q>Nexus Archive</q> website, based on Symfony framework.
|
|||
This project is licensed under [European Union Public Licence (EUPL)][EUPL].
|
||||
|
||||
For convenience an English text of the licence is included
|
||||
in [LICENSE.txt](LICENSE.txt) file.
|
||||
in [LICENSE.txt](./LICENSE.txt) file.
|
||||
|
||||
## Repositories
|
||||
|
||||
Source code is primarily hosted
|
||||
on [my private Git server](https://git.zerozero.pl/nexus-archive), but for
|
||||
convenience and redundancy it is also mirrored to a few popular code hosting
|
||||
portals:
|
||||
|
||||
- [Gitlab mirror](https://gitlab.com/krzysztof-sikorski/nexus-archive)
|
||||
- [GitHub mirror](https://github.com/krzysztof-sikorski/nexus-archive)
|
||||
- [Launchpad mirror](https://git.launchpad.net/nexus-archive)
|
||||
|
||||
## Installation and deployment
|
||||
|
||||
|
@ -16,7 +27,7 @@ software stack of:
|
|||
|
||||
- an http server (e.g. Nginx)
|
||||
- PHP binaries and some standard extensions (
|
||||
see [composer.json file](composer.json) for details)
|
||||
see [composer.json file](./composer.json) for details)
|
||||
- [Composer][Composer] tool (for fetching and installing third-party PHP
|
||||
libraries)
|
||||
- a relational database server supporting SQL language (e.g. PostgreSQL)
|
||||
|
|
|
@ -50,3 +50,16 @@ table {
|
|||
table th, table td {
|
||||
@apply tw-border-solid tw-border-2 tw-border-black tw-px-4 tw-py-2;
|
||||
}
|
||||
|
||||
section.leaderboard-grid {
|
||||
@apply tw-grid tw-gap-x-8 tw-gap-y-4
|
||||
tw-grid-cols-1 md:tw-grid-cols-2 xl:tw-grid-cols-3;
|
||||
}
|
||||
|
||||
section.leaderboard-grid caption {
|
||||
@apply tw-text-lg tw-font-bold;
|
||||
}
|
||||
|
||||
section.leaderboard-grid article.error {
|
||||
@apply tw-my-4 tw-text-red-500 tw-text-lg tw-font-bold;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Service\MainMenuService;
|
||||
use App\Service\MainMenuGenerator;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
use Symfony\Config\TwigConfig;
|
||||
|
||||
|
@ -11,8 +11,8 @@ use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
|
|||
return static function (TwigConfig $twigConfig, ContainerConfigurator $containerConfigurator) {
|
||||
$twigConfig->defaultPath(value: '%kernel.project_dir%/templates');
|
||||
|
||||
$mainMenuConfig = $twigConfig->global(key: 'mainMenu');
|
||||
$mainMenuConfig->value(value: service(serviceId: MainMenuService::class));
|
||||
$mainMenuConfig = $twigConfig->global(key: 'mainMenuGenerator');
|
||||
$mainMenuConfig->value(value: service(serviceId: MainMenuGenerator::class));
|
||||
|
||||
if ('test' === $containerConfigurator->env()) {
|
||||
$twigConfig->strictVariables(value: true);
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Contract\Entity\Nexus\GamePeriodIdEnum;
|
||||
use App\Doctrine\Entity\Nexus\GamePeriod;
|
||||
use App\Doctrine\Entity\Nexus\Leaderboard;
|
||||
use App\Doctrine\Entity\Nexus\LeaderboardEntry;
|
||||
use App\Service\Repository\Nexus\GamePeriodRepository;
|
||||
use App\Service\Repository\Nexus\LeaderboardRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
use function array_key_exists;
|
||||
use function intval;
|
||||
use function is_numeric;
|
||||
use function max;
|
||||
use function mb_convert_case;
|
||||
use function mb_strlen;
|
||||
use function printf;
|
||||
use function sprintf;
|
||||
|
||||
use const MB_CASE_TITLE;
|
||||
use const PHP_EOL;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:export:leaderboards',
|
||||
description: 'Export leaderboards into a file',
|
||||
)]
|
||||
final class ExportLeaderboardsCommand extends BaseCommand
|
||||
{
|
||||
private const OPTION_NAME_GAME_PERIOD_ID = 'period';
|
||||
private const TABLE_HEADER_CHARACTER = 'Character';
|
||||
private const ENCODING_UTF8 = 'UTF-8';
|
||||
|
||||
private const BREATH_4_NAME_REPLACEMENTS = [
|
||||
"I\u{00c3}\u{00af}\u{00c2}\u{00bf}\u{00c2}\u{00bd}unn" => "I\u{00f0}unn",
|
||||
"J\u{00c3}\u{00af}\u{00c2}\u{00bf}\u{00c2}\u{00bd}stein Bever" => "J\u{00f8}stein Bever",
|
||||
"Mockfj\u{00c3}\u{00af}\u{00c2}\u{00bf}\u{00c2}\u{00bd}rdsvapnet" => "Mockfj\u{00e4}rdsvapnet",
|
||||
];
|
||||
|
||||
private ?GamePeriod $gamePeriod = null;
|
||||
|
||||
public function __construct(
|
||||
private GamePeriodRepository $gamePeriodRepository,
|
||||
private LeaderboardRepository $leaderboardRepository,
|
||||
SerializerInterface $serializer,
|
||||
) {
|
||||
parent::__construct(serializer: $serializer);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
name: self::OPTION_NAME_GAME_PERIOD_ID,
|
||||
mode: InputOption::VALUE_REQUIRED,
|
||||
description: 'Game period ID',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = $this->createSymfonyStyle(input: $input, output: $output);
|
||||
$helper = $this->getQuestionHelper();
|
||||
$question = new Question(question: 'Input game period ID:');
|
||||
|
||||
$inputStr = $input->getOption(name: self::OPTION_NAME_GAME_PERIOD_ID);
|
||||
$gamePeriodId = is_numeric($inputStr) ? intval($inputStr) : null;
|
||||
|
||||
while (true) {
|
||||
if (null === $gamePeriodId) {
|
||||
do {
|
||||
$inputStr = $helper->ask(input: $input, output: $output, question: $question);
|
||||
$gamePeriodId = is_numeric($inputStr) ? intval($inputStr) : null;
|
||||
} while (null === $gamePeriodId);
|
||||
}
|
||||
$this->gamePeriod = $this->gamePeriodRepository->findById(id: $gamePeriodId);
|
||||
if (null === $this->gamePeriod) {
|
||||
$io->error(message: sprintf('Game period with id=%s does not exists!', $inputStr));
|
||||
$gamePeriodId = null;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = $this->createSymfonyStyle(input: $input, output: $output);
|
||||
|
||||
$this->displayValue(io: $io, label: 'Selected game period', value: $this->gamePeriod->getName());
|
||||
|
||||
$leaderboards = $this->leaderboardRepository->findByGamePeriod(gamePeriod: $this->gamePeriod);
|
||||
|
||||
/** @var Leaderboard $leaderboard */
|
||||
foreach ($leaderboards as $leaderboard) {
|
||||
echo PHP_EOL, PHP_EOL, PHP_EOL;
|
||||
$category = $leaderboard->getCategory();
|
||||
printf(
|
||||
'[b]%s (%s)[/b]',
|
||||
$category->getName(),
|
||||
mb_convert_case(string: $category->getType(), mode: MB_CASE_TITLE, encoding: self::ENCODING_UTF8),
|
||||
);
|
||||
echo PHP_EOL, '[code]', PHP_EOL;
|
||||
$headerCharacterLength = mb_strlen(string: self::TABLE_HEADER_CHARACTER, encoding: self::ENCODING_UTF8);
|
||||
$characterColumnWidth = $headerCharacterLength;
|
||||
$entries = [];
|
||||
/** @var LeaderboardEntry $entry */
|
||||
foreach ($leaderboard->getEntries() as $entry) {
|
||||
$characterName = $entry->getCharacterName();
|
||||
if (
|
||||
GamePeriodIdEnum::BREATH_4 === $this->gamePeriod->getId()
|
||||
&& array_key_exists(key: $characterName, array: self::BREATH_4_NAME_REPLACEMENTS)
|
||||
) {
|
||||
$characterName = self::BREATH_4_NAME_REPLACEMENTS[$characterName];
|
||||
}
|
||||
$characterNameLength = mb_strlen(string: $characterName, encoding: self::ENCODING_UTF8);
|
||||
$characterColumnWidth = max($characterColumnWidth, $characterNameLength + 4);
|
||||
$entries[] = [
|
||||
'position' => $entry->getPosition(),
|
||||
'characterName' => $characterName,
|
||||
'characterNameLength' => $characterNameLength,
|
||||
'score' => $entry->getScore(),
|
||||
];
|
||||
}
|
||||
$padding = str_repeat(string: ' ', times: $characterColumnWidth - $headerCharacterLength);
|
||||
printf('%s%s %s', self::TABLE_HEADER_CHARACTER, $padding, $category->getScoreLabel());
|
||||
echo PHP_EOL;
|
||||
/** @var LeaderboardEntry $entry */
|
||||
foreach ($entries as $entry) {
|
||||
$characterStr = sprintf('%d) %s', $entry['position'], $entry['characterName']);
|
||||
$characterStrLength = mb_strlen(string: $characterStr, encoding: self::ENCODING_UTF8);
|
||||
$padding = str_repeat(string: ' ', times: $characterColumnWidth - $characterStrLength);
|
||||
printf('%s%s %s', $characterStr, $padding, $entry['score']);
|
||||
echo PHP_EOL;
|
||||
}
|
||||
echo '[/code]', PHP_EOL;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
|
@ -7,11 +7,15 @@ namespace App\Contract\Config;
|
|||
// TODO convert into native enum when PHP 8.1 is released
|
||||
final class AppRoutes
|
||||
{
|
||||
// public routes
|
||||
public const HOME = 'app_home';
|
||||
public const FAVICON_ICO = 'app_favicon_ico';
|
||||
public const LEADERBOARDS = 'app_leaderboards';
|
||||
public const ABOUT = 'app_about';
|
||||
public const SUBMIT_JSON = 'app_submit_json';
|
||||
|
||||
// undocumented routes
|
||||
public const LOGIN = 'app_login';
|
||||
public const LOGOUT = 'app_logout';
|
||||
public const SUBMIT_JSON = 'app_submit_json';
|
||||
public const EASYADMIN = 'app_easyadmin';
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Contract\Config\AppRoutes;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
final class AboutController
|
||||
{
|
||||
public function __construct(private Environment $twigEnvironment)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '/about', name: AppRoutes::ABOUT, methods: [Request::METHOD_GET])]
|
||||
public function index(): Response
|
||||
{
|
||||
$responseBody = $this->twigEnvironment->render(name: 'about/index.html.twig');
|
||||
|
||||
return new Response(content: $responseBody);
|
||||
}
|
||||
}
|
|
@ -38,7 +38,10 @@ class Leaderboard implements UuidPrimaryKeyInterface, GamePeriodReferenceInterfa
|
|||
]
|
||||
private ?GamePeriodInterface $gamePeriod = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'leaderboard', targetEntity: LeaderboardEntry::class)]
|
||||
#[
|
||||
ORM\OneToMany(mappedBy: 'leaderboard', targetEntity: LeaderboardEntry::class),
|
||||
ORM\OrderBy(value: ['position' => 'ASC']),
|
||||
]
|
||||
private Collection $entries;
|
||||
|
||||
public function __construct()
|
||||
|
|
|
@ -7,7 +7,7 @@ namespace App\Service;
|
|||
use App\Contract\Config\AppRoutes;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
final class MainMenuService
|
||||
final class MainMenuGenerator
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
|
@ -27,20 +27,15 @@ final class MainMenuService
|
|||
'external' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Submit data',
|
||||
'url' => $this->urlGenerator->generate(name: AppRoutes::SUBMIT_JSON),
|
||||
'name' => 'About website',
|
||||
'url' => $this->urlGenerator->generate(name: AppRoutes::ABOUT),
|
||||
'external' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Nexus Clash',
|
||||
'name' => 'Back to Nexus Clash',
|
||||
'url' => 'https://www.nexusclash.com/',
|
||||
'external' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Discord',
|
||||
'url' => 'https://discord.gg/zBVwzD3f8v',
|
||||
'external' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}About - Nexus Archive{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section>
|
||||
<h1>About Nexus Archive</h1>
|
||||
<p>
|
||||
This website was created to automatically collect and display various data
|
||||
from the world of <a href="https://www.nexusclash.com/" rel="external">Nexus Clash</a> game.
|
||||
</p>
|
||||
<p>
|
||||
Currently the only implemented feature is a "time machine" that presents various Leaderboards
|
||||
in their state at the end of Breath 3.5.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Contact information</h1>
|
||||
<p>
|
||||
My Nexus Clash account is
|
||||
<a href="https://www.nexusclash.com/memberlist.php?mode=viewprofile&u=108" rel="external">Badziew</a>,
|
||||
you can send me a private message or start a forum thread.
|
||||
</p>
|
||||
<p>
|
||||
My Discord username is <code>That Crazy Old Badziew#1362</code>,
|
||||
you can find me on a few different Discord servers,<br>
|
||||
including my own personal server
|
||||
<a href="https://discord.gg/zBVwzD3f8v" rel="external">House of Badziew</a>.
|
||||
Feel free to drop a message in one of these places.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Contributing data</h1>
|
||||
<p>This webpage is still just an early prototype and there is no official, user-friendly way to submit data.</p>
|
||||
<p>If you really want to use the same experimental procedure that I used, please contact me for details.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Open source</h1>
|
||||
<p>
|
||||
Source code is primarily hosted on
|
||||
<a href="https://git.zerozero.pl/nexus-archive" rel="external">my private Git server</a>,
|
||||
but for convenience and redundancy it is also mirrored to a few popular code hosting portals:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://gitlab.com/krzysztof-sikorski/nexus-archive" rel="external">Gitlab mirror</a></li>
|
||||
<li><a href="https://github.com/krzysztof-sikorski/nexus-archive" rel="external">GitHub mirror</a></li>
|
||||
<li><a href="https://git.launchpad.net/nexus-archive" rel="external">Launchpad mirror</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Bugs and patches</h1>
|
||||
<p>
|
||||
If you want to report a bug or submit a code patch, I would prefer if you used one of the above-mentioned
|
||||
code hosting portals, but you can also open a forum thread or send me a private message.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -10,7 +10,7 @@
|
|||
<header>
|
||||
<nav>
|
||||
<menu>
|
||||
{% for menuItem in mainMenu.getMenu() %}
|
||||
{% for menuItem in mainMenuGenerator.getMenu() %}
|
||||
{% if menuItem.external %}
|
||||
<li><a href="{{ menuItem.url }}" rel="external">{{ menuItem.name }}</a></li>
|
||||
{% else %}
|
||||
|
|
Reference in New Issue