feat: catalog curated unit archetypes and templates

This commit is contained in:
Keith Solomon
2026-07-05 18:41:53 -05:00
parent 78fc802cdc
commit 0ac8c06c5e
5 changed files with 250 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Archetype: string
{
case Defender = 'defender';
case Striker = 'striker';
case Support = 'support';
case Scout = 'scout';
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class ArchetypeCatalog
{
/**
* @return array<string, ArchetypeTemplate>
*/
public static function templates(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = [
Archetype::Defender->value => new ArchetypeTemplate(
minHealth: 10,
maxHealth: 14,
minAttack: 2,
maxAttack: 4,
minDefense: 3,
maxDefense: 5,
minSpeed: 1,
maxSpeed: 3,
allowedAbilities: ['buff'],
),
Archetype::Striker->value => new ArchetypeTemplate(
minHealth: 6,
maxHealth: 10,
minAttack: 4,
maxAttack: 6,
minDefense: 1,
maxDefense: 2,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['area_damage'],
),
Archetype::Support->value => new ArchetypeTemplate(
minHealth: 5,
maxHealth: 9,
minAttack: 1,
maxAttack: 3,
minDefense: 1,
maxDefense: 3,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
),
Archetype::Scout->value => new ArchetypeTemplate(
minHealth: 4,
maxHealth: 7,
minAttack: 2,
maxAttack: 4,
minDefense: 0,
maxDefense: 2,
minSpeed: 4,
maxSpeed: 6,
allowedAbilities: [],
),
];
return $cache;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ArchetypeTemplate
{
/** @param list<string> $allowedAbilities */
public function __construct(
public int $minHealth,
public int $maxHealth,
public int $minAttack,
public int $maxAttack,
public int $minDefense,
public int $maxDefense,
public int $minSpeed,
public int $maxSpeed,
public array $allowedAbilities,
) {
if ($this->minHealth > $this->maxHealth) {
throw new InvalidArgumentException('Health range is inverted.');
}
if ($this->minAttack > $this->maxAttack) {
throw new InvalidArgumentException('Attack range is inverted.');
}
if ($this->minDefense > $this->maxDefense) {
throw new InvalidArgumentException('Defense range is inverted.');
}
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
}
if ($this->minHealth < 1) {
throw new InvalidArgumentException('Minimum health must be at least 1.');
}
}
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
public function defaultStats(): array
{
return [
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
];
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeCatalogTest extends TestCase
{
public function testItExposesExactlyTheFourPlannedArchetypes(): void
{
$keys = array_keys(ArchetypeCatalog::templates());
sort($keys);
self::assertSame(['defender', 'scout', 'striker', 'support'], $keys);
}
public function testEveryArchetypeTemplateCarriesAllFourStatRanges(): void
{
foreach (ArchetypeCatalog::templates() as $key => $template) {
self::assertInstanceOf(ArchetypeTemplate::class, $template);
self::assertLessThanOrEqual($template->maxHealth, $template->minHealth);
self::assertLessThanOrEqual($template->maxAttack, $template->minAttack);
self::assertLessThanOrEqual($template->maxDefense, $template->minDefense);
self::assertLessThanOrEqual($template->maxSpeed, $template->minSpeed);
self::assertGreaterThanOrEqual(1, $template->minSpeed);
}
}
public function testEveryArchetypeTemplateDefaultsToTheCenterOfItsRanges(): void
{
foreach (ArchetypeCatalog::templates() as $template) {
$defaults = $template->defaultStats();
self::assertSame((int) ceil(($template->minHealth + $template->maxHealth) / 2), $defaults['maxHealth']);
self::assertSame((int) ceil(($template->minAttack + $template->maxAttack) / 2), $defaults['attack']);
self::assertSame((int) ceil(($template->minDefense + $template->maxDefense) / 2), $defaults['defense']);
self::assertSame((int) ceil(($template->minSpeed + $template->maxSpeed) / 2), $defaults['speed']);
}
}
public function testDefaultStatsReturnAllFourKeys(): void
{
$template = ArchetypeCatalog::templates()[Archetype::Defender->value];
self::assertSame(
['maxHealth', 'attack', 'defense', 'speed'],
array_keys($template->defaultStats()),
);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeTemplateTest extends TestCase
{
public function testItRejectsInvertedStatRanges(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 10,
maxHealth: 5,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItRejectsZeroMinimumSpeed(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 0,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItExposesAllowedAbilitiesVerbatim(): void
{
$template = new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
);
self::assertSame(['heal', 'buff'], $template->allowedAbilities);
}
}