Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840e86b9af | ||
|
|
8bec9baab6 | ||
|
|
09f2bda924 | ||
|
|
fb06da3f28 | ||
|
|
241195bbc5 | ||
|
|
4cc457c231 | ||
|
|
9eac4981f4 | ||
|
|
f72072f3ef | ||
|
|
44199ec0a8 |
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.
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ final readonly class Scenario
|
|||||||
$unit->abilities,
|
$unit->abilities,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user