feat(combat): to-hit roll, ±15% damage variance, crits, and new log format
This commit is contained in:
@@ -8,6 +8,14 @@ use InvalidArgumentException;
|
|||||||
|
|
||||||
final class CombatEngine
|
final class CombatEngine
|
||||||
{
|
{
|
||||||
|
public const TO_HIT_BASE = 40;
|
||||||
|
public const TO_HIT_PER_ATTACK = 3;
|
||||||
|
public const TO_HIT_FLOOR = 5;
|
||||||
|
public const TO_HIT_CEILING = 95;
|
||||||
|
public const CRIT_THRESHOLD = 95;
|
||||||
|
public const DAMAGE_VARIANCE_MIN_PCT = 85;
|
||||||
|
public const DAMAGE_VARIANCE_MAX_PCT = 115;
|
||||||
|
|
||||||
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
||||||
{
|
{
|
||||||
$this->assertMatchActive($match);
|
$this->assertMatchActive($match);
|
||||||
@@ -53,19 +61,59 @@ final class CombatEngine
|
|||||||
throw new CombatException('Target must be an active enemy unit.');
|
throw new CombatException('Target must be an active enemy unit.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$distance = $attacker->position->distanceTo($target->position);
|
if ($attacker->position->distanceTo($target->position) !== 1) {
|
||||||
|
|
||||||
if ($distance !== 1) {
|
|
||||||
throw new CombatException('Target is outside attack range.');
|
throw new CombatException('Target is outside attack range.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
||||||
$damage = max(1, $attacker->attack + $attacker->attackBonus - $target->defense - $terrainDefense);
|
$flanking = $this->flankingBonusFor($match, $attacker, $target);
|
||||||
|
$threshold = $this->thresholdFor($attacker, $target, $terrainDefense, $flanking);
|
||||||
|
$roll = random_int(1, 100);
|
||||||
|
|
||||||
|
$logParts = [
|
||||||
|
"{$attacker->id} attacked {$target->id}",
|
||||||
|
"(rolled {$roll} / needed {$threshold})",
|
||||||
|
];
|
||||||
|
|
||||||
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
||||||
|
|
||||||
|
if ($roll < $threshold) {
|
||||||
|
// To-hit miss; log and return without applying damage.
|
||||||
|
$logParts[] = 'for 0 damage';
|
||||||
|
$logParts[] = '— miss';
|
||||||
|
$next = $match->withUnit($updatedAttacker);
|
||||||
|
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forest concealment: independent 15% miss roll.
|
||||||
|
if ($match->battlefield->terrainAt($target->position)->movementCost() === 2) {
|
||||||
|
// Forest and rough both have cost 2; only forest has concealment. Distinguish by name.
|
||||||
|
$terrainName = $match->battlefield->terrainAt($target->position)->name;
|
||||||
|
if ($terrainName === 'Forest') {
|
||||||
|
$concealmentRoll = random_int(1, 100);
|
||||||
|
if ($concealmentRoll <= 15) {
|
||||||
|
$logParts[] = 'for 0 damage';
|
||||||
|
$logParts[] = '— miss — concealed';
|
||||||
|
$next = $match->withUnit($updatedAttacker);
|
||||||
|
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isCrit = $roll >= self::CRIT_THRESHOLD;
|
||||||
|
$base = max(1, ($attacker->attack + $attacker->attackBonus) - ($target->defense + $terrainDefense));
|
||||||
|
$damage = $this->applyDamage($base, $isCrit);
|
||||||
|
|
||||||
|
$logParts[] = "for {$damage} damage";
|
||||||
|
if ($isCrit) {
|
||||||
|
$logParts[] = '— crit';
|
||||||
|
}
|
||||||
|
|
||||||
$updatedTarget = $target->takeDamage($damage);
|
$updatedTarget = $target->takeDamage($damage);
|
||||||
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
|
$next = $match
|
||||||
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
|
->withUnit($updatedAttacker)
|
||||||
$next = $next->copy(actionLog: $actionLog);
|
->withUnit($updatedTarget);
|
||||||
|
$next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
|
||||||
|
|
||||||
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
||||||
}
|
}
|
||||||
@@ -344,6 +392,50 @@ final class CombatEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function thresholdFor(UnitState $attacker, UnitState $target, int $terrainDefense, int $flankingBonus): int
|
||||||
|
{
|
||||||
|
$raw = self::TO_HIT_BASE
|
||||||
|
+ ($attacker->attack * self::TO_HIT_PER_ATTACK)
|
||||||
|
- ($target->defense + $terrainDefense)
|
||||||
|
- $flankingBonus;
|
||||||
|
if ($raw < self::TO_HIT_FLOOR) {
|
||||||
|
return self::TO_HIT_FLOOR;
|
||||||
|
}
|
||||||
|
if ($raw > self::TO_HIT_CEILING) {
|
||||||
|
return self::TO_HIT_CEILING;
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($match->units as $other) {
|
||||||
|
if ($other->id === $attacker->id || $other->id === $target->id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($other->isDefeated() || $other->teamId !== $attacker->teamId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dx = abs($other->position->x - $target->position->x);
|
||||||
|
$dy = abs($other->position->y - $target->position->y);
|
||||||
|
if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
|
||||||
|
$count += 1;
|
||||||
|
if ($count >= 2) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $count * 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyDamage(int $base, bool $isCrit): int
|
||||||
|
{
|
||||||
|
$doubled = $isCrit ? $base * 2 : $base;
|
||||||
|
$pct = random_int(self::DAMAGE_VARIANCE_MIN_PCT, self::DAMAGE_VARIANCE_MAX_PCT);
|
||||||
|
return (int) round($doubled * $pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
private function assertMatchActive(MatchState $match): void
|
private function assertMatchActive(MatchState $match): void
|
||||||
{
|
{
|
||||||
if ($match->winnerTeamId !== null) {
|
if ($match->winnerTeamId !== null) {
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ final class PostMatchActionTest extends TestCase
|
|||||||
$matchArray = ScenarioSerializer::matchToArray($match);
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
|
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
|
||||||
$match = ScenarioSerializer::matchFromArray($matchArray);
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
// Loop until a hit lands; the to-hit roll can miss.
|
||||||
|
$bravo = null;
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
$token = $this->tokenFor($match);
|
$token = $this->tokenFor($match);
|
||||||
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
|
||||||
|
|
||||||
@@ -120,6 +128,11 @@ final class PostMatchActionTest extends TestCase
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($bravo !== null && $bravo['health'] < 10) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self::assertNotNull($bravo);
|
self::assertNotNull($bravo);
|
||||||
self::assertLessThan(10, $bravo['health']);
|
self::assertLessThan(10, $bravo['health']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace BattleForge\Tests\Unit\Domain;
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Application\ScenarioSerializer;
|
||||||
|
use BattleForge\Application\TurnToken;
|
||||||
use BattleForge\Domain\Archetype;
|
use BattleForge\Domain\Archetype;
|
||||||
use BattleForge\Domain\Battlefield;
|
use BattleForge\Domain\Battlefield;
|
||||||
use BattleForge\Domain\CombatEngine;
|
use BattleForge\Domain\CombatEngine;
|
||||||
@@ -14,6 +16,9 @@ use BattleForge\Domain\Position;
|
|||||||
use BattleForge\Domain\Terrain;
|
use BattleForge\Domain\Terrain;
|
||||||
use BattleForge\Domain\UnitState;
|
use BattleForge\Domain\UnitState;
|
||||||
use BattleForge\Domain\VictoryCondition;
|
use BattleForge\Domain\VictoryCondition;
|
||||||
|
use BattleForge\Http\CsrfToken;
|
||||||
|
use BattleForge\Http\Handlers\PostMatchAttack;
|
||||||
|
use BattleForge\Http\MatchActionSupport;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -242,12 +247,16 @@ final class CombatEngineTest extends TestCase
|
|||||||
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
|
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
|
||||||
self::assertFalse($match->unit('alpha-1')->hasAttacked);
|
self::assertFalse($match->unit('alpha-1')->hasAttacked);
|
||||||
self::assertSame(['match started'], $match->actionLog);
|
self::assertSame(['match started'], $match->actionLog);
|
||||||
self::assertSame(9, $next->unit('bravo-1')->health);
|
self::assertGreaterThanOrEqual(9, $next->unit('bravo-1')->health);
|
||||||
|
self::assertLessThanOrEqual(10, $next->unit('bravo-1')->health);
|
||||||
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
||||||
self::assertTrue($next->unit('alpha-1')->hasAttacked);
|
self::assertTrue($next->unit('alpha-1')->hasAttacked);
|
||||||
self::assertSame(
|
// With attack=4, defense=2, +1 forest defense: base = max(1, 4-2-1) = 1.
|
||||||
['match started', 'alpha-1 attacked bravo-1 for 1 damage'],
|
// On hit, damage = 1 (variance leaves it at 1). On miss, damage = 0.
|
||||||
$next->actionLog,
|
$log = $next->actionLog[1];
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — (miss|crit|concealed)( .+)?)?$/',
|
||||||
|
$log,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,17 +279,50 @@ final class CombatEngineTest extends TestCase
|
|||||||
|
|
||||||
public function testAttackDefeatingLastEnemyAwardsVictory(): void
|
public function testAttackDefeatingLastEnemyAwardsVictory(): void
|
||||||
{
|
{
|
||||||
$match = new MatchState(
|
$buildMatch = static function (): MatchState {
|
||||||
|
return new MatchState(
|
||||||
new Battlefield(8, 8),
|
new Battlefield(8, 8),
|
||||||
[
|
[
|
||||||
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
|
new UnitState(
|
||||||
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
'alpha-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
new UnitState(
|
||||||
|
'bravo-1',
|
||||||
|
'bravo',
|
||||||
|
new Position(1, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
$match = $buildMatch();
|
||||||
|
|
||||||
|
// The new to-hit roll can miss; loop until we find a hit that defeats the target.
|
||||||
|
$next = $match;
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$fresh = $buildMatch();
|
||||||
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
||||||
|
if ($next->unit('bravo-1')->health === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
self::assertSame(0, $next->unit('bravo-1')->health);
|
self::assertSame(0, $next->unit('bravo-1')->health);
|
||||||
self::assertSame('alpha', $next->winnerTeamId);
|
self::assertSame('alpha', $next->winnerTeamId);
|
||||||
self::assertNull($match->winnerTeamId);
|
self::assertNull($match->winnerTeamId);
|
||||||
@@ -423,54 +465,168 @@ final class CombatEngineTest extends TestCase
|
|||||||
|
|
||||||
public function testAttackDealsCalculatedOpenTerrainDamage(): void
|
public function testAttackDealsCalculatedOpenTerrainDamage(): void
|
||||||
{
|
{
|
||||||
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 3);
|
$buildMatch = static function (): MatchState {
|
||||||
$match = new MatchState(
|
$target = new UnitState(
|
||||||
|
'bravo-1',
|
||||||
|
'bravo',
|
||||||
|
new Position(1, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return new MatchState(
|
||||||
new Battlefield(8, 8),
|
new Battlefield(8, 8),
|
||||||
[
|
[
|
||||||
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 8),
|
new UnitState(
|
||||||
|
'alpha-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
8,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
$target,
|
$target,
|
||||||
],
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
// Loop until a non-crit hit lands; the test verifies the base variance on a hit.
|
||||||
|
$match = $buildMatch();
|
||||||
self::assertSame(10, $target->health);
|
$next = $match;
|
||||||
self::assertSame(5, $next->unit('bravo-1')->health);
|
for ($i = 0; $i < 500; $i += 1) {
|
||||||
self::assertSame(['alpha-1 attacked bravo-1 for 5 damage'], $next->actionLog);
|
$fresh = $buildMatch();
|
||||||
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
||||||
|
$entry = $next->actionLog[0];
|
||||||
|
if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$damage = 10 - $next->unit('bravo-1')->health;
|
||||||
|
// Base = max(1, 8-3) = 5. Variance 85-115% = 4-6 (rounded).
|
||||||
|
self::assertGreaterThanOrEqual(4, $damage);
|
||||||
|
self::assertLessThanOrEqual(6, $damage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAttackAlwaysDealsAtLeastOneDamage(): void
|
public function testAttackAlwaysDealsAtLeastOneDamage(): void
|
||||||
{
|
{
|
||||||
$match = new MatchState(
|
$buildMatch = static function (): MatchState {
|
||||||
|
return new MatchState(
|
||||||
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
|
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
|
||||||
[
|
[
|
||||||
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 0),
|
new UnitState(
|
||||||
$this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 20),
|
'alpha-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
new UnitState(
|
||||||
|
'bravo-1',
|
||||||
|
'bravo',
|
||||||
|
new Position(1, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
20,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
// Loop until a non-crit hit lands; then verify the damage is at least 1.
|
||||||
|
$match = $buildMatch();
|
||||||
|
$next = $match;
|
||||||
|
for ($i = 0; $i < 500; $i += 1) {
|
||||||
|
$fresh = $buildMatch();
|
||||||
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
||||||
|
$entry = $next->actionLog[0];
|
||||||
|
if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Base = max(1, 0-20-1) = 1. Variance 85-115% rounds to 1.
|
||||||
self::assertSame(9, $next->unit('bravo-1')->health);
|
self::assertSame(9, $next->unit('bravo-1')->health);
|
||||||
self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog);
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for 1 damage$/',
|
||||||
|
$next->actionLog[0],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void
|
public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void
|
||||||
{
|
{
|
||||||
$match = new MatchState(
|
$buildMatch = static function (): MatchState {
|
||||||
|
return new MatchState(
|
||||||
new Battlefield(8, 8),
|
new Battlefield(8, 8),
|
||||||
[
|
[
|
||||||
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
|
new UnitState(
|
||||||
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
'alpha-1',
|
||||||
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
new UnitState(
|
||||||
|
'bravo-1',
|
||||||
|
'bravo',
|
||||||
|
new Position(1, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
new UnitState(
|
||||||
|
'bravo-2',
|
||||||
|
'bravo',
|
||||||
|
new Position(7, 7),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
// Loop until we find a hit that defeats bravo-1.
|
||||||
|
$match = $buildMatch();
|
||||||
|
$next = $match;
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$fresh = $buildMatch();
|
||||||
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
||||||
|
if ($next->unit('bravo-1')->health === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
self::assertSame(0, $next->unit('bravo-1')->health);
|
self::assertSame(0, $next->unit('bravo-1')->health);
|
||||||
self::assertSame(10, $next->unit('bravo-2')->health);
|
self::assertSame(10, $next->unit('bravo-2')->health);
|
||||||
self::assertNull($next->winnerTeamId);
|
self::assertNull($next->winnerTeamId);
|
||||||
@@ -1036,4 +1192,321 @@ final class CombatEngineTest extends TestCase
|
|||||||
self::assertSame([], $next->objectiveControl);
|
self::assertSame([], $next->objectiveControl);
|
||||||
self::assertNull($next->winnerTeamId);
|
self::assertNull($next->winnerTeamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAttackRollsAboveThresholdAndDamageStaysWithinVariance(): void
|
||||||
|
{
|
||||||
|
$buildPayload = static function (): array {
|
||||||
|
$match = new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[
|
||||||
|
new UnitState(
|
||||||
|
'alpha-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
new UnitState(
|
||||||
|
'bravo-1',
|
||||||
|
'bravo',
|
||||||
|
new Position(1, 0),
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; // alpha-1
|
||||||
|
$matchArray['units'][0]['attack'] = 10;
|
||||||
|
$matchArray['units'][0]['defense'] = 0;
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; // bravo-1 (the target)
|
||||||
|
$matchArray['units'][1]['attack'] = 3;
|
||||||
|
$matchArray['units'][1]['defense'] = 0;
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'match' => $match,
|
||||||
|
'matchArray' => $matchArray,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$log = null;
|
||||||
|
// Loop until we find a hit; the to-hit roll can miss.
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$payload = $buildPayload();
|
||||||
|
$req = $this->buildRequest($payload['match'], $token, $this->attackBody($payload['matchArray']));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
self::assertSame(200, $result->status);
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'][0];
|
||||||
|
if (!str_contains($log, ' — miss')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($log);
|
||||||
|
$pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — crit)?$/';
|
||||||
|
self::assertMatchesRegularExpression($pattern, $log);
|
||||||
|
preg_match('/rolled (\d+) \/ needed (\d+)\) for (\d+)/', $log, $m);
|
||||||
|
$threshold = (int) ($m[2] ?? 0);
|
||||||
|
$roll = (int) ($m[1] ?? 0);
|
||||||
|
$damage = (int) ($m[3] ?? 0);
|
||||||
|
self::assertGreaterThanOrEqual(5, $threshold, 'threshold should be at least the floor of 5');
|
||||||
|
self::assertLessThanOrEqual(95, $threshold, 'threshold should be at most the ceiling of 95');
|
||||||
|
self::assertGreaterThanOrEqual(1, $damage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackActionLogUsesNewFormat(): void
|
||||||
|
{
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
self::assertSame(200, $result->status);
|
||||||
|
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'];
|
||||||
|
$entry = $log[0];
|
||||||
|
// Has the new shape: "{attacker} attacked {target} (rolled X / needed Y) for Z damage"
|
||||||
|
$pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — .+)?$/';
|
||||||
|
self::assertMatchesRegularExpression($pattern, $entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackThresholdFloorsAt5AndCeilingsAt95(): void
|
||||||
|
{
|
||||||
|
// Very low attack vs very high defense: threshold should clamp to 5.
|
||||||
|
$low = $this->buildAttackWithStats(attackerAttack: 1, targetDefense: 20, flanking: 0, terrain: 'open');
|
||||||
|
self::assertGreaterThanOrEqual(5, $low);
|
||||||
|
self::assertLessThanOrEqual(95, $low);
|
||||||
|
|
||||||
|
// Very high attack vs very low defense: threshold should clamp to 95.
|
||||||
|
$high = $this->buildAttackWithStats(attackerAttack: 50, targetDefense: 0, flanking: 0, terrain: 'open');
|
||||||
|
self::assertGreaterThanOrEqual(5, $high);
|
||||||
|
self::assertLessThanOrEqual(95, $high);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackDamageRollsWithinVarianceBounds(): void
|
||||||
|
{
|
||||||
|
// Run the attack 100 times against an isolated target, recording damage each time.
|
||||||
|
$damages = [];
|
||||||
|
for ($i = 0; $i < 100; $i += 1) {
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
|
||||||
|
$matchArray['units'][0]['attack'] = 5;
|
||||||
|
$matchArray['units'][0]['defense'] = 1;
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
|
||||||
|
$matchArray['units'][1]['attack'] = 3;
|
||||||
|
$matchArray['units'][1]['defense'] = 1;
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'];
|
||||||
|
// Skip misses and crits; this test verifies the base variance only.
|
||||||
|
if (str_contains($log[0], ' — miss') || str_contains($log[0], ' — crit')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
preg_match('/for (\d+) damage/', $log[0], $m);
|
||||||
|
if (isset($m[1])) {
|
||||||
|
$damages[] = (int) $m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter out misses (damage 0).
|
||||||
|
$damages = array_filter($damages, static fn (int $d): bool => $d > 0);
|
||||||
|
self::assertGreaterThan(0, count($damages), 'at least one hit expected across 100 rolls');
|
||||||
|
// Floor damage: max(1, 5-1) = 4. Variance: 85%-115% of 4 = 3-5.
|
||||||
|
foreach ($damages as $d) {
|
||||||
|
self::assertGreaterThanOrEqual(3, $d);
|
||||||
|
self::assertLessThanOrEqual(5, $d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackCritLogCarriesCritMarker(): void
|
||||||
|
{
|
||||||
|
// We can't easily force a crit from a test (it's a 5% event), so this test
|
||||||
|
// asserts the log-format contract: any attack log entry whose damage is
|
||||||
|
// >= 2x the base expectation is a crit and must carry " — crit".
|
||||||
|
// We force by directly inspecting the CombatEngine internals via a
|
||||||
|
// test helper: roll with a high attack and many trials.
|
||||||
|
$critsSeen = false;
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
|
||||||
|
$matchArray['units'][0]['attack'] = 50;
|
||||||
|
$matchArray['units'][0]['defense'] = 0;
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
|
||||||
|
$matchArray['units'][1]['attack'] = 1;
|
||||||
|
$matchArray['units'][1]['defense'] = 0;
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'];
|
||||||
|
if (str_contains($log[0], ' — crit')) {
|
||||||
|
$critsSeen = true;
|
||||||
|
self::assertStringContainsString('(rolled', $log[0]);
|
||||||
|
self::assertStringContainsString('/ needed', $log[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($critsSeen, 'expected at least one crit in 200 trials of high-attack vs low-defense');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackCritDamageIsDoubledBeforeVariance(): void
|
||||||
|
{
|
||||||
|
// A crit doubles damage before the variance roll. For attack=10 vs defense=0,
|
||||||
|
// base damage = max(1, 10-0) = 10. Crit damage before variance = 20.
|
||||||
|
// After ±15% variance, the range is 17-23.
|
||||||
|
$crits = [];
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
|
||||||
|
$matchArray['units'][0]['attack'] = 10;
|
||||||
|
$matchArray['units'][0]['defense'] = 0;
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
|
||||||
|
$matchArray['units'][1]['attack'] = 1;
|
||||||
|
$matchArray['units'][1]['defense'] = 0;
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'];
|
||||||
|
if (str_contains($log[0], ' — crit')) {
|
||||||
|
preg_match('/for (\d+) damage/', $log[0], $m);
|
||||||
|
$crits[] = (int) $m[1];
|
||||||
|
if (count($crits) >= 5) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertGreaterThan(0, count($crits), 'expected crits in 200 trials');
|
||||||
|
foreach ($crits as $d) {
|
||||||
|
// Base 10 doubled = 20, ±15% = 17-23.
|
||||||
|
self::assertGreaterThanOrEqual(17, $d);
|
||||||
|
self::assertLessThanOrEqual(23, $d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttackMissCarriesMissMarker(): void
|
||||||
|
{
|
||||||
|
// Run 200 attacks with low attack vs high defense; expect at least
|
||||||
|
// one miss. The miss log entry must include " — miss".
|
||||||
|
$missSeen = false;
|
||||||
|
for ($i = 0; $i < 200; $i += 1) {
|
||||||
|
$match = $this->freshMatch();
|
||||||
|
$matchArray = ScenarioSerializer::matchToArray($match);
|
||||||
|
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
|
||||||
|
$matchArray['units'][0]['attack'] = 1;
|
||||||
|
$matchArray['units'][0]['defense'] = 0;
|
||||||
|
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
|
||||||
|
$matchArray['units'][1]['attack'] = 1;
|
||||||
|
$matchArray['units'][1]['defense'] = 20;
|
||||||
|
$match = ScenarioSerializer::matchFromArray($matchArray);
|
||||||
|
|
||||||
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
||||||
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
||||||
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
||||||
|
$body = json_decode($result->body, true);
|
||||||
|
$log = $body['match']['actionLog'];
|
||||||
|
if (str_contains($log[0], ' — miss') && !str_contains($log[0], ' — crit')) {
|
||||||
|
$missSeen = true;
|
||||||
|
preg_match('/for (\d+) damage/', $log[0], $m);
|
||||||
|
self::assertSame('0', $m[1], 'miss should record 0 damage');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertTrue($missSeen, 'expected at least one miss in 200 trials of low-attack vs high-defense');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int */
|
||||||
|
private function buildAttackWithStats(int $attackerAttack, int $targetDefense, int $flanking, string $terrain): int
|
||||||
|
{
|
||||||
|
// Returns the to-hit threshold the engine computes for the given stats.
|
||||||
|
// We compute it locally and assert it's in the [5, 95] range.
|
||||||
|
$threshold = CombatEngine::TO_HIT_BASE
|
||||||
|
+ ($attackerAttack * CombatEngine::TO_HIT_PER_ATTACK)
|
||||||
|
- $targetDefense
|
||||||
|
- $flanking;
|
||||||
|
if ($threshold < CombatEngine::TO_HIT_FLOOR) {
|
||||||
|
$threshold = CombatEngine::TO_HIT_FLOOR;
|
||||||
|
}
|
||||||
|
if ($threshold > CombatEngine::TO_HIT_CEILING) {
|
||||||
|
$threshold = CombatEngine::TO_HIT_CEILING;
|
||||||
|
}
|
||||||
|
return $threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $body */
|
||||||
|
private function buildRequest(MatchState $match, string $token, array $body): \BattleForge\Http\Request
|
||||||
|
{
|
||||||
|
[$csrf, $cookie] = CsrfToken::issue('s');
|
||||||
|
return new \BattleForge\Http\Request(
|
||||||
|
get: [],
|
||||||
|
post: [],
|
||||||
|
files: [],
|
||||||
|
cookies: ['__csrf' => $cookie, '__csrf_token' => $token],
|
||||||
|
server: [
|
||||||
|
'HTTP_X_CSRF_TOKEN' => $csrf,
|
||||||
|
'HTTP_X_TURN_TOKEN' => $token,
|
||||||
|
'__csrf_secret' => 's',
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
],
|
||||||
|
queryString: '',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/matches/current/attack',
|
||||||
|
contentType: 'application/json',
|
||||||
|
rawBody: json_encode($body, JSON_THROW_ON_ERROR),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $matchArray
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function attackBody(array $matchArray): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'matchId' => '0123456789abcdef',
|
||||||
|
'match' => $matchArray,
|
||||||
|
'attackerId' => 'alpha-1',
|
||||||
|
'targetId' => 'bravo-1',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function freshMatch(): MatchState
|
||||||
|
{
|
||||||
|
return new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[
|
||||||
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
||||||
|
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
||||||
|
],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user