This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user