feat(combat): forest concealment roll, only on Forest terrain

This commit is contained in:
Keith Solomon
2026-07-26 23:12:16 -05:00
parent 241195bbc5
commit fb06da3f28
2 changed files with 91 additions and 12 deletions
+5 -7
View File
@@ -15,6 +15,7 @@ final class CombatEngine
public const CRIT_THRESHOLD = 95; public const CRIT_THRESHOLD = 95;
public const DAMAGE_VARIANCE_MIN_PCT = 85; public const DAMAGE_VARIANCE_MIN_PCT = 85;
public const DAMAGE_VARIANCE_MAX_PCT = 115; public const DAMAGE_VARIANCE_MAX_PCT = 115;
public const FOREST_CONCEALMENT_MISS_CHANCE = 15;
public function move(MatchState $match, string $unitId, Position $destination): MatchState public function move(MatchState $match, string $unitId, Position $destination): MatchState
{ {
@@ -85,20 +86,17 @@ final class CombatEngine
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]); return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
} }
// Forest concealment: independent 15% miss roll. // Forest concealment: independent 15% miss roll after to-hit passes.
if ($match->battlefield->terrainAt($target->position)->movementCost() === 2) { $targetTerrain = $match->battlefield->terrainAt($target->position);
// Forest and rough both have cost 2; only forest has concealment. Distinguish by name. if ($targetTerrain === \BattleForge\Domain\Terrain::Forest) {
$terrainName = $match->battlefield->terrainAt($target->position)->name;
if ($terrainName === 'Forest') {
$concealmentRoll = random_int(1, 100); $concealmentRoll = random_int(1, 100);
if ($concealmentRoll <= 15) { if ($concealmentRoll <= self::FOREST_CONCEALMENT_MISS_CHANCE) {
$logParts[] = 'for 0 damage'; $logParts[] = 'for 0 damage';
$logParts[] = '— miss — concealed'; $logParts[] = '— miss — concealed';
$next = $match->withUnit($updatedAttacker); $next = $match->withUnit($updatedAttacker);
return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]); return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
} }
} }
}
$isCrit = $roll >= self::CRIT_THRESHOLD; $isCrit = $roll >= self::CRIT_THRESHOLD;
$base = max(1, ($attacker->attack + $attacker->attackBonus) - ($target->defense + $terrainDefense)); $base = max(1, ($attacker->attack + $attacker->attackBonus) - ($target->defense + $terrainDefense));
+81
View File
@@ -1656,4 +1656,85 @@ final class CombatEngineTest extends TestCase
self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19'); self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19');
self::assertNotContains(4, $unique, 'flanking should not exceed +30'); self::assertNotContains(4, $unique, 'flanking should not exceed +30');
} }
public function testAttackForestDefenderCarriesConcealedMissOnLowRoll(): void
{
// Force a concealment miss: target is on forest. Run 200 attacks; the
// engine rolls the concealment die; we observe at least one log entry
// containing " — miss — concealed" and confirm the damage is 0.
$concealSeen = 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'] = 3;
$matchArray['units'][0]['defense'] = 0;
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
$matchArray['units'][1]['attack'] = 1;
$matchArray['units'][1]['defense'] = 1;
// (no-op, kept for clarity)
$match = ScenarioSerializer::matchFromArray($matchArray);
// Stamp forest terrain on the target's tile.
$matchArray['battlefield']['terrain'] = ['1:0' => 'forest'];
$match = ScenarioSerializer::matchFromArray($matchArray);
// Brief uses 'm' for matchId; TurnToken requires 16+ lowercase hex chars.
// Use a valid matchId (the smallest deviation).
$matchId = '0123456789abcdef';
$token = TurnToken::issue('s', $matchId, 'alpha', 1, 0);
$req = $this->buildRequest($match, $token, [
'matchId' => $matchId,
'match' => $matchArray,
'attackerId' => 'alpha-1',
'targetId' => 'bravo-1',
]);
$result = (new PostMatchAttack('s'))->handle($req, []);
$body = json_decode($result->body, true);
$log = $body['match']['actionLog'];
if (str_contains($log[0], ' — miss — concealed')) {
$concealSeen = true;
preg_match('/for (\d+) damage/', $log[0], $m);
self::assertSame('0', $m[1], 'concealed miss should record 0 damage');
break;
}
}
self::assertTrue($concealSeen, 'expected at least one concealed miss in 200 trials against a forest defender');
}
public function testAttackNonForestTerrainDoesNotRollConcealment(): void
{
// Same as above but target is on rough terrain (cost 2, no concealment).
// No log entry should contain " — concealed" in 200 trials.
$concealSeen = 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'] = 3;
$matchArray['units'][0]['defense'] = 0;
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
$matchArray['units'][1]['attack'] = 1;
$matchArray['units'][1]['defense'] = 1;
$match = ScenarioSerializer::matchFromArray($matchArray);
$matchArray['battlefield']['terrain'] = ['1:0' => 'rough'];
$match = ScenarioSerializer::matchFromArray($matchArray);
$matchId = '0123456789abcdef';
$token = TurnToken::issue('s', $matchId, 'alpha', 1, 0);
$req = $this->buildRequest($match, $token, [
'matchId' => $matchId,
'match' => $matchArray,
'attackerId' => 'alpha-1',
'targetId' => 'bravo-1',
]);
$result = (new PostMatchAttack('s'))->handle($req, []);
$body = json_decode($result->body, true);
$log = $body['match']['actionLog'];
if (str_contains($log[0], ' — concealed')) {
$concealSeen = true;
break;
}
}
self::assertFalse($concealSeen, 'rough terrain should never produce a concealed-miss log entry');
}
} }