From f72072f3ef44d96f654a8e61a71d29ad1769f740 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 18:25:58 -0500 Subject: [PATCH] docs: add Plan 5 design spec (deeper combat) --- .../specs/2026-07-26-deeper-combat-design.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-deeper-combat-design.md diff --git a/docs/superpowers/specs/2026-07-26-deeper-combat-design.md b/docs/superpowers/specs/2026-07-26-deeper-combat-design.md new file mode 100644 index 0000000..1f9b715 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-deeper-combat-design.md @@ -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.