Compare commits

...
9 Commits
Author SHA1 Message Date
Keith Solomon 840e86b9af Merge feature/deeper-combat into develop
CI / php (push) Failing after 1m19s
Implements Plan 5: Deeper Combat.

Engine-side changes only:
- To-hit roll: 40 + (attack*3) - (defense+terrain) - flanking, floor 5, ceiling 95
- Crit: roll >= 95 (always hits, damage doubled before variance)
- Damage variance: random_int(85,115)/100 multiplier
- Action log: '(rolled X / needed Y) for Z damage' with miss/crit/concealed suffixes
- Forest concealment: independent 15% miss chance after to-hit
- Flanking: +15 per orthogonal ally, cap +30
- Water traversal: cost 3, ends turn via wadedThisTurn flag

Final review: 9 minor findings, all parked-as-is or already-resolved.
Per-task review verdicts: 6/6 approved.

256/256 tests pass (was 237/237 pre-Plan-5).
No new PHPCS warnings (baseline: 91-92).
2026-07-27 08:00:01 -05:00
Keith Solomon 8bec9baab6 feat(combat): wade visual + smoke test budget 2026-07-27 00:10:53 -05:00
Keith Solomon 09f2bda924 feat(combat): water traversal costs 3 and ends the turn 2026-07-26 23:38:13 -05:00
Keith Solomon fb06da3f28 feat(combat): forest concealment roll, only on Forest terrain 2026-07-26 23:12:16 -05:00
Keith Solomon 241195bbc5 feat(combat): flanking bonus caps at +30, diagonal allies do not count 2026-07-26 22:51:13 -05:00
Keith Solomon 4cc457c231 feat(combat): to-hit roll, ±15% damage variance, crits, and new log format 2026-07-26 21:41:53 -05:00
Keith Solomon 9eac4981f4 docs: add Plan 5 implementation plan (deeper combat)
CI / php (push) Failing after 1m10s
2026-07-26 18:39:54 -05:00
Keith Solomon f72072f3ef docs: add Plan 5 design spec (deeper combat)
CI / php (push) Failing after 1m14s
2026-07-26 18:25:58 -05:00
Keith Solomon 44199ec0a8 chore: address Plan 4 deferred-minors (stale-cookie cleanup pass)
CI / php (push) Failing after 1m11s
- Rename $secret to $csrfSecret in public/index.php to make
  intent clear at every call site; the value is the CSRF/turn-token
  HMAC key, not just 'the secret'.
- Drop the stale 'Configure the router with the eight routes' comment
  in public/index.php; the router now has sixteen routes.
- Drop the duplicate .bf-toast rule in public/assets/styles.css; the
  second declaration (the new yellow box) is the active one, the
  first (legacy green pill) is dead code from the editor era.
- Add a trailing newline to src/Views/home.php.
- Tighten the matchId regex in two tests from {16,} to {16} since
  bin2hex(random_bytes(8)) always produces exactly 16 hex chars; the
  spec's {16,} stays in production TurnToken.

