feat(combat): forest concealment roll, only on Forest terrain
This commit is contained in:
@@ -15,6 +15,7 @@ final class CombatEngine
|
||||
public const CRIT_THRESHOLD = 95;
|
||||
public const DAMAGE_VARIANCE_MIN_PCT = 85;
|
||||
public const DAMAGE_VARIANCE_MAX_PCT = 115;
|
||||
public const FOREST_CONCEALMENT_MISS_CHANCE = 15;
|
||||
|
||||
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)]);
|
||||
}
|
||||
|
||||
// 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') {
|
||||
// Forest concealment: independent 15% miss roll after to-hit passes.
|
||||
$targetTerrain = $match->battlefield->terrainAt($target->position);
|
||||
if ($targetTerrain === \BattleForge\Domain\Terrain::Forest) {
|
||||
$concealmentRoll = random_int(1, 100);
|
||||
if ($concealmentRoll <= 15) {
|
||||
if ($concealmentRoll <= self::FOREST_CONCEALMENT_MISS_CHANCE) {
|
||||
$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));
|
||||
|
||||
@@ -1656,4 +1656,85 @@ final class CombatEngineTest extends TestCase
|
||||
self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user