docs: add Plan 5 design spec (deeper combat)
CI / php (push) Failing after 1m14s

This commit is contained in:
Keith Solomon
2026-07-26 18:25:58 -05:00
parent 44199ec0a8
commit f72072f3ef
@@ -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.