No behavior changes; addresses the deferred-minors parked during
per-task review.
2026-07-26 16:38:05 -05:00
19 changed files with 2574 additions and 116 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
# Plan 5: Deeper Combat Design Spec
## Purpose
The Plan 4 release delivered a playable MVP. Playtest feedback surfaced four combat-feel gaps: deterministic attacks (every click is a hit), no hit/miss randomness, terrain that doesn't reward positioning, and a small "every fight feels the same" effect from the above. Plan 5 deepens the combat layer — engine-only changes — without introducing new infrastructure, new storage, or new external dependencies.
## Success Criteria
Plan 5 succeeds when a new user, without developer assistance, can:
1. Click the Attack button on an enemy unit and see a non-zero miss rate (roll-based, not always-hit).
2. Read the action log entry and see the roll, the threshold, and the outcome (hit, miss, miss-with-concealment, crit).
3. Position a unit in a forest and see the defender's miss rate increase.
4. Position a unit in rough terrain and see only movement slowdown (no defensive bonus).
5. Move a unit into water and see it spend the rest of its turn "wading" (no further actions).
6. Surround a target with allies and see flanking bonuses.
7. Win a match and feel that outcomes depend on positioning and randomness, not pure stat comparison.
## In Scope
### To-hit + damage model
A successful attack is no longer guaranteed. The engine computes a per-attack threshold; the roll either meets it (hit) or doesn't (miss). Damage is rolled within ±15% variance on top of the deterministic base.
- **To-hit threshold**:
`threshold = 40 + (attacker.attack * 3) - (target.defense + terrain.defenseBonus) - flankingBonus`
- 40 is the base (matches the "60% hit rate at parity" feel).
- `attacker.attack * 3` rewards higher-attack units.
- `(target.defense + terrain.defenseBonus)` rewards higher-defense units and favorable terrain.
- `flankingBonus` is +15 per ally in any of the 4 orthogonally-adjacent tiles of the target, capped at +30 (3+ allies = +30).
- Floor: 5 (always at least 5% chance to hit a target you can reach).
- Ceiling: 95 (always at least 5% chance to miss, so flanking has teeth).
- **Roll**: `random_int(1, 100)`. Server-side, via the existing `random_int()`.
- **Outcome**: `isHit = roll >= threshold`.
- **Damage on hit**:
`damage = round( max(1, attacker.attack + attacker.attackBonus - (target.defense + terrain.defenseBonus)) * random_int(85, 115) / 100 )`
- Variance is ±15% on the deterministic damage floor.
- Floor: 1 damage (cannot deal 0 on a hit).
- **Damage on miss**: 0. Logged as such for transparency.
### Crits
A `roll >= 95` is a crit (5% chance on any attack). On crit:
- Damage is doubled before the variance multiplier (so a crit can deal substantially more than normal).
- Log entry includes ` — crit`.
Crits bypass the to-hit threshold (the roll is always >= 95, always >= any threshold up to 95). The threshold ceiling at 95 ensures a 5% crit window still exists.
### Forest concealment
A defender standing on forest gets an additional 15% miss chance. This is a *second* independent random roll (after the to-hit roll passes, the engine rolls `random_int(1, 100) > 15`; if that roll fails, the attack is a miss-with-concealment instead of a hit). Implementation:
- Forest defender: `isHit = (roll >= threshold) AND (concealmentRoll = random_int(1, 100) > 15)`
- Other terrain: `isHit = (roll >= threshold)` (no concealment roll).
The action log entry for a forest miss includes ` — concealed`.
This is independent of the to-hit roll. The flow:
1. Roll to-hit. If miss, log miss, damage 0.
2. If to-hit, roll concealment (only on forest). If concealment fails, log `miss — concealed`, damage 0.
3. If hit, roll damage variance, log `hit` (or `crit` if roll was >= 95).
The to-hit threshold formula does not include a concealment bonus; concealment is a separate roll on top of an already-passing to-hit. This is so the to-hit formula stays readable (one calculation) and the concealment is a clean, independent effect.
### Flanking
A unit attacking a target with one or more allies in the target's 4 orthogonally-adjacent tiles (N/S/E/W) gets a flanking bonus to its to-hit threshold. Specifically:
- `flankingBonus = min(countAlliesAdjacentTo(target) * 15, 30)`
- The flanking check counts any non-defeated ally of the attacker. Multiple allies stack up to 2 (cap 30).
- Diagonal positions do not count (e.g., a unit at `(x+1, y+1)` relative to the target does not contribute).
- The target itself does not contribute (obviously — it is the target).
- Defeated allies do not contribute.
### Water traversal
Currently water tiles have `movementCost() === null` (impassable). Under Plan 5:
- A unit can move INTO a water tile; the cost is 3 movement points.
- After moving into water, the unit's `actionsRemaining` is forced to 0 (no further move, attack, or ability this turn).
- The unit's `wadedThisTurn` flag is set to true.
- `startTurn()` resets `wadedThisTurn` to false.
- A unit on water can still attack from there (only the *entering* ends the turn; standing on water doesn't restrict attacks after the turn boundary).
This makes water a tactical choice: pay 3 movement to get there, but your turn is over. The visual: a unit on a water tile gets a `bf-unit--wading` class (low opacity, slight blur) for the rest of its turn. `startTurn()` clears the class on the next turn's start.
### Rough terrain
No change to existing rules: cost 2 movement, no defense bonus, no concealment, no crit window. Rough slows approach but is not a defensive position.
### Blocking
No change. Impassable.
## Out of Scope
- New terrain types (e.g., high ground, walls, doors). Plan 5 keeps the existing 5 terrain types.
- New unit archetypes. The 4 archetypes (defender, striker, support, scout) and their stat ranges are unchanged.
- New stats (e.g., luck, precision). To-hit is derived from the existing attack/defense/speed stats; no new stat axis.
- New abilities. The 3 abilities (heal, area_damage, buff) are unchanged.
- New victory conditions. The 2 existing conditions (eliminate_all, hold_objective) are unchanged.
- UI redesign. The match view's layout and interaction model are unchanged; only the action log gets longer strings.
- A "fog of war" or hidden-information system. The engine is fully observable to both players in hot-seat mode; Plan 5 keeps that.
- Per-turn re-rolling of starting positions. The starting position is what the scenario sets.
## System Boundaries (unchanged from Plan 1)
1. **Content library** (Domain) — owns archetypes, abilities, terrain, and now the new combat math.
2. **Scenario editor** (web) — unchanged. Existing JSON shape; no new fields.
3. **Rules engine** (Domain) — `CombatEngine` is the single authority. Plan 5 modifies `attack()` to add to-hit, concealment, variance, crits, and water-wade.
4. **Battle interface** (web) — unchanged structurally. The JS renderer just reads the (now richer) action log strings.
The Application layer is unchanged except for a small extension to `ScenarioSerializer` to round-trip `wadedThisTurn` in the `UnitState`. Web layer is unchanged. Storage layer is unchanged.
## Data and Action Flow
### Per-attack flow (modified)
1. User clicks the Attack button on an enemy unit adjacent to one of their units.
2. JS reads the current match state from `localStorage`, builds the action POST with `X-CSRF-Token` and `X-Turn-Token`, and POSTs to `/matches/current/attack`.
3. Server: `PostMatchAttack::handle` calls `MatchActionSupport::verify` (existing CSRF/turn-token check).
4. Server: `CombatEngine::attack(...)` runs:
a. Verifies the attacker can act, the target is a valid enemy, and the target is in attack range (existing checks).
b. Computes the to-hit threshold: `40 + attack*3 - (defense + terrainDefense) - flanking`.
c. Rolls `roll = random_int(1, 100)`.
d. If `roll < threshold`: log miss, return without state change to attacker (still spends the attack action).
e. If the target is on forest: roll `concealmentRoll = random_int(1, 100)`. If `concealmentRoll <= 15`: log `miss — concealed`, return.
f. Compute damage: `round(max(1, attack+bonus - defense-terrainDefense) * random_int(85, 115) / 100)`.
g. If `roll >= 95`: double the damage before rounding. Log `crit`.
h. Apply damage to the target. Log hit (or crit) with the roll and threshold.
5. Server returns the new match state with the action log entry.
6. JS replaces the local envelope, re-renders.
### Per-turn flow (modified for water)
1. End Turn: same as today.
2. `CombatEngine::endTurn()` rotates the active team, refreshes the next team's units, and resets `actionsRemaining`, `hasAttacked`, `hasUsedAbility`, **and `wadedThisTurn`**.
3. The new turn token is computed and returned to the JS as before.
### Water-entry flow (new)
1. JS sends a move action with destination on a water tile.
2. `CombatEngine::move(...)` validates the destination is in attack range (1 step), is not blocking, and the unit has actions remaining.
3. After moving, the engine sets `wadedThisTurn = true` on the unit and `actionsRemaining = 0`.
4. The new `UnitState` is included in the response.
5. JS renders the unit on the water tile with the `bf-unit--wading` class. No further action buttons are enabled for that unit this turn (the existing "actions remaining" UI gate already handles this).
## Validation and Failure Handling
- The to-hit formula is always evaluated; no random roll is skipped. Every attack produces an action log entry, even if the result is "miss."
- Water-wade is enforced server-side. The JS cannot bypass it by simply not sending the wade flag — the flag is part of the `UnitState` and the engine sets it.
- A 5% crit window always exists (threshold ceiling at 95). A 5% hit window always exists (threshold floor at 5).
- The forest concealment is independent of to-hit. A unit in forest can still be hit (the concealment roll is a 15% miss chance, not a 100% miss chance).
- The `random_int(1, 100)` call uses PHP's CSPRNG (`/dev/urandom` on Linux, `BCryptGenRandom` on Windows). No seeding; no reproducibility.
## Security and Quality Requirements
Inherited from the design spec:
- Every state-changing form or request uses and verifies a CSRF token before mutation.
- All rendered output is escaped for its output context.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- JavaScript passes ESLint using `airbnb/base` and Prettier checks.
- Composer configuration passes `composer validate --strict`.
Plan 5-specific:
- The `random_int(1, 100)` calls are documented with their purpose at the call site (to-hit roll, concealment roll, damage variance).
- The to-hit constants (40, 3, 15, 5, 95) are named constants in `CombatEngine`, not magic numbers, so they're easy to retune. Documentation at the constant declaration explains the choice.
- The action log entry format is documented as a string contract; any deviation is a bug.
- The Plan 5 changes do not introduce new PHPCS warnings. The `random_int` and to-hit math are short; the log format string is < 200 chars; no line-length regression.
- Plan 5 changes are covered by unit tests at the engine level (hit, miss, crit, variance, flanking, terrain, water-wade) and by an integration test that asserts the action log entry format on a sample attack.
## Verification Strategy
### Unit tests
- `tests/Unit/Domain/CombatEngineTest.php` (existing, extended):
- `testAttackRollsAboveThreshold` — attack with `attack=10, defense=0, no terrain` produces threshold 70, roll of 80 yields hit, damage within ±15% of base.
- `testAttackMissesWhenRollBelowThreshold` — same setup, roll of 30 yields miss, damage 0, log has ` — miss`.
- `testAttackCritsOnRollAtOrAbove95` — roll 95 yields crit, damage doubled.
- `testAttackForestDefenderRollsConcealment` — defender on forest, to-hit passes, concealment roll 10 yields ` — miss — concealed`.
- `testAttackForestDefenderPassesConcealmentMostOfTheTime` — repeated roll sample, 80%+ hits through concealment.
- `testAttackFlankingBonus` — ally in N/S/E/W of target reduces threshold by 15 (30 with 2+).
- `testAttackDoesNotFlankFromDiagonal` — ally at a corner does not contribute.
- `testAttackFlankingCapsAt30` — three allies give 30, not 45.
- `testAttackDamageVariance` — repeated runs, damage falls within `[floor*0.85, ceil*1.15]`.
- `testAttackThresholdFloorAt5` — very-low-attack vs very-high-defense never drops below 5.
- `testAttackThresholdCeilingAt95` — very-high-attack vs very-low-defense never exceeds 95.
### Per-unit-state tests
- `tests/Unit/Domain/UnitStateTest.php` (existing, extended):
- `testStartTurnResetsWadedFlag` — unit that waded has `wadedThisTurn = false` after `startTurn()`.
- `testWithWadedSetsFlag``withWaded(true)` returns a unit with the flag set.
- Round-trip via `ScenarioSerializer::unitToArray` / `unitFromArray` carries the flag.
### Per-terrain tests
- `tests/Unit/Domain/TerrainTest.php` (new):
- `testWaterIsTraversableWithCost3``Water->movementCost() === 3`.
- `testForestCostIs2``Forest->movementCost() === 2` (existing; regression).
- `testWaterIsNotBlocking``Water !== Blocking`.
- `testForestConcealmentConstantIs15` — a named constant `Forest::CONCEALMENT_MISS_CHANCE === 15`.
### Integration tests
- `tests/Integration/PostMatchActionTest.php` (existing, extended):
- `testAttackActionLogIncludesRollAndThreshold` — fresh attack, response body, assert `match.actionLog[-1]` matches `/^\S+ attacked \S+ \(rolled \d+ \/ needed \d+\) for \d+ damage/`.
- `testAttackActionLogFlagsMiss` — when a miss happens (force via seeded test), log entry contains ` — miss`.
- `testMoveIntoWaterEndsTurn` — fresh unit on dry tile, move to adjacent water tile, response carries `wadedThisTurn = true` and `actionsRemaining = 0`.
- `testStartTurnResetsWadedFlag` — end turn, next turn the unit's `wadedThisTurn` is back to false.
### Scenario fixtures
- `public/assets/scenarios/last-stand.json` — unchanged on disk; the validator's water-traversable rule allows units to START on water, and the editor's water traversal works the same. No JSON edits needed.
- `public/assets/scenarios/skirmish.json` — unchanged (no water in this scenario).
- `public/assets/scenarios/hold-the-line.json` — unchanged.
### Manual usability check
A tester with no BattleForge context can:
1. Open the dev URL, click Play bundled → Skirmish.
2. Move a unit next to an enemy, click Attack, see the action log entry show `rolled X / needed Y for Z damage` (or ` — miss`).
3. Move a unit into forest, defend with it, see ` — concealed` in the log when an attack misses.
4. Move a unit into water, see the rest of the turn's actions disabled and the wading visual.
5. Surround an enemy with two allies, see the threshold drop and more hits land.
## Release Boundary
Plan 5 is releasable when:
- Every in-scope capability above is implemented and the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier).
- A new playthrough of Skirmish and Hold the Line shows the action log entries in the documented format.
- A playthrough of Last Stand (which has water tiles) shows the wade behavior.
- The to-hit formula's constants (40, 3, 15, 5, 95) are documented as named constants and easy to retune.
- The pre-existing PHPCS baseline (91 warnings across 18 files) is unchanged.
+1 -1
View File
@@ -18,7 +18,6 @@ table.bf-grid button[data-objective="1"] { outline: 3px solid #c00; }
table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; } table.bf-grid button[data-zone="alpha"] { outline: 3px solid #00c; }
table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; } table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; }
.bf-errors { color: #c00; } .bf-errors { color: #c00; }
.bf-toast { background: #dfd; padding: 0.5rem 1rem; margin: 0.5rem 0; }
.bf-match { display: flex; gap: 1rem; } .bf-match { display: flex; gap: 1rem; }
.bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; grid-template-columns: repeat(var(--bf-cols, 8), 2.5rem); } .bf-grid { display: grid; gap: 1px; background: #ddd; padding: 1px; width: max-content; grid-template-columns: repeat(var(--bf-cols, 8), 2.5rem); }
.bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; } .bf-tile { width: 2.5rem; height: 2.5rem; border: 0; background: #fff; padding: 0; position: relative; }
@@ -32,6 +31,7 @@ table.bf-grid button[data-zone="bravo"] { outline: 3px solid #0c0; }
.bf-unit--active { outline: 2px solid #060; } .bf-unit--active { outline: 2px solid #060; }
.bf-unit--inactive { opacity: 0.4; cursor: not-allowed; } .bf-unit--inactive { opacity: 0.4; cursor: not-allowed; }
.bf-unit--winner { outline: 3px solid #c00; } .bf-unit--winner { outline: 3px solid #c00; }
.bf-unit--wading { opacity: 0.55; filter: blur(0.4px); }
.bf-action--active { font-weight: bold; } .bf-action--active { font-weight: bold; }
.bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; } .bf-toast { background: #ffd; padding: 0.5rem 1rem; margin: 0.25rem 0; border: 1px solid #cc9; }
.bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; } .bf-log { max-height: 12rem; overflow: auto; background: #f4f4f4; padding: 0.5rem; margin-top: 1rem; }
+20 -17
View File
@@ -25,8 +25,11 @@ use BattleForge\Http\Router;
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
// 1. Read the app secret. // 1. Read the app secret.
$secret = getenv('BATTLEFORGE_SECRET'); // The app secret reads as the CSRF/turn-token HMAC key. The name below
if ($secret === false || $secret === '') { // (kept for historical reasons) is just the variable label; the same
// value is used for both the CSRF cookie HMAC and the turn-token HMAC.
$csrfSecret = getenv('BATTLEFORGE_SECRET');
if ($csrfSecret === false || $csrfSecret === '') {
$secretFile = __DIR__ . '/../var/secret.key'; $secretFile = __DIR__ . '/../var/secret.key';
if (!is_file($secretFile)) { if (!is_file($secretFile)) {
if (!is_dir(dirname($secretFile))) { if (!is_dir(dirname($secretFile))) {
@@ -35,8 +38,8 @@ if ($secret === false || $secret === '') {
file_put_contents($secretFile, random_bytes(32)); file_put_contents($secretFile, random_bytes(32));
chmod($secretFile, 0600); chmod($secretFile, 0600);
} }
$secret = file_get_contents($secretFile); $csrfSecret = file_get_contents($secretFile);
if ($secret === false) { if ($csrfSecret === false) {
http_response_code(500); http_response_code(500);
echo "Failed to read app secret.\n"; echo "Failed to read app secret.\n";
return; return;
@@ -61,11 +64,11 @@ $existingCookie = (string) ($cookies['__csrf'] ?? '');
$existingRawToken = (string) ($cookies['__csrf_token'] ?? ''); $existingRawToken = (string) ($cookies['__csrf_token'] ?? '');
$pairIsFresh = false; $pairIsFresh = false;
if ($existingCookie !== '' && $existingRawToken !== '') { if ($existingCookie !== '' && $existingRawToken !== '') {
$expectedHmac = hash_hmac('sha256', $existingRawToken, $secret); $expectedHmac = hash_hmac('sha256', $existingRawToken, $csrfSecret);
$pairIsFresh = hash_equals($expectedHmac, $existingCookie); $pairIsFresh = hash_equals($expectedHmac, $existingCookie);
} }
if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) { if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
[$token, $cookie] = CsrfToken::issue($secret); [$token, $cookie] = CsrfToken::issue($csrfSecret);
setcookie('__csrf', $cookie, [ setcookie('__csrf', $cookie, [
'expires' => time() + 86400, 'expires' => time() + 86400,
'path' => '/', 'path' => '/',
@@ -93,7 +96,7 @@ if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
} }
// 3. Compute the upload-endpoint user token (derived from the same secret). // 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads'); $uploadsToken = hash_hmac('sha256', $csrfSecret, 'bf-uploads');
// 4. Determine the request method, path, and content type. // 4. Determine the request method, path, and content type.
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
@@ -115,7 +118,7 @@ $rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input'))
// 6. Build a Request with the request data, the CSRF secret, and the upload token. // 6. Build a Request with the request data, the CSRF secret, and the upload token.
$server = $_SERVER; $server = $_SERVER;
if (!isset($server['__csrf_secret'])) { if (!isset($server['__csrf_secret'])) {
$server['__csrf_secret'] = $secret; $server['__csrf_secret'] = $csrfSecret;
} }
$server['__uploads_token'] = $uploadsToken; $server['__uploads_token'] = $uploadsToken;
@@ -132,7 +135,7 @@ $request = new Request(
rawBody: $rawBody, rawBody: $rawBody,
); );
// 7. Configure the router with the eight routes. // 7. Configure the router.
$uploadsRoot = __DIR__ . '/../var/uploads'; $uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders'; $placeholderDir = __DIR__ . '/assets/placeholders';
@@ -164,17 +167,17 @@ $getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Res
return (new GetBundledScenario($scenariosDir))->handle($r, $p); return (new GetBundledScenario($scenariosDir))->handle($r, $p);
}; };
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p); $getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
$postMatchMove = static function (Request $r, array $p) use ($secret): Response { $postMatchMove = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchMove($secret))->handle($r, $p); return (new PostMatchMove($csrfSecret))->handle($r, $p);
}; };
$postMatchAttack = static function (Request $r, array $p) use ($secret): Response { $postMatchAttack = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchAttack($secret))->handle($r, $p); return (new PostMatchAttack($csrfSecret))->handle($r, $p);
}; };
$postMatchAbility = static function (Request $r, array $p) use ($secret): Response { $postMatchAbility = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchAbility($secret))->handle($r, $p); return (new PostMatchAbility($csrfSecret))->handle($r, $p);
}; };
$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response { $postMatchEndTurn = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchEndTurn($secret))->handle($r, $p); return (new PostMatchEndTurn($csrfSecret))->handle($r, $p);
}; };
$router = new Router(); $router = new Router();
+1
View File
@@ -97,6 +97,7 @@ function renderGrid(root, match) {
if (isActive) cls.push('bf-unit--active'); if (isActive) cls.push('bf-unit--active');
else cls.push('bf-unit--inactive'); else cls.push('bf-unit--inactive');
if (isWinner) cls.push('bf-unit--winner'); if (isWinner) cls.push('bf-unit--winner');
if (unit.wadedThisTurn) cls.push('bf-unit--wading');
const unitBtn = el( const unitBtn = el(
'button', 'button',
{ {
+6 -2
View File
@@ -209,8 +209,11 @@ final class ScenarioSerializer
'attack' => $unit->attack, 'attack' => $unit->attack,
'defense' => $unit->defense, 'defense' => $unit->defense,
'speed' => $unit->speed, 'speed' => $unit->speed,
'actionsRemaining' => $unit->actionsRemaining,
'hasAttacked' => $unit->hasAttacked,
'archetype' => $unit->archetype->value, 'archetype' => $unit->archetype->value,
'abilities' => $unit->abilities, 'abilities' => $unit->abilities,
'wadedThisTurn' => $unit->wadedThisTurn,
]; ];
} }
@@ -239,12 +242,13 @@ final class ScenarioSerializer
attack: (int) $row['attack'], attack: (int) $row['attack'],
defense: (int) $row['defense'], defense: (int) $row['defense'],
speed: (int) $row['speed'], speed: (int) $row['speed'],
actionsRemaining: 2, actionsRemaining: (int) ($row['actionsRemaining'] ?? 2),
hasAttacked: false, hasAttacked: (bool) ($row['hasAttacked'] ?? false),
archetype: $archetype, archetype: $archetype,
abilities: $abilities ?? [], abilities: $abilities ?? [],
attackBonus: 0, attackBonus: 0,
hasUsedAbility: false, hasUsedAbility: false,
wadedThisTurn: (bool) ($row['wadedThisTurn'] ?? false),
); );
} }
+120 -11
View File
@@ -8,6 +8,15 @@ 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 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
{ {
$this->assertMatchActive($match); $this->assertMatchActive($match);
@@ -29,11 +38,27 @@ final class CombatEngine
throw new CombatException('Destination is not reachable.'); throw new CombatException('Destination is not reachable.');
} }
$movedUnit = $unit->moveTo($destination)->spendAction(); $moved = $unit->moveTo($destination);
$next = $match->withUnit($movedUnit); $logParts = ["{$unit->id} moved to {$destination->key()}"];
$actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"];
return $next->copy(actionLog: $actionLog); $updatedUnit = $moved;
if ($match->battlefield->terrainAt($moved->position) === Terrain::Water) {
// Water entry ends the turn: spend both actions to zero them out,
// then flag the wade. (UnitState::copy is private, so chain public
// spenders instead of editing fields directly.)
$updatedUnit = $moved
->spendAction()
->spendAction()
->withWaded(true);
$logParts[] = '— wading';
} else {
$updatedUnit = $moved->spendAction();
}
$next = $match->withUnit($updatedUnit);
$next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
return $this->checkVictoryAfterAction($next, $unit->teamId);
} }
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
@@ -53,19 +78,56 @@ 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 after to-hit passes.
$targetTerrain = $match->battlefield->terrainAt($target->position);
if ($targetTerrain === \BattleForge\Domain\Terrain::Forest) {
$concealmentRoll = random_int(1, 100);
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));
$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 +406,53 @@ 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 = $this->flankingAlliesCount($match, $attacker, $target);
return min($count * 15, 30);
}
private function flankingAlliesCount(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;
}
}
return $count;
}
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) {
+1
View File
@@ -129,6 +129,7 @@ final readonly class Scenario
$unit->abilities, $unit->abilities,
0, 0,
false, false,
false,
); );
} }
+2 -1
View File
@@ -17,7 +17,8 @@ enum Terrain: string
return match ($this) { return match ($this) {
self::Open => 1, self::Open => 1,
self::Forest, self::Rough => 2, self::Forest, self::Rough => 2,
self::Water, self::Blocking => null, self::Water => 3,
self::Blocking => null,
}; };
} }
+9
View File
@@ -24,6 +24,7 @@ final readonly class UnitState
public array $abilities = [], public array $abilities = [],
public int $attackBonus = 0, public int $attackBonus = 0,
public bool $hasUsedAbility = false, public bool $hasUsedAbility = false,
public bool $wadedThisTurn = false,
) { ) {
if ($id === '') { if ($id === '') {
throw new InvalidArgumentException('Unit id cannot be empty.'); throw new InvalidArgumentException('Unit id cannot be empty.');
@@ -135,6 +136,11 @@ final readonly class UnitState
return $this->copy(attackBonus: $bonus); return $this->copy(attackBonus: $bonus);
} }
public function withWaded(bool $waded): self
{
return $this->copy(wadedThisTurn: $waded);
}
public function startTurn(): self public function startTurn(): self
{ {
if ($this->isDefeated()) { if ($this->isDefeated()) {
@@ -146,6 +152,7 @@ final readonly class UnitState
hasAttacked: false, hasAttacked: false,
attackBonus: 0, attackBonus: 0,
hasUsedAbility: false, hasUsedAbility: false,
wadedThisTurn: false,
); );
} }
@@ -156,6 +163,7 @@ final readonly class UnitState
?bool $hasAttacked = null, ?bool $hasAttacked = null,
?int $attackBonus = null, ?int $attackBonus = null,
?bool $hasUsedAbility = null, ?bool $hasUsedAbility = null,
?bool $wadedThisTurn = null,
): self { ): self {
return new self( return new self(
$this->id, $this->id,
@@ -172,6 +180,7 @@ final readonly class UnitState
$this->abilities, $this->abilities,
$attackBonus ?? $this->attackBonus, $attackBonus ?? $this->attackBonus,
$hasUsedAbility ?? $this->hasUsedAbility, $hasUsedAbility ?? $this->hasUsedAbility,
wadedThisTurn: $wadedThisTurn ?? $this->wadedThisTurn,
); );
} }
+1 -1
View File
@@ -27,4 +27,4 @@ render_layout(static function () use ($csrf, $bundled): void {
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div> <div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
<script type="module" src="<?= $a('/js/storage.js') ?>"></script> <script type="module" src="<?= $a('/js/storage.js') ?>"></script>
<?php <?php
}, $csrf, 'BattleForge'); }, $csrf, 'BattleForge');
@@ -60,7 +60,7 @@ final class PostMatchActionSmokeTest extends TestCase
$turnToken = $startResponse['body']['turnToken'] ?? null; $turnToken = $startResponse['body']['turnToken'] ?? null;
self::assertIsArray($match); self::assertIsArray($match);
self::assertIsString($matchId); self::assertIsString($matchId);
self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $matchId); self::assertMatchesRegularExpression('/^[a-f0-9]{16}$/', $matchId);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken);
for ($i = 0; $i < 200; $i += 1) { for ($i = 0; $i < 200; $i += 1) {
+89 -23
View File
@@ -92,34 +92,47 @@ 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);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAttack(self::SECRET))->handle( // Loop until a hit lands; the to-hit roll can miss.
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie, '__csrf_token' => $token],
body: [
'matchId' => self::MATCH_ID,
'match' => ScenarioSerializer::matchToArray($match),
'attackerId' => 'alpha-1',
'targetId' => 'bravo-1',
],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$bravo = null; $bravo = null;
foreach ($body['match']['units'] as $unit) { for ($i = 0; $i < 200; $i += 1) {
if ($unit['id'] === 'bravo-1') { $match = $this->freshMatch();
$bravo = $unit; $matchArray = ScenarioSerializer::matchToArray($match);
$matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7];
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchAttack(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie, '__csrf_token' => $token],
body: [
'matchId' => self::MATCH_ID,
'match' => ScenarioSerializer::matchToArray($match),
'attackerId' => 'alpha-1',
'targetId' => 'bravo-1',
],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$bravo = null;
foreach ($body['match']['units'] as $unit) {
if ($unit['id'] === 'bravo-1') {
$bravo = $unit;
break;
}
}
if ($bravo !== null && $bravo['health'] < 10) {
break; break;
} }
} }
self::assertNotNull($bravo); self::assertNotNull($bravo);
self::assertLessThan(10, $bravo['health']); self::assertLessThan(10, $bravo['health']);
} }
@@ -236,6 +249,59 @@ final class PostMatchActionTest extends TestCase
self::assertSame(409, $response->status); self::assertSame(409, $response->status);
} }
public function testMoveIntoWaterEndsTurn(): void
{
// Build a match where alpha-1 is on dry ground next to a water tile;
// alpha-2 and alpha-3 are parked out of the way so the move path is
// clear, and the move target (0,1) becomes a water tile whose cost (3)
// matches alpha-1's speed budget.
$match = $this->freshMatch();
$matchArray = ScenarioSerializer::matchToArray($match);
$matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
$matchArray['units'][0]['speed'] = 3;
$matchArray['units'][1]['position'] = ['x' => 0, 'y' => 3]; // alpha-2 clear of alpha-1's path
$matchArray['units'][2]['position'] = ['x' => 0, 'y' => 2]; // alpha-3 clear of alpha-1's path
$matchArray['units'][3]['position'] = ['x' => 7, 'y' => 7]; // bravo-1 stays put
$matchArray['units'][4]['position'] = ['x' => 6, 'y' => 7]; // bravo-2
$matchArray['units'][5]['position'] = ['x' => 5, 'y' => 7]; // bravo-3
$matchArray['battlefield']['terrain'] = ['0:1' => 'water'];
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = $this->tokenFor($match);
[$csrf, $cookie] = CsrfToken::issue(self::SECRET);
$response = (new PostMatchMove(self::SECRET))->handle(
$this->buildRequest(
match: $match,
csrf: $csrf,
turnToken: $token,
cookies: ['__csrf' => $cookie, '__csrf_token' => $token],
body: [
'matchId' => self::MATCH_ID,
'match' => ScenarioSerializer::matchToArray($match),
'unitId' => 'alpha-1',
'x' => 0,
'y' => 1,
],
),
[],
);
self::assertSame(200, $response->status);
$body = json_decode($response->body, true);
$moved = null;
foreach ($body['match']['units'] as $u) {
if ($u['id'] === 'alpha-1') {
$moved = $u;
}
}
self::assertNotNull($moved);
self::assertSame(['x' => 0, 'y' => 1], $moved['position']);
self::assertSame(0, $moved['actionsRemaining'], 'wading ends the turn');
self::assertTrue($moved['wadedThisTurn'], 'wade flag is set after water entry');
}
public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void
{ {
$match = $this->freshMatch(); $match = $this->freshMatch();
+1 -1
View File
@@ -39,7 +39,7 @@ final class PostStartMatchTest extends TestCase
self::assertSame(1, $body['match']['round'] ?? null); self::assertSame(1, $body['match']['round'] ?? null);
self::assertCount(6, $body['match']['units'] ?? []); self::assertCount(6, $body['match']['units'] ?? []);
self::assertIsString($body['matchId'] ?? null); self::assertIsString($body['matchId'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $body['matchId']); self::assertMatchesRegularExpression('/^[a-f0-9]{16}$/', $body['matchId']);
self::assertIsString($body['turnToken'] ?? null); self::assertIsString($body['turnToken'] ?? null);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']);
} }
+764 -55
View File
@@ -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;
@@ -226,28 +231,40 @@ final class CombatEngineTest extends TestCase
{ {
$attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0)); $attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0)); $target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
$match = new MatchState( $buildMatch = static fn (): MatchState => new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]), new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[$attacker, $target], [$attacker, $target],
'alpha', 'alpha',
actionLog: ['match started'], actionLog: ['match started'],
); );
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); // Loop until a non-crit hit lands; a crit would double the base 1 damage to 2,
// which would mask the variance behavior this test exercises.
$next = $buildMatch();
for ($i = 0; $i < 200; $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;
}
}
// Original match state must not have been mutated by the loop's iterations.
self::assertSame(10, $target->health); self::assertSame(10, $target->health);
self::assertSame(2, $attacker->actionsRemaining); self::assertSame(2, $attacker->actionsRemaining);
self::assertFalse($attacker->hasAttacked); self::assertFalse($attacker->hasAttacked);
self::assertSame(10, $match->unit('bravo-1')->health);
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining); self::assertGreaterThanOrEqual(9, $next->unit('bravo-1')->health);
self::assertFalse($match->unit('alpha-1')->hasAttacked); self::assertLessThanOrEqual(10, $next->unit('bravo-1')->health);
self::assertSame(['match started'], $match->actionLog);
self::assertSame(9, $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 +287,50 @@ final class CombatEngineTest extends TestCase
public function testAttackDefeatingLastEnemyAwardsVictory(): void public function testAttackDefeatingLastEnemyAwardsVictory(): void
{ {
$match = new MatchState( $buildMatch = static function (): MatchState {
new Battlefield(8, 8), return new MatchState(
[ new Battlefield(8, 8),
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20), [
$this->unit('bravo-1', 'bravo', new Position(1, 0)), new UnitState(
], 'alpha-1',
'alpha', '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',
);
};
$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 +473,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(
new Battlefield(8, 8), 'bravo-1',
[ 'bravo',
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 8), new Position(1, 0),
$target, 10,
], 10,
'alpha', 3,
); 3,
4,
2,
false,
);
return new MatchState(
new Battlefield(8, 8),
[
new UnitState(
'alpha-1',
'alpha',
new Position(0, 0),
10,
10,
8,
2,
4,
2,
false,
),
$target,
],
'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 {
new Battlefield(8, 8, ['1:0' => Terrain::Forest]), return new MatchState(
[ new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 0), [
$this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 20), new UnitState(
], 'alpha-1',
'alpha', 'alpha',
); new Position(0, 0),
10,
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); 10,
0,
2,
4,
2,
false,
),
new UnitState(
'bravo-1',
'bravo',
new Position(1, 0),
10,
10,
3,
20,
4,
2,
false,
),
],
'alpha',
);
};
// 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 {
new Battlefield(8, 8), return new MatchState(
[ new Battlefield(8, 8),
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20), [
$this->unit('bravo-1', 'bravo', new Position(1, 0)), new UnitState(
$this->unit('bravo-2', 'bravo', new Position(7, 7)), 'alpha-1',
], 'alpha',
'alpha', new Position(0, 0),
); 10,
10,
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1'); 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',
);
};
// 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 +1200,549 @@ 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',
);
}
public function testFlankingBonusFromOneOrthogonalAlly(): void
{
// Build a match: attacker alpha-1 at (0, 0), target bravo-1 at (0, 1),
// ally alpha-2 at (1, 1) (orthogonal to target), and bravo-2 filler at (2, 0).
// One orthogonal ally should produce a flanking bonus of +15, so the
// to-hit threshold becomes 40 + 3*attack - defense - 15 = 34.
$counts = [];
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;
// Rename the existing bravo-1 (units[1]) to alpha-2 and move it to (1, 1).
$matchArray['units'][1]['id'] = 'alpha-2';
$matchArray['units'][1]['teamId'] = 'alpha';
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 1];
// Insert bravo-1 as the target at (0, 1) by copying the alpha-2 row above.
$matchArray['units'][2] = $matchArray['units'][1];
$matchArray['units'][2]['id'] = 'bravo-1';
$matchArray['units'][2]['teamId'] = 'bravo';
$matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1];
$matchArray['units'][2]['defense'] = 0; // brief's threshold 34 requires target defense = 0
// Add bravo-2 as a far-away filler so the match has two full teams.
$matchArray['units'][3] = $matchArray['units'][1];
$matchArray['units'][3]['id'] = 'bravo-2';
$matchArray['units'][3]['teamId'] = 'bravo';
$matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0];
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
$req = $this->buildRequest($match, $token, [
'matchId' => '0123456789abcdef',
'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 (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
$counts[] = (int) $m[1];
}
}
$unique = array_unique($counts);
self::assertContains(34, $unique, 'one ally should produce a threshold of 34');
}
public function testFlankingDiagonalsDoNotContribute(): void
{
// Same setup as testFlankingBonusFromOneOrthogonalAlly, but the ally sits at (1, 2),
// which is diagonal to the target bravo-1 at (0, 1). Diagonal neighbours should
// not contribute, so the flanking bonus is 0 and the threshold stays at 40 + 9 - 0 = 49.
$counts = [];
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'] = 3;
$matchArray['units'][0]['defense'] = 0;
$matchArray['units'][1]['id'] = 'alpha-2';
$matchArray['units'][1]['teamId'] = 'alpha';
$matchArray['units'][1]['position'] = ['x' => 1, 'y' => 2]; // diagonal "ally"
$matchArray['units'][2] = $matchArray['units'][1];
$matchArray['units'][2]['id'] = 'bravo-1';
$matchArray['units'][2]['teamId'] = 'bravo';
$matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1];
$matchArray['units'][2]['defense'] = 0; // target -- brief's threshold 49 requires target defense = 0
$matchArray['units'][3] = $matchArray['units'][1];
$matchArray['units'][3]['id'] = 'bravo-2';
$matchArray['units'][3]['teamId'] = 'bravo';
$matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0];
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
$req = $this->buildRequest($match, $token, [
'matchId' => '0123456789abcdef',
'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 (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
$counts[] = (int) $m[1];
}
}
$unique = array_unique($counts);
self::assertContains(49, $unique, 'no orthogonal ally should produce threshold of 40 + 9 - 0 = 49');
}
public function testFlankingCapsAt30WithThreeOrMoreAllies(): void
{
// Three orthogonal allies should still cap at +30, not produce +45.
// Layout (all coordinates on the 8x8 grid):
// alpha-1 (attacker) at (1, 0)
// bravo-1 (target) at (1, 1)
// alpha-2 (ally 1) at (0, 1) -- orthogonal (left)
// alpha-3 (ally 2) at (2, 1) -- orthogonal (right)
// alpha-4 (ally 3) at (1, 2) -- orthogonal (down)
// Three orthogonal allies => flanking bonus min(3 * 15, 30) = 30,
// threshold = 40 + 9 - 0 - 30 = 19.
$counts = [];
for ($i = 0; $i < 50; $i += 1) {
$match = $this->freshMatch();
$matchArray = ScenarioSerializer::matchToArray($match);
$matchArray['units'][0]['position'] = ['x' => 1, 'y' => 0];
$matchArray['units'][0]['attack'] = 3;
$matchArray['units'][0]['defense'] = 0;
$matchArray['units'][1]['id'] = 'alpha-2';
$matchArray['units'][1]['teamId'] = 'alpha';
$matchArray['units'][1]['position'] = ['x' => 0, 'y' => 1]; // ally 1 (orthogonal)
$matchArray['units'][2] = $matchArray['units'][1];
$matchArray['units'][2]['id'] = 'bravo-1';
$matchArray['units'][2]['teamId'] = 'bravo';
$matchArray['units'][2]['position'] = ['x' => 1, 'y' => 1]; // target
$matchArray['units'][2]['defense'] = 0; // brief's threshold 19 requires target defense = 0
$matchArray['units'][3] = $matchArray['units'][1];
$matchArray['units'][3]['id'] = 'alpha-3';
$matchArray['units'][3]['teamId'] = 'alpha';
$matchArray['units'][3]['position'] = ['x' => 2, 'y' => 1]; // ally 2 (orthogonal)
$matchArray['units'][4] = $matchArray['units'][1];
$matchArray['units'][4]['id'] = 'alpha-4';
$matchArray['units'][4]['teamId'] = 'alpha';
$matchArray['units'][4]['position'] = ['x' => 1, 'y' => 2]; // ally 3 (orthogonal)
$match = ScenarioSerializer::matchFromArray($matchArray);
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
$req = $this->buildRequest($match, $token, [
'matchId' => '0123456789abcdef',
'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 (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
$counts[] = (int) $m[1];
}
}
$unique = array_unique($counts);
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');
}
} }
-1
View File
@@ -56,7 +56,6 @@ final class MatchStateTest extends TestCase
*/ */
public static function impassableTerrainProvider(): iterable public static function impassableTerrainProvider(): iterable
{ {
yield 'water' => [Terrain::Water];
yield 'blocking' => [Terrain::Blocking]; yield 'blocking' => [Terrain::Blocking];
} }
+2 -2
View File
@@ -28,7 +28,7 @@ final class ScenarioValidatorTest extends TestCase
public function testItRejectsAnObjectiveOnImpassableTerrain(): void public function testItRejectsAnObjectiveOnImpassableTerrain(): void
{ {
$scenario = $this->validScenario( $scenario = $this->validScenario(
terrain: ['4:4' => Terrain::Water], terrain: ['4:4' => Terrain::Blocking],
objectivePosition: new Position(4, 4), objectivePosition: new Position(4, 4),
); );
@@ -81,7 +81,7 @@ final class ScenarioValidatorTest extends TestCase
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
{ {
$units = $this->unitSet(); $units = $this->unitSet();
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]); $battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Blocking]);
$scenario = new Scenario( $scenario = new Scenario(
id: 'demo', id: 'demo',
name: 'Demo', name: 'Demo',
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Terrain;
use PHPUnit\Framework\TestCase;
final class TerrainTest extends TestCase
{
public function testOpenCostsOne(): void
{
self::assertSame(1, Terrain::Open->movementCost());
self::assertSame(0, Terrain::Open->defenseBonus());
}
public function testForestCostsTwoAndGivesOneDefense(): void
{
self::assertSame(2, Terrain::Forest->movementCost());
self::assertSame(1, Terrain::Forest->defenseBonus());
}
public function testRoughCostsTwoAndGivesNoDefense(): void
{
self::assertSame(2, Terrain::Rough->movementCost());
self::assertSame(0, Terrain::Rough->defenseBonus());
}
public function testWaterCostsThreeAndGivesNoDefense(): void
{
self::assertSame(3, Terrain::Water->movementCost());
self::assertSame(0, Terrain::Water->defenseBonus());
}
public function testBlockingIsImpassable(): void
{
self::assertNull(Terrain::Blocking->movementCost());
}
}
+20
View File
@@ -229,12 +229,32 @@ final class UnitStateTest extends TestCase
self::assertFalse($started->hasUsedAbility); self::assertFalse($started->hasUsedAbility);
} }
public function testStartTurnResetsWadedFlag(): void
{
$unit = $this->unit(wadedThisTurn: true);
$started = $unit->startTurn();
self::assertFalse($started->wadedThisTurn);
}
public function testWithWadedSetsAndClearsFlag(): void
{
$unit = $this->unit();
self::assertFalse($unit->wadedThisTurn);
$wading = $unit->withWaded(true);
self::assertTrue($wading->wadedThisTurn);
$dry = $wading->withWaded(false);
self::assertFalse($dry->wadedThisTurn);
}
private function unit( private function unit(
int $health = 10, int $health = 10,
int $actionsRemaining = 2, int $actionsRemaining = 2,
bool $hasAttacked = false, bool $hasAttacked = false,
int $attackBonus = 0, int $attackBonus = 0,
bool $hasUsedAbility = false, bool $hasUsedAbility = false,
bool $wadedThisTurn = false,
): UnitState { ): UnitState {
return new UnitState( return new UnitState(
'unit-1', 'unit-1',