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 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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user