feat(mastervega): promote V2 combat engine with performance, formation, and UI overhauls

- Promote VegaCombatV2 to the official combat engine.
- Performance: manual sqrt replaces Math.hypot, spatial grid collision avoidance, and per-side ship cap (100) with largest-remainder proportional sampling. Overflow fleets resolve via V1's cheap aggregate math.
- Combat: replace uniform grid placement with formation-aware layouts (Power Pressure, Speed Swarm) using per-ship avoidRadius. Add lead-pursuit targeting to fix gap-closing issues. Add centering force to keep battles on-screen.
- UI: add commander roster with procedural static for wiped types, move building icons below text, add direct war/peace toggle for diplomacy-incapable species.
- Test/Docs: add verifier checks for pursuit, formations, centering, spatial grid correctness, and 790-ship battle performance. Document design traps and fixes.
This commit is contained in:
Brian Fertig 2026-08-09 22:15:27 -06:00
parent bfaee613c9
commit c105ebd23c
9 changed files with 1965 additions and 141 deletions

View File

@ -503,7 +503,7 @@
"singularityShieldPierce": 0.5 "singularityShieldPierce": 0.5
}, },
"_combatV2Readme": "Constants for the VegaCombatV2 per-ship prototype only (behind ?movsim's Live/V2 toggle) — completely separate from 'combat' above, which the live engine still reads unmodified. World space is continuous 2D, not a lane, and combat runs in continuous simulated TIME, not discrete rounds: every ship has its own firing cooldown (turnSeconds, seconds between shots — armed the moment it first comes into weapon range of its target, matching real-time engagement rather than a synchronized lockstep round) and moves/turns continuously every tick rather than snapping once per round. moveUnitsPerSpeed is world units per SECOND now (was per old discrete round, at 100) — deliberately slowed down, not just time-converted, per Brian's explicit ask for a slower, more deliberate pace. turnRateScale converts each hull's turnRateBase (still degrees, still hand-tuned per hull, still living in the hulls block) into a per-second max angular velocity; spinUpSeconds is how long a ship takes to spin up to that max rate from a standing start, which is what gives turning real momentum instead of an instant snap. separationUnit/separationWeight tune the collision-avoidance steering (VegaCombatV2.js's computeSeparation) — separationUnit is the 'personal space' radius per point of a hull's sizeScale, so bigger ships keep proportionally more distance. Ships also carry real LINEAR momentum (vx/vy, independent of facing) — each hull's brakeSeconds (hulls block) sets how hard it can actually decelerate; a hull whose stopping distance at full speed exceeds beamRange physically cannot stop before reaching its target and blows through for another pass instead (see computeShipMove's comment). avoidAccel is a flat, hull-independent acceleration budget for collision avoidance ONLY, deliberately NOT drawn from a hull's own (possibly weak) linearAccel — several ships converging on the same weighted-random target approach nearly in formation, and a frigate's deliberately poor brakes must not also mean it can't swerve around a teammate on that same course. Kept deliberately gentle (not a strong repulsion) per Brian's explicit ask — ships should attempt to avoid each other, not bounce, and overlapping when their actual objectives require it (e.g. a strafing pass, or several ships converging on one target) is fine. beamRange/missileRange are unchanged by any of this (spatial, not temporal) — every other combat constant (hit-chance coefficients, cloakEvasion, singularityShieldPierce) is shared by reading rules.combat directly, since none of it is range/position/time-scaled; retreatAfterRound/disengageRound are reinterpreted as seconds (×turnSeconds) rather than duplicated here. damageMultiplier scales each weapon's raw output (before shield mitigation) in VegaCombatV2.js's fireMounts() ONLY — the live engine's weapon damage (read from the same shared VegaShips.js designs) is completely untouched, so this shortens V2 battle length without changing live-game balance at all. Brian's explicit ask, after watching V2 play out live and finding fights took too many exchanges to resolve. maxDurationSeconds is V2's OWN total time budget (VegaCombatV2.js's maxDurationSec(b) helper), deliberately decoupled from rules.combat.maxRounds (shared with the live engine's round cap) — measuring actual battle outcomes found the disengage timer was firing in essentially every simulated battle before combat concluded naturally regardless of damage level, so raising the ceiling (120s -> 240s) and disengageFraction (0.85 -> 0.9, now 216s before the weaker side is forced to flee, was 102s) gives combat meaningfully more room to resolve on its own merits. IMPORTANT interaction: raising the duration ceiling alone made battles LONGER, not shorter (removing what had effectively been an implicit cap) — damageMultiplier is the lever that actually shortens things once the ceiling isn't artificially truncating fights; went 1.6 -> 2.8 after measuring that duration keeps dropping sharply as multiplier climbs while decisiveness (fraction resolving by real destruction, not forced retreat) holds or improves, not degrades.", "_combatV2Readme": "Constants for the VegaCombatV2 per-ship prototype only (behind ?movsim's Live/V2 toggle) — completely separate from 'combat' above, which the live engine still reads unmodified. World space is continuous 2D, not a lane, and combat runs in continuous simulated TIME, not discrete rounds: every ship has its own firing cooldown (turnSeconds, seconds between shots — armed the moment it first comes into weapon range of its target, matching real-time engagement rather than a synchronized lockstep round) and moves/turns continuously every tick rather than snapping once per round. moveUnitsPerSpeed is world units per SECOND now (was per old discrete round, at 100) — deliberately slowed down, not just time-converted, per Brian's explicit ask for a slower, more deliberate pace. turnRateScale converts each hull's turnRateBase (still degrees, still hand-tuned per hull, still living in the hulls block) into a per-second max angular velocity; spinUpSeconds is how long a ship takes to spin up to that max rate from a standing start, which is what gives turning real momentum instead of an instant snap. separationUnit/separationWeight tune the collision-avoidance steering (VegaCombatV2.js's computeSeparation) — separationUnit is the 'personal space' radius per point of a hull's sizeScale, so bigger ships keep proportionally more distance. Ships also carry real LINEAR momentum (vx/vy, independent of facing) — each hull's brakeSeconds (hulls block) sets how hard it can actually decelerate; a hull whose stopping distance at full speed exceeds beamRange physically cannot stop before reaching its target and blows through for another pass instead (see computeShipMove's comment). avoidAccel is a flat, hull-independent acceleration budget for collision avoidance ONLY, deliberately NOT drawn from a hull's own (possibly weak) linearAccel — several ships converging on the same weighted-random target approach nearly in formation, and a frigate's deliberately poor brakes must not also mean it can't swerve around a teammate on that same course. Kept deliberately gentle (not a strong repulsion) per Brian's explicit ask — ships should attempt to avoid each other, not bounce, and overlapping when their actual objectives require it (e.g. a strafing pass, or several ships converging on one target) is fine. beamRange/missileRange are unchanged by any of this (spatial, not temporal) — every other combat constant (hit-chance coefficients, cloakEvasion, singularityShieldPierce) is shared by reading rules.combat directly, since none of it is range/position/time-scaled; retreatAfterRound/disengageRound are reinterpreted as seconds (×turnSeconds) rather than duplicated here. damageMultiplier scales each weapon's raw output (before shield mitigation) in VegaCombatV2.js's fireMounts() ONLY — the live engine's weapon damage (read from the same shared VegaShips.js designs) is completely untouched, so this shortens V2 battle length without changing live-game balance at all. Brian's explicit ask, after watching V2 play out live and finding fights took too many exchanges to resolve. centeringAccel is a flat, hull-independent acceleration budget (same shape as avoidAccel) that gently pulls a ship back toward roughly where the battle STARTED once it has wandered meaningfully beyond that starting footprint — VegaCombatV2.js's computeCenteringForce/createBattle for the full mechanism; Brian's ask after noticing battles look great early on and then drift off the fixed camera framing by the end. Zero force within the battle's own actual starting spread (formation-and-fleet-size-aware, not a guessed world fraction), ramping in only beyond it. maxDurationSeconds is V2's OWN total time budget (VegaCombatV2.js's maxDurationSec(b) helper), deliberately decoupled from rules.combat.maxRounds (shared with the live engine's round cap) — measuring actual battle outcomes found the disengage timer was firing in essentially every simulated battle before combat concluded naturally regardless of damage level, so raising the ceiling (120s -> 240s) and disengageFraction (0.85 -> 0.9, now 216s before the weaker side is forced to flee, was 102s) gives combat meaningfully more room to resolve on its own merits. IMPORTANT interaction: raising the duration ceiling alone made battles LONGER, not shorter (removing what had effectively been an implicit cap) — damageMultiplier is the lever that actually shortens things once the ceiling isn't artificially truncating fights; went 1.6 -> 2.8 after measuring that duration keeps dropping sharply as multiplier climbs while decisiveness (fraction resolving by real destruction, not forced retreat) holds or improves, not degrades.",
"combatV2": { "combatV2": {
"worldWidth": 3600, "worldWidth": 3600,
"worldHeight": 2400, "worldHeight": 2400,
@ -518,7 +518,8 @@
"avoidAccel": 60, "avoidAccel": 60,
"damageMultiplier": 2.8, "damageMultiplier": 2.8,
"maxDurationSeconds": 240, "maxDurationSeconds": 240,
"disengageFraction": 0.9 "disengageFraction": 0.9,
"centeringAccel": 45
}, },
"victory": { "victory": {

View File

@ -538,6 +538,279 @@ Each of these was a real bug that produced a plausible-looking but broken game.
passing smoke test can be hiding a different, load-bearing mechanism passing smoke test can be hiding a different, load-bearing mechanism
doing the actual work, which then breaks silently the moment that doing the actual work, which then breaks silently the moment that
mechanism gets retuned for an unrelated reason.** mechanism gets retuned for an unrelated reason.**
34. **A hard snap onto the "prepare to brake" heading is invisible in
isolation and a real bug the instant anything else nudges the ship.**
Trap 33's anticipatory pre-turn snapped `facing`'s steering target
straight onto retrograde the instant the pre-turn window opened, for
however long that window lasted (15-20+ seconds for the slowest
hulls). In a clean 1-ship-vs-a-stationary-target test this was
completely harmless — a ship already flying in a straight line needs
zero corrective thrust, so a cosmetically-backward-pointing nose while
coasting true didn't affect the flight path at all, and that test was
the one used to validate trap 33's fix. But the pre-turn also
collapses `computeShipMove`'s alignment-scaled thrust (trap 31) toward
zero for that entire window, since thrust efficiency is gated by how
well facing matches the approach-direction task angle — meaning for
that whole stretch the ship had almost no ABILITY to correct its
course if something nudged it off-line. A 2-battleship-per-side smoke
test (not the 1-ship isolation trap 33 was validated against) showed
exactly that: gentle collision avoidance from a nearby teammate was
enough to visibly drift a ship off its target with nothing available
to pull it back. Fixed by BLENDING the pre-turn's facing target
(approach direction -> retrograde) proportional to urgency
(`1 - timeToRange/worstTurnTime`) instead of snapping straight onto
retrograde the instant the window opens — keeps real corrective
steering authority for most of the pre-turn window, only fully
committing to retrograde right as braking is about to start. **Same
lesson as trap 33, one level deeper: the fix that gets validated in
isolation is only as good as the isolation scenario — a test with
nothing else nearby cannot expose a failure mode that only manifests
when something else is nearby.**
35. **Steering at a target's CURRENT position, not where it's actually
headed, makes a slow-turning ship unable to catch anything that isn't
trying to reach IT specifically.** Fixing trap 34 improved but didn't
fully resolve the same 2-battleship smoke test — distance to a live,
still-being-chased target would close to a few hundred units, then
reopen to 1000+, repeatedly, over a full minute, never settling.
Root cause, found by logging the CHASED ship's own target: it was
pursuing a completely different third ship, so its motion had nothing
to do with evading the ship chasing it — two independently-moving,
equal-top-speed ships, one a slow turner, produced a classic pursuit
failure (chasing a laterally-moving point you can't out-turn, like a
dog that can't catch a squirrel that keeps changing direction) that
LOOKS exactly like "the target is fleeing" from the chaser's
perspective even though neither ship is choosing to run from the
other. Brian's diagnosis question ("is that the auto-disengage
thing?") was a reasonable guess but wrong — disengage is a rare,
whole-side, once-per-battle event; this was happening continuously,
ship-pair by ship-pair, throughout ordinary combat. Fixed with
`predictIntercept()` — classic lead-pursuit algebra: given the
target's position/velocity and the shooter's assumed closing speed
(its own `effSpeed`), solve the quadratic for the smallest positive
time `t` where a straight course from the shooter meets the target's
projected position, and aim there instead of at the target's current
position. Degenerates exactly to today's behaviour for a stationary
target (validated as an explicit unit case), and falls back to it
gracefully when no real positive-time solution exists (target
receding faster than the shooter can ever close) — this can only ever
match or beat plain pursuit, never make aim worse. Confirmed via the
same smoke test: distance now closes steadily and stabilizes (~340
units and still slowly shrinking) instead of oscillating without
bound. Only affects the APPROACH steering aim point — `dist`/
`inRange`/the braking trigger/weapon range all still read the
target's TRUE current position, unaffected.
36. **A "spread ships apart by X" offset that's multiplied by a magnitude
which is deliberately ZERO for some inputs silently produces zero
offset for those inputs, not the intended spacing.** Speed Swarm's
rear-most band (the biggest hulls) is explicitly meant to sit near the
centerline — `bandSpreadMag = 0` there, by design. The per-ship
within-band offset formula was `arm * (bandSpreadMag + stepIndex *
cell)`, and for the first two ships in ANY band (`stepIndex = 0`),
that's `arm * bandSpreadMag` — which is `arm * 0 = 0` for the rear
band specifically, regardless of the `arm` sign meant to push them to
opposite sides. Two battleships landed on the exact same coordinate
(a same-side ship-overlap smoke test caught it immediately: worst
ratio 0.00, an exact double placement). Fixed by making the per-ship
term ADD to, never multiply through, the band's fan magnitude:
`arm * (bandSpreadMag + (stepIndex + 0.5) * cell)` guarantees a full
cell-width of separation between the first two ships in any band
regardless of what `bandSpreadMag` happens to be that band.
37. **A post-hoc "scale everything down to fit" clamp is unsafe the moment
anything in the thing being scaled was already at its own guaranteed
minimum.** After fixing trap 36, a wide fleet (many frigates fanned far
out under Speed Swarm) still showed two battleships landing inside each
other's `avoidRadius` — 0.77 of the safe minimum, not 0.00, but still a
real violation. Root cause: `placeFleets()` had a vertical-fit
safety net that uniformly multiplied every ship's `spread` coordinate
by a single scale factor if the fleet's total spread exceeded a
world-height budget. That factor applies identically to a ship pair
that was placed at EXACTLY its guaranteed-safe minimum distance and to
one with plenty of slack — shrinking the tight pair below their own
safe minimum right along with everything else, precisely undoing the
guarantee trap 36 exists to provide. Fixed by moving the safety margin
to where it belongs: `layoutSpeedSwarm()` bounds its own fan magnitude
(`maxSpreadMag`) against world height BEFORE any ship is placed, so
the per-ship minimum-spacing term is never something that needs
correcting afterward. **Same shape of lesson as trap 4/22's class of
bug (a fix at the wrong layer looks right until you check what it's
actually doing to values that were already exact): a correction
applied AFTER a value is computed can't tell "this was already exactly
right" from "this needs adjusting," and will happily break the first
case to fix the second.**
38. **A mirror match with formation-shaped placement can be structurally
biased even with allowRetreat disabled, ruling out the obvious
suspect.** Power Pressure's front-loaded heavy-ship clash, for a
mixed-hull fleet mirrored against itself, showed a real 33-34pp
attacker/defender split (same-hull fleets and Speed Swarm showed none)
— and the bias persisted almost unchanged with the auto-disengage
mechanism (already known to cause a smaller, asymmetric bias — see the
"attacker leaves early" rule) fully disabled, which would have been the
natural first guess. The front tier's `packUniformGrid` placed
same-row ships (e.g. two cruisers, `stepIndex` differing only by
column) at the EXACT SAME depth — a perfect-tie geometry, the same
shape of problem as trap 27's collinearity bug, just on the axis this
placement rewrite introduced rather than the one that already had
wobble. Mitigated (not fully eliminated — matches trap 28's own framing
of this whole class of small-N chaos) with a second, decorrelated
wobble on the DEPTH axis (`placementWobbleDepth`, different
multiplier/modulus than the existing spread wobble so the two don't
cancel or reinforce for the same ship) — brought the mixed-fleet bias
down to ~8pp with retreat enabled (how real games actually play),
comparable to or better than other already-tolerated biases in this
engine. The residual with retreat disabled is larger and left
undocumented-as-fixed on purpose — that configuration is a diagnostic
tool this session used to rule out the retreat-timing hypothesis, not
a real gameplay scenario worth chasing further.
39. **A "column" placement function that stacks along the wrong local axis
produces a shape that isn't visually what its name promises, even
though nothing else in the pipeline complains.** `packColumn` (the
original name) stacked ships along DEPTH while holding spread ~flat —
numerically fine (no overlap, valid coordinates), but it meant Power
Pressure's "vertical line in the rear" sat at nearly constant
world-Y the whole time, just extending far into world-X: a horizontal
smear parked through the battlefield's vertical center, not a vertical
line at all. Nothing in the geometry checks caught it because they only
asserted relative depth ordering (heavy-ahead-of-light), never which
local axis a "column" actually varied along. Renamed to
`packVerticalLine` and rewritten to stack along spread instead
(`layoutPowerPressure`'s `packVerticalLine(group, depth, startSpread,
direction)`), with the existing `placementWobbleDepth` now holding
DEPTH ~constant instead of decorrelating it. Caught from Brian's own
report ("many of the units are starting in the center of the screen")
before it was traced to code, not the other way around — a reminder
that "the math checks out" and "it looks like what was asked for" are
different verifications, and only a human watching the screen catches
the second one reliably.
- **Immediate follow-up correction, same day**: the first fix
interpreted "the middle 40% of the screen empty" as a VERTICAL
(spread/Y) requirement — pushing every ship at least
`worldHeight·0.4/2` from the horizontal centerline — matching the
literal words ("vertically speaking… middle 40%… use vertical
space") but not Brian's actual intent, which he clarified
immediately after seeing the first pass land: the empty band is
HORIZONTAL (depth/X, between the two fleets' front lines), and
"vertical space" in the original ask meant ships are free to use
the full height for their columns/arcs, not that the gap itself
runs vertically. Moved the 40% floor from a per-ship spread
constraint inside `layoutPowerPressure`/`layoutSpeedSwarm` to a
single floor on the attacker/defender gap in `placeFleets`
(`gap = Math.max(gap, worldWidth * CENTER_GAP_FRACTION)`, applied
AFTER the existing `maxSpan` safety clamp so it wins even for a
fleet whose own depth would otherwise have squeezed the gap
smaller) — safe to let this exceed `maxSpan` because, post-trap-39,
per-formation depth no longer grows with fleet size the way the old
grid did (ranks/bands stack along spread now, not depth), so
`maxDepth` stays small and bounded regardless of fleet size; this
fixed floor can't runaway the way `GAP_MULT`'s proportional term
could (the failure mode `maxSpan` was originally added to prevent,
see trap 29). Formations themselves no longer carry any center-gap
floor at all — they're free to use the full vertical span.
- Both passes were caught with the same isolated-smoke-test-first
method as every other formation fix this session, run against
`placeFleets` directly (not a full battle) since the invariant is
pure placement geometry: a fresh gap-check script asserting (a) the
attacker/defender front-to-front X gap clears 40% of world width,
(b) both vertical wings are populated, (c) same-side minimum spacing
still holds, and (d) the rear light-ship group varies far more in Y
than X (the corrected column shape) — all four re-verified after
the horizontal-vs-vertical correction, since the first pass's
version of (a) was checking the wrong axis entirely.
- Section 11's "column is deeper than wide" check was inverted to
match the corrected geometry (`lightYRange > lightXRange`, was
`lightXRange > lightYRange`) and a new front-to-front horizontal gap
check was added for both formations.
- Quick suite: 2608 passed, 0 failed. Full suite: 2610 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
40. **A pure `Math.hypot`+squared-distance-early-out fix for an O(n²) hot
loop only cuts the cost of the common case (an out-of-range pair) — it
does nothing about the *count* of pairs still being examined, which is
still O(n²) regardless.** Discovered the hard way: after V2 was chosen
to become the real game's combat resolver (see the section below),
`node --prof` on a 17-a-side battle showed `Math.hypot` at 41.4% of
total runtime — an easy, low-risk fix (V8's hypot does overflow/
underflow-safety work this game's small bounded coordinates never
need). Swapping it for manual `Math.sqrt(dx*dx+dy*dy)` plus a squared-
distance reject before the sqrt even runs (`computeSeparation`, the
per-ship-per-tick collision-avoidance sweep) took a 17-a-side mirror
from 77.4ms to 18.4ms — a real, measured win. But a genuine self-play
soak (run as due diligence before trusting the verifier's own "AI turn
time" check — which turned out to only time `AI.runAITurn`, never
`resolveCombats`, so it was structurally blind to this whole class of
problem) found a single game turn stalling **46.3 seconds**, caused by
a 278-ship battle. The early-out cheapens examining a far-apart pair;
it doesn't reduce how many pairs get examined — still O(n²) iterations
regardless of how cheap each one is. Fixed with a uniform spatial grid
(`SEPARATION_CELL_SIZE=400`, derived from the largest possible
avoidRadius pair-sum — two battleships, 350 — with headroom) scanning
only a ship's own cell + its 8 neighbors, turning the sweep into
roughly O(n) for a reasonably-spread battle. **A second trap inside the
fix**: forcing every battle through the grid unconditionally regressed
small/typical battles (Map-building overhead exceeds the O(n²) scan's
own cost until a battle is genuinely large) — MEASURED, not assumed,
the same discipline as everything else in this doc. Split into two
fully separate functions, `computeSeparationFlat`/`computeSeparationGrid`
(not one function branching on data shape — V8 optimizes a small
single-purpose function more reliably), dispatched once per tick via
`SEPARATION_GRID_THRESHOLD=60`. Both exported purely for verifier
consumption (matching the existing `placeFleets`/`shipBounds`
pattern), with a correctness check (grid output must exactly match
brute-force flat output for a synthetic ship set, including ships
sitting exactly on a cell boundary) and a performance regression check
(a 278-ship battle must resolve under 5s). This dropped the 46.3s
worst case to 16.9s — real progress, but (see trap 41) not the whole
fix.
41. **Even O(n) collision avoidance degrades when ship DENSITY grows
unboundedly in a FIXED-SIZE world.** Re-running the soak with an
improved diagnostic (measuring fleet sizes *after* `Logic.moveFleetsFor`
runs, not before — the original diagnostic's pre-movement snapshot
missed battles between fleets that arrive and immediately fight in the
same turn) found real late-game wars (turn 400-500+) putting up to
**790 ships** into one battle. The 3600×2400 world doesn't grow with
fleet size, so packing hundreds of ships into it means grid cells get
crowded and per-tick cost keeps climbing even with trap 40's fix:
418 ships → 9.9s, 737 → 14.7s, 790 → 16.9s. This is a structural limit
of "simulate every ship's continuous physics in a fixed-size arena,"
not a bug to keep squeezing — Brian's explicit choice, offered as one
of several options (accept the ~17s worst case as rare; scale the
battle world with fleet size; or cap simulated ships and fold the rest
in via V1's cheap aggregate math): the last one. New
`MAX_SIMULATED_SHIPS_PER_SIDE=100` (200 total) — comfortably below the
tested-safe ~278-ship mark, since real self-play's messier 166-ship
case already cost ~2s, more than a clean synthetic mirror benchmark at
the same size. `capFleetLines()` proportionally downsamples a side's
fleet-lines to the cap using largest-remainder apportionment (preserves
each hull's relative share — a fleet that's 80% frigates still
simulates as ~80% frigates, not skewed by naive truncation), called in
`createBattle()` BEFORE per-ship expansion, so an oversized fleet never
even gets expanded past the cap. The excess (`battle.attackerOverflow`/
`defenderOverflow`, `[]` for the overwhelming majority of battles that
never approach the cap) is resolved in `battleResult()`: if only ONE
side overflowed, those ships never met an opposing force and simply
survive intact; if BOTH sides overflowed, they fight each other as an
isolated battle via **V1's** `createBattle`/`runBattle` (a new but
harmless dependency direction — V1 itself is not modified, still
resolves `resolveInvasion` and nothing about this import touches it),
merged into the simulated portion's own survivor/loss totals. Winner:
if merging leaves one side at zero total ships and the other with some,
that side wins outright (can flip the simulated portion's own result —
a wiped-out simulated force whose untouched reserve overflow still
holds the field); otherwise the simulated (actually-fought) portion's
winner decides. `VegaCombatViewV2.js` shows a one-line disclosure
("+N reserve ships also engaged") when a battle the player watches had
any overflow, so the final counts never silently disagree with what
was shown fighting on screen. Verified: conservation (survivors+losses
always equals the original per-side total — no ship silently vanishes
to rounding), proportional sampling, both winner-decision branches on
constructed edge cases, and — the real gate — a 790-ship battle
(matching real self-play's measured extreme) now resolving in ~1.3s
instead of 16.9s. **Re-running the actual 27-game self-play turn-time
diagnostic** (not just synthetic benchmarks — this exact tool is what
caught that trap 40's fix alone wasn't sufficient) confirmed the real
fix: worst-case single-turn cost across the whole soak dropped from
**46.3s → 16.9s → 4.5s**, with even a 636-ship real-game battle now
resolving in 3.3s.
## Master of Vega V2 — per-ship tactical combat prototype (2026-08-09) ## Master of Vega V2 — per-ship tactical combat prototype (2026-08-09)
@ -990,6 +1263,180 @@ model.
- Quick and full suites: clean, 0 failures (after the tolerance widening). - Quick and full suites: clean, 0 failures (after the tolerance widening).
- Never browser-tested, per [[feedback_no_auto_verify]]. - Never browser-tested, per [[feedback_no_auto_verify]].
**"Ships are running away from each other" — two real bugs, not disengage,
still 2026-08-09**: Brian noticed capital ships visually behaving like
they were fleeing each other and asked whether that was the auto-disengage
mechanism. It wasn't — disengage is a rare, whole-side, once-per-battle
event; what he was seeing happened continuously, ship-pair by ship-pair,
during ordinary combat. Traced to two separate causes, both fixed same
day — see traps 34 and 35 for the full mechanism of each:
- **Trap 34**: the anticipatory pre-turn (trap 33) snapped a ship's nose
straight onto retrograde for its ENTIRE pre-turn window (15-20+ seconds
for the slowest hulls), collapsing corrective steering thrust toward
zero for that whole stretch — harmless when nothing else was nearby (the
scenario trap 33 was validated against), a real problem the instant
something WAS (even gentle collision avoidance from a teammate). Fixed
by blending the pre-turn target smoothly instead of snapping.
- **Trap 35**: ships steer at their target's CURRENT position, not where
it's actually headed — fine against a stationary or slow target, a real
failure against a target that's independently pursuing a THIRD ship
entirely (common in any multi-ship battle) and therefore moving in a
direction that has nothing to do with this shooter. New
`predictIntercept()` (classic lead-pursuit quadratic, degenerates
exactly to today's plain pursuit for a stationary target, falls back to
it when no real intercept exists) fixed the actual "never closes the
gap" failure.
- Both fixes were found and validated the same way as everything else in
this movement system: isolated math/scenario smoke tests first (8 unit
cases for the intercept algebra alone — stationary target, closing
head-on, receding-but-catchable, receding-faster-than-shooter, matched
speeds, degenerate shooter-at-target-position), THEN wired in, THEN
re-run against the actual 2-battleship trace that exposed the bug in the
first place to confirm the specific symptom was gone, THEN the full
verifier.
- New permanent verifier check (section 11): the same 2-battleship-per-side
scenario, asserting a still-alive, still-being-chased target's distance
closes to within 3× beamRange over a full minute rather than growing
without bound — protects the actual regression found, not a proxy for
it.
- Quick suite: 2597 passed, 0 failed (one more check than before). Full
suite: 2598 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
**Strategy formations actually shape placement, still 2026-08-09**: from
the very first formation-strategy session ("this is plumbing only —
formation strategy does not yet affect placement, movement, or
targeting"), Brian specified exactly how each strategy should arrange a
fleet — and asked for per-hull-type spacing instead of the uniform grid at
the same time. Both landed together, since they're the same rewrite.
- **Per-hull spacing, not a uniform grid**: `fleetFootprint`/`placeFleet`
(one grid cell size for the whole fleet, sized by whichever hull happened
to be biggest present — a lone battleship among many destroyers used to
force every destroyer to keep battleship-sized distance for no reason)
replaced entirely. New primitives — `packUniformGrid` (a small grid for
one hull-homogeneous group), `packColumn` (a single-file line, spacing
each adjacent PAIR by their own two `avoidRadius` values, so a mixed-hull
column still gets correct per-pair spacing) — are built on `avoidRadius`,
which already existed for collision avoidance rather than a second,
independent size concept.
- **Local (depth, spread) coordinate space**: every layout works in depth
(distance behind this side's own front line — "the front of battle,"
Brian's own framing, as the reference point) and spread (perpendicular
offset from centerline) BEFORE `placeFleets()` translates into world
coordinates, flipping which raw-X direction is "deeper" per side.
- **Power Pressure** ("bigger tougher ships on the front line... well
ahead of the frigates and destroyers who form a vertical line in the
rear... support the larger battleships and cruisers getting to the
middle area first"): hulls above `HEAVY_SIZE_THRESHOLD` (sizeScale > 1.0
— cruiser/battleship; destroyer sits at exactly 1.0 and stays rear, per
Brian's explicit naming) form a compact cluster at shallow depth;
everyone else forms one tall single-file column well behind them.
- **Speed Swarm** ("large ships in the rear, fanning out smaller and
smaller ships towards the center... a tall arch... as wide an angle as
possible"): hulls grouped into size bands (same hullId = same
`avoidRadius` = same band), swept along an eased curve
(`sin(t·π/2)`, not linear, so the curve stays narrow near the rear and
only fans out as hulls actually get smaller) from rear-and-narrow
(biggest) to front-and-wide (smallest) — the smallest, fastest hulls end
up both closest to the front AND furthest to the sides, so a longer,
wider diagonal path is offset by a head start and (now, post-agility-
retune) genuinely higher speed, aiming for a simultaneous multi-angle
arrival rather than exact-to-the-second synchronization.
- **Three real bugs, all caught by the same isolated-first methodology used
all session** (design → dedicated same-side-overlap smoke test → full
battle simulation → verifier) — see traps 36-38 for the full mechanism
of each: (36) a spacing term multiplied through a fan magnitude that's
deliberately 0 for the rear band, collapsing to zero offset instead of
real spacing; (37) a vertical-fit safety clamp that scaled already-exact
minimum distances down along with everything else — fixed at the source
(bound the fan shape before placing, never scale placed coordinates
after) instead of patched at the symptom; (38) a genuine mixed-hull
mirror-match bias (33-34pp) from Power Pressure's front tier placing
same-row ships at the exact same depth — a perfect-tie geometry, same
shape as trap 27's collinearity bug — mitigated with a second,
decorrelated depth wobble down to ~8pp with retreat enabled.
- Three EXISTING verifier checks broke, not from a regression in this
rewrite but from an outdated test assumption: none of them pinned an
explicit `formationStrategy`, which was harmless while formation was
pure plumbing but now lets each side silently resolve to a DIFFERENT
strategy (mismatched footprint shapes) or makes results depend on
exactly which strategy a given seed happens to draw. Fixed by pinning
explicit, matching formations in every geometry-focused check, and by
switching the "footprint grows with fleet size" check from literal
fleet-to-fleet gap to overall `shipBounds` width, since Speed Swarm's
footprint growth is often more about spread than depth.
- New permanent verifier checks (section 11): Power Pressure's heavy/light
depth ordering and column shape, Speed Swarm's size-based fan spread and
forward lean, and same-side minimum-spacing safety for both formations.
- Quick suite: 2604 passed, 0 failed (6 more checks than before). Full
suite: 2606 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
- **Follow-up, same day (trap 39)**: Brian watched it and reported units
still clustering near screen-center — the front tier's "column" was
actually stacking along depth, not spread, so it never varied in world-Y
at all. Fixed by rewriting the column packer to stack along spread, and
a horizontal (not vertical) 40%-of-world-width floor was added on the
attacker/defender front-line gap in `placeFleets` after Brian corrected
an initial vertical-axis misread of his own "middle 40% empty" ask. See
trap 39 for the full mechanism of both the bug and the correction.
**Battles favor staying centered until ships are defeated, still
2026-08-09** (Brian: "battles wind up being great at the beginning and then
drift off screen towards the end... have the ships favor centered battles
until such time as ships are defeated"): confirmed two contributing facts
before writing any code — `VegaCombatCamera.js`'s `bindZoomPan` computes its
starting zoom/pan ONCE, from `fitBounds` at battle creation, and never
re-fits as the fight moves; and nothing in `computeShipMove()` constrained a
ship's position at all — pursuit, strafing, and chasing survivors were free
to wander anywhere in the open 3600×2400 world. Fixed at the physics level
(not the camera): a new gentle, always-on "return toward roughly where the
battle started" force, same shape as the existing `avoidAccel` — its own
flat acceleration budget (`combatV2.centeringAccel`, 45), never
alignment-gated, purely additive so real task/avoidance thrust can always
override it.
- **The anchor is FIXED, computed once in `createBattle`, not a live
centroid recomputed every tick.** A live centroid (mean of wherever every
ship currently is) can't actually counteract net drift — by definition
it's already wherever the fight currently is, so pulling ships toward it
only tightens clustering around the fight's current location, it doesn't
stop that location itself from translating. `battle.centerX/centerY` is
the mean position of every entity (both fleets, planet included) at the
moment they're placed; `battle.centeringComfortRadius` is the farthest
any entity actually started from that point, ×1.2 slack.
- **The comfort radius is sized to the battle's OWN starting spread, not a
guessed fraction of world size.** This was the one real design trap:
Power Pressure's front/rear depth split and the newly-added trap-39
horizontal center-gap both deliberately spread ships far from world
center at t=0 — a generic fixed-fraction radius would have undercut that
and made the centering force immediately fight the placement work from
earlier the same day. Deriving the radius from the actual starting
geometry instead guarantees the force is exactly zero at t=0 for any
formation, fleet size, or fleet-vs-planet configuration, by construction
— confirmed by a verifier check asserting `worstStart <=
centeringComfortRadius` for a real mixed-hull Power-Pressure battle.
- `computeCenteringForce(s, center)` mirrors `computeSeparation`'s falloff
shape: zero inside the comfort radius, then ramps in QUADRATICALLY
(`t*t`), capped at full strength by 2× the comfort radius so it can't
out-muscle real combat thrust indefinitely once a fight has genuinely
wandered far.
- Verified in three stages before touching the verifier: (1) a stranded
ship placed 3× its comfort radius from center, given no target so no task
thrust competes, measurably moves toward center over one tick; (2) eight
seeds of a real mixed-hull mirror battle, comparing max distance any
living ship ever reached from its own battle's center — centering ON
averaged 2951 units vs. OFF's 8081, and was never-worse on 8/8 seeds; (3)
the same cruiser×5/destroyer×8 mirror-match bias check used throughout
the damage-tuning work, confirming centering doesn't shift attacker win
rate at all (0.53/0.60, bit-for-bit identical on vs. off at the same
seeds) — it's purely a geometry/timing effect, not something that touches
the hit-chance/RNG path. Bonus, not the ask, but worth recording: duration
dropped too (cruiser×5 78.9s→42.8s, destroyer×8 38.8s→31.8s) — pulling
stray ships back together lets them re-engage instead of coasting apart.
- New permanent verifier checks (section 11): the zero-force-at-start
invariant, and a 5-seed on/off average-max-drift comparison.
- Quick suite: 2610 passed, 0 failed. Full suite: 2612 passed, 0 failed.
- Never browser-tested, per [[feedback_no_auto_verify]].
### Ship rows carry video, so they have to be pooled ### Ship rows carry video, so they have to be pooled
Every place a ship is listed — the side panel's task force, its in-transit, Every place a ship is listed — the side panel's task force, its in-transit,
@ -1133,16 +1580,198 @@ prints — max pop, output, construction, factory cap — is finite and sane on
colony *one tick old*, for all 13 colonisable world types. That last state is colony *one tick old*, for all 13 colonisable world types. That last state is
one no other screen ever sees. one no other screen ever sees.
**V2 becomes the official combat engine, still 2026-08-09** (Brian: "Let's
make V2 the new official way battles happen"): the guardrail stated
throughout every V2 entry above — `VegaCombat.js`/`VegaCombatView.js`
"completely untouched," every real player battle running the original
engine, V2 confined to `?movsim`'s toggle — is deliberately lifted here, at
Brian's explicit request, after V2's momentum/collision-avoidance/lead-
pursuit/formation/centering work (all documented above) had matured enough
to trust with real games.
- **Performance had to come first.** Benchmarked headlessly before writing
any migration code: V1 gets CHEAPER as fleets grow (0.26ms for
17-a-side, discrete-round aggregate math); V2 got far MORE expensive for
the identical fight (77ms) — the opposite trend, and dangerous, since
`VegaLogic.resolveCombats()` runs on every empire's turn for every star
with hostile fleets sharing orbit, against a 50ms/turn AI budget the
self-play soak already enforced. Brian's explicit choice, given this:
optimize first, then swap — not swap now and firefight later.
- **Traps 40-41** (above) are what that optimization pass actually took —
not the Math.hypot fix alone (which looked sufficient at the synthetic
benchmark scale that motivated it, 77.4ms→18.4ms for 17-a-side, but a
real self-play soak found a 46.3-second single-turn stall from a
278-ship battle), and not the spatial grid alone either (fixed the
278-ship case but a real soak found 790-ship late-game mega-wars still
stalling 16.9s). Both real problems were found by the SAME tool — an
actual 27-game self-play turn-time diagnostic, not synthetic mirror
benchmarks — which is also what confirmed the final fix: worst-case
turn time across a real soak dropped **46.3s → 16.9s → 4.5s**.
- **The wiring itself was a mechanical swap**, once the performance work
was done: `VegaLogic.js`'s `createBattle`/`runBattle` import switched
from `VegaCombat.js` to `VegaCombatV2.js` (`resolveInvasion` stays on
`VegaCombat.js` — ground combat has zero shared surface with either
space-combat engine); `MasterOfVegaGame.js`'s `playPlayerBattles()`
switched from `openCombatView` to `openCombatViewV2` (identical call
signature, confirmed by reading both before touching either). Both
`battleResult()` shapes were already kept identical from V2's original
design specifically so this swap would be mechanical when the day came.
One real semantic difference, checked and found harmless: `result.rounds`
is now elapsed SECONDS, not a discrete round count — confirmed by grep
that nothing in the real game UI ever reads it (only `VegaCombatSim.js`'s
own simulator does, already V2-aware).
- **`VegaCombat.js`/`VegaCombatView.js` were NOT deleted.** They still
power `resolveInvasion` and `?movsim`'s "Live" toggle option (kept for
direct side-by-side comparison during the transition) — cleanup, if ever
wanted, is a separate, easily-reversible decision made later, not
bundled into this change.
- Every check in this migration was gated on the FULL verifier and, twice,
on a real end-to-end self-play soak — not just synthetic benchmarks —
because that's specifically what caught both real problems traps 40-41
describe.
- Never browser-tested, per [[feedback_no_auto_verify]] — Brian to verify
a real game turn/battle in-browser.
**Ship-type commander roster along the bottom, still 2026-08-09** (Brian:
per-side row of each unique ship type's commander video, smallest hull
closest to that side's own screen corner, biggest toward the center; a
type that's completely wiped out swaps to a procedurally-generated static/
snow loop). `VegaCombatViewV2.js` only — headless `VegaCombatV2.js` is
untouched, this is pure presentation.
- `uniqueHullsFor(side)` reads the hull types actually present in
`battle.ships` at battle start — deliberately the SIMULATED roster only,
not `battle.attackerOverflow`/`defenderOverflow` (trap 41's mega-battle
cap): an overflow ship never appears on screen at all, so a commander
video for a type that's never rendered would be actively confusing, not
informative. Sorted by `rules.hulls[hullId].sizeScale` ascending — the
same "how big/tough is this hull" axis used everywhere else in V2
(formation placement, `HEAVY_SIZE_THRESHOLD`, etc.), reused rather than
inventing a second size concept.
- Positioned outward from each side's own corner (attacker bottom-left,
defender bottom-right) toward the horizontal center as size increases —
literally the spec, "smallest at the edge, strongest toward the middle."
Icon size itself stays UNIFORM across hulls (only position encodes size)
so a scout's portrait is exactly as readable as a battleship's — a
deliberate implementation choice, not asked for explicitly, made for
legibility. Verified with an isolated pure-JS re-derivation of the
layout math against synthetic fleets (can't run `VegaCombatViewV2.js`
itself headlessly — it imports Phaser): correct sort order, correct
edge-to-center growth direction per side, no same-side icon overlap, and
the two sides' rows never collide even at 6 unique hull types per side
(comfortably past what a real battle fields).
- The roster is fixed for the whole battle — who STARTED fighting, not who
currently has ships left. A wiped hull's SLOT stays in place; it goes
dark, it doesn't disappear or shift its neighbors' positions.
- Reuses `VegaShipMedia.js`'s `makeCommanderPortrait()` directly (same call
shape already used by `VegaShipDetail.js`) rather than its pooling
wrapper (`createShipMediaPool`) — the pool exists specifically for views
that rebuild their whole row list on every click (side panel, colony
catalogue); this roster is built once per battle and never rebuilt, so
the simpler direct call is the right tool, not the pool.
- **The "wiped" check reuses `syncMarkers`' own alive definition**
(`hp > 0 && !retreated`), not `hp > 0` alone — a side's last ship of a
type retreating counts as that type being gone from the fight the same
as it dying, consistent with how the rest of this view already treats
retreat.
- **Snow/static is procedurally generated, no video asset**: a small
(32×32) canvas texture per wiped slot (`scene.textures.createCanvas`,
the same pattern already used by Total Annihilation's fog-of-war/Zuma's
portal/Balatro elsewhere in this repo), filled with random greyscale
noise and refreshed on a `scene.time.addEvent` timer (~11fps — a video
loop doesn't need to update every render frame to read as "static"),
NEAREST-filtered (not the engine default LINEAR) so it stays crisp
blocky noise instead of blurring into grey mush. **One texture per
wiped slot, not shared** — several lost commanders in the same battle
don't flicker in lockstep, matching how real uncorrelated static
actually looks.
- **Cleanup matches this file's existing scene.time/input/events
discipline** (trap 25's lesson, still being applied): the refresh timers
are a `scene.time` resource independent of any game object's own
lifecycle, so `teardown()` explicitly removes every one, plus every
wiped slot's canvas texture — `MasterOfVegaGame.js` replays this view
once per battle, potentially many times in one scene session
(`playPlayerBattles`'s loop), so a leaked timer or texture per wiped
ship type would accumulate across every battle fought that session.
- Never browser-tested, per [[feedback_no_auto_verify]] — Brian to verify
in-browser, including how the static loop actually reads visually
(contrast/size/refresh-rate are first-pass judgment calls, easy to retune
once seen).
**Building icon moved below the text, still 2026-08-09**
(`VegaTurnReportScreen.js`): a completed building's picture used to sit
right-aligned BESIDE its synopsis text (reserving side wrap width so long
descriptions wouldn't run under it) — Brian asked for it below the text
instead, on the left, matching how a completed ship's hull icon + commander
portrait already sit. Now both `buildingDone` and `shipDone` rows share the
same "full-width text, then a left-aligned media block below it" shape;
the now-always-0 side-reserved wrap width (`mediaW`/`BUILDING_MEDIA_W`) was
removed rather than left dead. Never browser-tested.
**A direct War/Cease Hostilities toggle for diplomacy-incapable species,
still 2026-08-09** (Brian, after establishing the player had NO path to
war with Lithox at all): `canNegotiate()` returns false whenever either
side's `traits.diplomacy <= -100` — Lithox's flag — which meant "Seek
Audience" sat permanently disabled for that row with no other diplomacy
entry point anywhere in the game. Worse, the only way war could ever start
was Lithox itself auto-declaring during its own AI turn
(`runDiplomacyTurn`'s "species that cannot negotiate still go to war, more
readily" branch), and that only fires when LITHOX's own power exceeds the
target's by 1.25× — never the reverse — so a player stronger than Lithox
could be locked out of ever fighting them, permanently.
- **Scoped to diplomacy-incapable rows only**, confirmed with Brian before
building it: capable empires keep the existing Audience-based Declare
War / negotiated Sue-for-Peace flow completely unchanged. Making this
universal would have let the player unilaterally force peace on ANY
empire, bypassing `wouldAccept`'s negotiation — a real balance change,
not the gap being closed here.
- `VegaScreens.js`'s `openDiplomacyScreen` now branches per row on the same
`traits.diplomacy <= -100` check `canNegotiate` already makes: incapable
rows get a `Declare War` / `Cease Hostilities` toggle (label driven by
current `atWar()`) in place of the disabled Seek Audience button.
- **Reuses `declareWar()`/`makePeace()` directly** — the exact same
functions the Audience screen's own Declare War button and an accepted
Sue for Peace offer already call — rather than inventing a parallel
relationship state. This only changes WHO can trigger them and how
(instantly, unilaterally, no negotiation UI at all), never what they
actually do to `treaties`/attitude/events.
- On click: tears down and reopens `openDiplomacyScreen` itself (same
`onClose`/`onChanged`) rather than calling `onClose` — the player stays
on the diplomacy list and sees the updated stance immediately, instead
of the whole modal closing the way Seek Audience does.
- Verified the underlying state transitions headlessly (can't run
`VegaScreens.js` itself, it's Phaser): exactly one default species
(`lithox`) trips the incapable gate; `declareWar`/`makePeace` correctly
flip `atWar()` both directions for a human/Lithox pair even though
`canNegotiate` stays false throughout, confirming the toggle works
precisely where the normal negotiation path cannot. Full verifier
unaffected (2622 passed, 0 failed) — this UI file isn't Node-importable,
so it was never going to move that number.
- Never browser-tested, per [[feedback_no_auto_verify]].
## Balance reference (27-game AI soak) ## Balance reference (27-game AI soak)
Re-measured 2026-08-09 after V2 became the official combat engine (see
traps 40-41 and the narrative entry above) — the figures below reflect V2,
not the original V1-era numbers this section used to hold:
``` ```
outcomes: { conquest: 17, council: 7, timeout: 3 } outcomes: { conquest: 14, council: 7, timeout: 6 }
turns: min 110, median 282, max 800 turns: min 110, median 285, max 800
AI turn time: 0.45 ms average (budget 50 ms) AI-decision time: 1.27 ms average worst-case (budget 50 ms — this measures
mirror-match bias: < 5.2pp at every tier, zero stalemates AI.runAITurn only, NOT resolveCombats; see traps 40-41 for combat's own
wins spread across 8 of 10 species separately-measured cost, which is what those fixes were about)
``` ```
Timeout share roughly doubled (3→6 of 27) and conquest dropped a bit
(17→14) relative to the old V1 reference — plausibly real (V2's combat
dynamics genuinely differ: continuous physics, momentum, formations, and
the disengage/duration tuning documented earlier in this file all shape
how decisively a fight resolves), but this is one 27-game sample, not
re-tuned against; worth re-checking if it drifts further in a future soak.
Mirror-match-bias and species-spread assertions (verifier sections 5/11)
both still passed at their existing tolerances — not re-measured as a
standalone figure here.
## Files touched to register the game ## Files touched to register the game
`src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js` `src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js`

View File

@ -24,7 +24,9 @@ import VegaStarMap from './VegaStarMap.js';
import VegaSidePanel from './VegaSidePanel.js'; import VegaSidePanel from './VegaSidePanel.js';
import VegaFx from './VegaFx.js'; import VegaFx from './VegaFx.js';
import { openSystemView } from './VegaSystemView.js'; import { openSystemView } from './VegaSystemView.js';
import { openCombatView } from './VegaCombatView.js'; // V2 (formerly ?movsim-only) is the real game's combat view now — see
// VegaLogic.js's createBattle/runBattle import comment.
import { openCombatViewV2 } from './VegaCombatViewV2.js';
import { import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen, FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
openSaveScreen, openLoadScreen, showVictoryOverlay, openSaveScreen, openLoadScreen, showVictoryOverlay,
@ -1029,7 +1031,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
} }
// Fight the human's battles one at a time on the tactical screen. Each one is // Fight the human's battles one at a time on the tactical screen. Each one is
// prepared by the engine, driven round by round by the view, and its outcome // prepared by the engine, driven tick by tick by the view, and its outcome
// handed straight back — so a battle the player fights and one the AI // handed straight back — so a battle the player fights and one the AI
// auto-resolves go through exactly the same code. // auto-resolves go through exactly the same code.
playPlayerBattles(done) { playPlayerBattles(done) {
@ -1045,7 +1047,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.map?.panToStar(starIdx, 260); this.map?.panToStar(starIdx, 260);
this.modalOpen = true; this.modalOpen = true;
this.music?.setCategory('combat'); this.music?.setCategory('combat');
openCombatView(this, this.rules, prepared.battle, this.art, { openCombatViewV2(this, this.rules, prepared.battle, this.art, {
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId, attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId, defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender', playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender',

View File

@ -21,6 +21,13 @@
import { designFor } from './VegaShips.js'; import { designFor } from './VegaShips.js';
import { isFormationStrategy, randomFormationStrategy } from './VegaFormations.js'; import { isFormationStrategy, randomFormationStrategy } from './VegaFormations.js';
// Used ONLY to resolve the rare mega-battle "overflow" (see
// MAX_SIMULATED_SHIPS_PER_SIDE) via V1's cheap aggregate math instead of
// full per-ship simulation — V1 itself is not modified by this import, and
// every real player battle still resolves through THIS file's own
// createBattle/runBattle/battleResult, unchanged for the vast majority of
// battles that never hit the cap.
import { createBattle as createBattleV1, runBattle as runBattleV1 } from './VegaCombat.js';
// Aggregate rather than per-shot rolls — a single hull can still mount many // Aggregate rather than per-shot rolls — a single hull can still mount many
// copies of one weapon (m.count, e.g. 30+ laser cannons on a battleship), so // copies of one weapon (m.count, e.g. 30+ laser cannons on a battleship), so
@ -36,7 +43,15 @@ function sampleHits(rnd, shots, chance) {
} }
const avgDmg = (w) => (w.min + w.max) / 2; const avgDmg = (w) => (w.min + w.max) / 2;
const dist2D = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); // Manual sqrt, not Math.hypot — hypot's extra overflow/underflow-safety work
// is pure overhead for this game's small, bounded coordinates (world tops
// out around 3600x2400, nowhere near float64's danger zone), and this
// helper (plus the equivalent pattern in computeSeparation/computeShipMove/
// computeCenteringForce) runs on the order of ships-squared times per
// battle. Confirmed via `node --prof`: Math.hypot alone was 41.4% of total
// runtime resolving a 17-a-side battle headlessly, ahead of every other
// single function in the profile.
const dist2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
// Fixed simulation tick, seconds. Both `runBattle()` (headless) and the view // Fixed simulation tick, seconds. Both `runBattle()` (headless) and the view
// (rendered, via a real-time accumulator) drive `advance()` with this exact // (rendered, via a real-time accumulator) drive `advance()` with this exact
@ -146,6 +161,52 @@ function makeShip(rules, design, side, hullIdx, i, seq, formationStrategy) {
}; };
} }
// A real self-play soak (late-game wars, turn 400-500+) produced battles
// with up to 790 ships at one star — even with spatial-grid collision
// avoidance (see computeSeparationGrid), a fixed 3600x2400 world holding
// that many ships gets dense enough that per-tick cost keeps climbing
// (measured: 418 ships -> 9.9s, 737 -> 14.7s, 790 -> 16.9s for a single
// game turn). That's a structural limit of full per-ship physics in a
// fixed-size arena, not a bug to squeeze further. Brian's chosen fix: cap
// how many ships per side get full simulation, and fold the rest in via
// V1's much cheaper aggregate math (see battleResult's overflow handling).
// 100/side (200 total) sits with real margin below the tested-safe ~278
// mark — real self-play's messier 166-ship case already cost ~2s, more
// than the cleaner synthetic mirror benchmark at the same size, so this
// leaves room for less favorable conditions than a clean lab benchmark.
const MAX_SIMULATED_SHIPS_PER_SIDE = 100;
// Proportionally downsamples one side's fleet-line list to at most `cap`
// total ships using largest-remainder apportionment (the same method used
// for allocating legislative seats proportionally) rather than naive
// truncation, which would systematically favor whichever hull line happens
// to come first in the array. Preserves each hull's relative share of the
// fleet in the simulated sample — a fleet that's 80% frigates/20%
// battleships still simulates as roughly 80%/20%, not skewed. No-op
// (returns the fleet unchanged, empty overflow) for the vast majority of
// battles that never approach the cap.
function capFleetLines(fleetLines, cap) {
const total = fleetLines.reduce((t, l) => t + l.count, 0);
if (total <= cap) return { simulated: fleetLines, overflow: [] };
const scale = cap / total;
const kept = fleetLines.map((line) => ({ ...line, count: Math.floor(line.count * scale) }));
const remainders = fleetLines
.map((line, i) => ({ i, frac: (line.count * scale) - kept[i].count }))
.sort((a, b) => b.frac - a.frac);
let leftover = cap - kept.reduce((t, l) => t + l.count, 0);
for (const r of remainders) {
if (leftover <= 0) break;
kept[r.i].count += 1;
leftover -= 1;
}
const overflow = [];
fleetLines.forEach((line, i) => {
const remain = line.count - kept[i].count;
if (remain > 0) overflow.push({ ...line, count: remain });
});
return { simulated: kept.filter((l) => l.count > 0), overflow };
}
function sideShips(rules, empire, fleetShips, side, seqCounter, formationStrategy) { function sideShips(rules, empire, fleetShips, side, seqCounter, formationStrategy) {
const out = []; const out = [];
fleetShips.forEach((s, hullIdx) => { fleetShips.forEach((s, hullIdx) => {
@ -160,7 +221,6 @@ function sideShips(rules, empire, fleetShips, side, seqCounter, formationStrateg
return out; return out;
} }
const MAX_COLS = 5;
const GAP_MULT = 3; const GAP_MULT = 3;
const MIN_GAP = 900; const MIN_GAP = 900;
// How much more than the bare minimum comfortable distance (avoidRadius×2, // How much more than the bare minimum comfortable distance (avoidRadius×2,
@ -168,54 +228,210 @@ const MIN_GAP = 900;
// start the battle already violating their own collision-avoidance radius. // start the battle already violating their own collision-avoidance radius.
const CELL_MARGIN = 1.15; const CELL_MARGIN = 1.15;
// A grid of a few columns and rows per side, spaced by the largest hull // --- Formation-aware placement --------------------------------------------
// present so bigger ships don't visually overlap smaller ones packed //
// nearby, AND wide enough that ships don't start inside each other's own // Brian's ask: instead of a uniform grid sized by whichever hull happens to
// collision-avoidance radius — cell size is derived from separationUnit, // be biggest present (a fleet of destroyers with one lone battleship in it
// not an independent constant. Getting this wrong is what caused fleets to // used to space every destroyer as far apart as the battleship needed, for
// scatter chaotically instead of converging on the enemy the first time // no reason), each hull keeps its OWN spacing — reusing `avoidRadius`,
// this was wired up: avoidance was fighting the initial placement from // which already exists per-ship for collision avoidance, rather than a
// tick one, since the old fixed CELL_BASE (90) was smaller than two // second independent size concept. And the two formation strategies (until
// cruisers' combined avoidRadius (separationUnit=70 × sizeScale=1.5 × 2 = // now pure plumbing, stamped onto ships but never read — see
// 210) — every ship started already deep inside its neighbours' personal // VegaFormations.js) actually shape how a side lines up.
// space. Deliberately simple and isolated otherwise — formation-aware //
// placement (Power Pressure/Speed Swarm actually arranging ships // Every layout works in LOCAL (depth, spread) space first: depth = distance
// differently) is still deferred to a later update; nothing else in the // behind this side's own front line (0 = right at the front, larger =
// engine cares how ships got arranged inside their own side, only that // further from the enemy — "the front of battle" as the reference point,
// each one ends up with an x/y. // per Brian's framing); spread = perpendicular offset from the centerline
function fleetFootprint(rules, ships) { // (+/-, becomes world Y once translated). placeFleets() below translates
const n = ships.length; // this into world coordinates, flipping which raw-X direction "deeper"
const cellBase = rules.combatV2.separationUnit * 2 * CELL_MARGIN; // means for whichever side reads forward as -X.
if (n === 0) return { //
cols: 0, rows: 0, cell: cellBase, gridW: 0, gridH: 0, // Two SAME-HULL ships (identical avoidRadius) packed by these helpers are
}; // always spaced by `(own radius + neighbour's radius) × CELL_MARGIN` — the
const cols = Math.max(1, Math.min(MAX_COLS, Math.ceil(Math.sqrt(n)))); // exact minimum-safe-distance formula collision avoidance itself uses
const rows = Math.ceil(n / cols); // (computeSeparation), just applied once up front instead of relied on to
const maxScale = ships.reduce((m, s) => Math.max(m, s.design?.hull?.sizeScale ?? 1), 1); // sort itself out after combat starts.
const cell = cellBase * Math.max(1, maxScale); function groupByHull(ships) {
return { const groups = new Map();
cols, rows, cell, gridW: (cols - 1) * cell, gridH: (rows - 1) * cell, for (const s of ships) {
}; if (!groups.has(s.hullId)) groups.set(s.hullId, []);
groups.get(s.hullId).push(s);
}
return [...groups.values()];
} }
function placeFleet(rules, ships, footprint, originX) { // A deterministic sub-cell wobble, not exact grid alignment. Without it, a
const originY = Math.max(0, (rules.combatV2.worldHeight - footprint.gridH) / 2); // row that lands exactly on the fleet's own centerline is also exactly
// level with a same-centered enemy row — every ship on that row computes
// the identical desired heading and converges onto the exact same point
// regardless of starting column, which cascades into pathological
// all-or-nothing focus fire. Keyed by `seq` (unique, stable) rather than a
// loop index, since these helpers no longer place ships in one single flat
// loop.
function placementWobble(s) {
return ((s.seq * 37) % 11) - 5;
}
// A second, decorrelated wobble (different multiplier/modulus) for the
// DEPTH axis — same-row ships in `packUniformGrid` otherwise share the
// exact same depth, which puts several ships (most consequentially the
// front-tier heavies under Power Pressure) the exact same distance from
// the enemy: a perfect-tie geometry that's the same shape of bug as trap
// 27's exact-collinearity issue, just on the depth axis instead of spread.
// Caught empirically: a mirror match with identical fleets/formation on
// both sides showed a 33-34pp attacker/defender split for Power Pressure
// specifically (speed_swarm and same-hull fleets showed none), and
// persisted even with auto-disengage fully disabled, ruling out the
// already-known asymmetric-retreat rule as the cause — ruling in the
// front tier's simultaneous-engagement geometry instead.
function placementWobbleDepth(s) {
return ((s.seq * 53) % 9) - 4;
}
// Brian: "the middle 40% of the [horizontal] screen empty at the beginning
// of a battle... use vertical space [for columns/arcs]... just leave that
// large center space open." This is a HORIZONTAL (depth/X) requirement, not
// a vertical (spread/Y) one — clarified after an initial misread applied it
// to spread instead. It's enforced once, on the attacker/defender front-line
// gap, in placeFleets() below; formations are free to use the full vertical
// span for their columns/arcs with no Y-axis floor.
const CENTER_GAP_FRACTION = 0.4;
// Stacks ships one after another along the SPREAD axis — a genuine
// vertical line, growing away from the centerline in `direction` (+1 up,
// -1 down). Previously this stacked along DEPTH by mistake (a leftover
// from thinking of it as "how far back," not "how it actually looks on
// screen"), which put an entire tier's ships at nearly identical spread —
// clustering them right through the battlefield's vertical center, the
// exact crowding this rewrite exists to fix. Each ship's own avoidRadius
// (not a shared cell size) determines spacing to its immediate neighbour,
// so a mixed-hull column still spaces each pair correctly.
function packVerticalLine(ships, depth, startSpread, direction) {
const placed = [];
let spread = startSpread;
let prevRadius = 0;
ships.forEach((s, i) => { ships.forEach((s, i) => {
const col = i % footprint.cols; spread += direction * (i === 0 ? s.avoidRadius : prevRadius + s.avoidRadius) * CELL_MARGIN;
const row = Math.floor(i / footprint.cols); placed.push({ ship: s, depth: depth + placementWobbleDepth(s), spread });
s.x = originX + col * footprint.cell; prevRadius = s.avoidRadius;
// A deterministic sub-cell wobble, not a plain grid. Without it, any row
// that lands exactly on the fleet's own centerline is also exactly level
// with a same-centered enemy row — every ship on that row computes the
// identical desired heading (0 or PI) and, since "how far to move" only
// depends on distance to the same shared target, converges onto the
// exact same point regardless of starting column. That degenerate
// collision then cascades into pathological all-or-nothing focus fire.
// The wobble is small relative to CELL_BASE, so it doesn't meaningfully
// change the grid's overall footprint, only breaks exact collinearity.
const wobble = ((i * 37) % 11) - 5;
s.y = originY + row * footprint.cell + wobble;
}); });
return { placed, endSpread: spread + direction * (ships.length ? prevRadius * CELL_MARGIN : 0) };
}
// Power Pressure (Brian: "the bigger tougher ships on the front line...
// well ahead of the frigates and destroyers who form a vertical line in
// the rear... oriented... to support the larger battleships and cruisers
// getting to the middle area first... line up ships in columns based on
// rank"): hulls above the size threshold form the front line, everyone
// else a line well behind them — BOTH lines built the same way, as a set
// of vertical columns (one hull "rank" at a time), split into upper/lower
// wings straddling the centerline, each successive rank's column-pair
// nested further out than the last. (The empty band Brian wants clear is
// horizontal — between the two sides' front lines — not this vertical
// split; see CENTER_GAP_FRACTION in placeFleets.)
const HEAVY_SIZE_THRESHOLD = 1.0; // destroyer (1.0) stays rear; cruiser (1.5)+ is front
function layoutPowerPressure(ships, C2) {
const heavy = ships.filter((s) => (s.design?.hull?.sizeScale ?? 1) > HEAVY_SIZE_THRESHOLD);
const light = ships.filter((s) => (s.design?.hull?.sizeScale ?? 1) <= HEAVY_SIZE_THRESHOLD);
const RANK_GAP = 150; // clear air between one rank's column-pair and the next, nesting outward
const REAR_DEPTH = C2.beamRange * 4; // clear distance behind the front line
function layoutRanks(rankShips, depth) {
const placed = [];
let outward = 0;
for (const group of groupByHull(rankShips)) {
const half = Math.ceil(group.length / 2);
const { placed: upRows, endSpread: upEnd } = packVerticalLine(group.slice(0, half), depth, outward, 1);
const { placed: downRows, endSpread: downEnd } = packVerticalLine(group.slice(half), depth, -outward, -1);
placed.push(...upRows, ...downRows);
outward = Math.max(Math.abs(upEnd), Math.abs(downEnd)) + RANK_GAP;
}
return placed;
}
const placed = [...layoutRanks(heavy, 0), ...layoutRanks(light, REAR_DEPTH)];
const maxDepth = placed.reduce((m, p) => Math.max(m, p.depth), 0);
const maxSpreadAbs = placed.reduce((m, p) => Math.max(m, Math.abs(p.spread)), 0);
return { placed, maxDepth, maxSpreadAbs };
}
// Speed Swarm (Brian: "large ships in the rear, fanning out smaller and
// smaller ships towards the center... a tall arch that promotes all the
// smaller ships getting towards the center around the same time from as
// wide an angle as possible"): hulls are grouped into size bands (same
// hullId = same avoidRadius = same band), sorted biggest to smallest, and
// swept along an eased curve from rear-and-narrow (biggest) to
// front-and-wide (smallest) — the smallest, fastest hulls end up BOTH
// closest to the front AND furthest out to the sides, so despite starting
// on a wider, longer diagonal they're positioned to reach the centerline
// at roughly the same time as the slower heavies coming straight up the
// middle from further back.
function layoutSpeedSwarm(ships, C2) {
const groups = groupByHull(ships)
.sort((a, b) => (b[0].design?.hull?.sizeScale ?? 1) - (a[0].design?.hull?.sizeScale ?? 1));
const K = groups.length;
const rearDepth = C2.beamRange * 4;
// Capped against world height HERE, not by scaling final coordinates
// after the fact (see placeFleets — that approach was tried first and
// is provably unsafe: uniformly shrinking every ship's spread shrinks
// pairs that were already sitting at the guaranteed-safe minimum
// distance right along with everything else, silently reopening the
// exact overlap this whole placement rewrite exists to prevent).
// Bounding the FAN's own magnitude before anyone gets placed means the
// per-ship minimum-spacing term below never needs correcting later. No
// center-gap floor here — that requirement is horizontal (see
// CENTER_GAP_FRACTION), enforced once in placeFleets, not per-formation.
const maxSpreadMag = Math.min(C2.beamRange * 6, C2.worldHeight * 0.35);
const placed = [];
groups.forEach((group, bandIdx) => {
// 0 = biggest/rear band, 1 = smallest/front band. A single-hull-type
// fleet (K=1) has no size variation to fan by, so there's nothing to
// anchor it at the full-rear extreme for — that just needlessly pushes
// every ship (including a lone frigate with no bigger hulls behind it
// at all) back to `rearDepth` regardless of fleet size, so its
// footprint barely grows with count the way every other placement
// shape does. Midpoint is the sane default when there's only one band.
const t = K > 1 ? bandIdx / (K - 1) : 0.5;
// Eased, not linear, so the curve stays flat and narrow near the rear
// (heavies don't need to fan out at all) and sweeps increasingly wide
// and forward only as hulls actually get smaller — a proper arch, not
// a straight diagonal line.
const eased = Math.sin((t * Math.PI) / 2);
const bandDepth = rearDepth * (1 - eased);
const bandSpreadMag = maxSpreadMag * eased;
const avoidRadius = group[0].avoidRadius;
const cell = avoidRadius * 2 * CELL_MARGIN;
group.forEach((s, i) => {
const arm = i % 2 === 0 ? 1 : -1; // alternate left/right of centerline
const stepIndex = Math.floor(i / 2);
// `(stepIndex + 0.5) * cell` is the part that guarantees real
// per-ship separation — it's added to, never multiplied by,
// `bandSpreadMag`, specifically because the rear-most band's
// `bandSpreadMag` is 0 BY DESIGN (the biggest hulls stay near the
// centerline). Multiplying `arm` across the whole sum collapsed to
// zero there, leaving same-band ships up to ~2 units apart (pure
// wobble) instead of a full cell width — caught by a same-side
// ship-overlap smoke test (worst-case ratio 0.00, an exact double
// placement) before this ever reached the verifier.
const spread = arm * (bandSpreadMag + (stepIndex + 0.5) * cell) + placementWobble(s) * 0.3;
const depth = bandDepth + stepIndex * cell * 0.2; // slight stagger so a big band doesn't sit on one exact depth line
placed.push({ ship: s, depth, spread });
});
});
const maxDepth = placed.reduce((m, p) => Math.max(m, p.depth), 0);
const maxSpreadAbs = placed.reduce((m, p) => Math.max(m, Math.abs(p.spread)), 0);
return { placed, maxDepth, maxSpreadAbs };
}
function layoutFormation(rules, ships) {
if (ships.length === 0) return { placed: [], maxDepth: 0, maxSpreadAbs: 0 };
const strategy = ships[0].formationStrategy;
if (strategy === 'power_pressure') return layoutPowerPressure(ships, rules.combatV2);
if (strategy === 'speed_swarm') return layoutSpeedSwarm(ships, rules.combatV2);
// No formation stamped (shouldn't happen — createBattle always resolves
// one — but fall back to Power Pressure's shape rather than crash).
return layoutPowerPressure(ships, rules.combatV2);
} }
// Places both fleets at once (not per-side) because the gap BETWEEN them has // Places both fleets at once (not per-side) because the gap BETWEEN them has
@ -225,7 +441,7 @@ function placeFleet(rules, ships, footprint, originX) {
// large world, which defeats "start the camera zoomed to fit however many // large world, which defeats "start the camera zoomed to fit however many
// ships are actually here" (see VegaCombatCamera.js's fitBounds) — the // ships are actually here" (see VegaCombatCamera.js's fitBounds) — the
// bounding box was dominated by the fixed margins, not the fleets. Instead, // bounding box was dominated by the fixed margins, not the fleets. Instead,
// the gap is `GAP_MULT`× the larger fleet's own width (floored at MIN_GAP so // the gap is `GAP_MULT`× the larger fleet's own depth (floored at MIN_GAP so
// even a lone ship gets room to use missile range before beam range), and // even a lone ship gets room to use missile range before beam range), and
// the whole attacker-gap-defender span is centered in the world — so a // the whole attacker-gap-defender span is centered in the world — so a
// small battle opens as a small, tight, centered cluster the camera can // small battle opens as a small, tight, centered cluster the camera can
@ -249,28 +465,63 @@ function placeFleet(rules, ships, footprint, originX) {
// health, having never reached the enemy — got swept into a total, // health, having never reached the enemy — got swept into a total,
// all-ships withdrawal on one side. Clamping the gap so the whole fleet // all-ships withdrawal on one side. Clamping the gap so the whole fleet
// actually has time to engage fixed it; see docs/mastervega-build-plan.md. // actually has time to engage fixed it; see docs/mastervega-build-plan.md.
//
// There's deliberately NO analogous post-hoc clamp on vertical spread here.
// One was tried — uniformly scaling every ship's `spread` down to fit a
// world-height budget — and it's provably unsafe: a pair of ships placed
// at exactly their guaranteed-safe minimum distance gets scaled down right
// along with everything else, silently reopening the exact overlap this
// whole placement rewrite exists to prevent (caught by the same-side
// ship-overlap smoke test: two battleships landed exactly on top of each
// other once the arc's frigates pushed `maxSpreadAbs` past the clamp
// threshold). `layoutSpeedSwarm` instead bounds its own fan magnitude
// against `worldHeight` BEFORE any ship is placed, so the guaranteed
// per-ship spacing term never needs correcting after the fact — see its
// comment. A pathologically large fleet can still exceed the nominal
// world box slightly; that's fine, the camera fits to the ships' actual
// bounding box (VegaCombatCamera), not a fixed rectangle.
export function placeFleets(rules, attackerShips, defenderShips) { export function placeFleets(rules, attackerShips, defenderShips) {
const a = fleetFootprint(rules, attackerShips); const a = layoutFormation(rules, attackerShips);
const d = fleetFootprint(rules, defenderShips); const d = layoutFormation(rules, defenderShips);
const worldW = rules.combatV2.worldWidth; const worldW = rules.combatV2.worldWidth;
const worldH = rules.combatV2.worldHeight;
const maxSpan = worldW * 0.85; const maxSpan = worldW * 0.85;
let gap = Math.max(MIN_GAP, GAP_MULT * Math.max(a.gridW, d.gridW, 1)); let gap = Math.max(MIN_GAP, GAP_MULT * Math.max(a.maxDepth, d.maxDepth, 1));
if (a.gridW + gap + d.gridW > maxSpan) { if (a.maxDepth + gap + d.maxDepth > maxSpan) {
gap = Math.max(200, maxSpan - a.gridW - d.gridW); gap = Math.max(200, maxSpan - a.maxDepth - d.maxDepth);
} }
const totalW = a.gridW + gap + d.gridW; // Brian: "the middle 40% of the [horizontal] screen empty at the
// beginning of a battle" — a hard floor on the front-line-to-front-line
// gap, applied AFTER the maxSpan clamp above so it wins even when a big
// fleet's own depth would otherwise have squeezed the gap smaller. Safe
// to let this push the total span past maxSpan: per-formation depth no
// longer grows with fleet size the way the old uniform grid did (ranks/
// bands now stack along spread, not depth), so maxDepth stays small and
// bounded regardless of fleet size — this floor can't runaway the way
// GAP_MULT's proportional term could. The camera fits to the ships'
// actual bounding box either way (VegaCombatCamera), not a fixed rect.
gap = Math.max(gap, worldW * CENTER_GAP_FRACTION);
const totalW = a.maxDepth + gap + d.maxDepth;
const startX = (worldW - totalW) / 2; const startX = (worldW - totalW) / 2;
const defenderStartX = startX + a.gridW + gap; const attackerFrontX = startX + a.maxDepth;
const defenderStartX = attackerFrontX + gap;
const centerY = worldH / 2;
placeFleet(rules, attackerShips, a, startX); for (const { ship, depth, spread } of a.placed) {
placeFleet(rules, defenderShips, d, defenderStartX); ship.x = attackerFrontX - depth;
ship.y = centerY + spread;
}
for (const { ship, depth, spread } of d.placed) {
ship.x = defenderStartX + depth;
ship.y = centerY + spread;
}
return { return {
attackerStartX: startX, attackerStartX: startX,
attackerEndX: startX + a.gridW, attackerEndX: attackerFrontX,
defenderStartX, defenderStartX,
defenderEndX: defenderStartX + d.gridW, defenderEndX: defenderStartX + d.maxDepth,
centerY: rules.combatV2.worldHeight / 2, centerY,
}; };
} }
@ -312,8 +563,16 @@ export function createBattle(rules, opts) {
const defenderFormation = isFormationStrategy(defender.formationStrategy) const defenderFormation = isFormationStrategy(defender.formationStrategy)
? defender.formationStrategy : randomFormationStrategy(rnd); ? defender.formationStrategy : randomFormationStrategy(rnd);
const aShips = sideShips(rules, attacker.empire, attacker.ships, 'attacker', seqCounter, attackerFormation); // Cap BEFORE expansion into per-ship objects — an oversized fleet-line
const dShips = sideShips(rules, defender.empire, defender.ships, 'defender', seqCounter, defenderFormation); // list never even gets expanded for the overflow portion. See
// MAX_SIMULATED_SHIPS_PER_SIDE's comment. `simulated`/`overflow` empty
// out to a no-op ({simulated: fleetLines, overflow: []}) for the vast
// majority of battles that never approach the cap.
const aCapped = capFleetLines(attacker.ships, MAX_SIMULATED_SHIPS_PER_SIDE);
const dCapped = capFleetLines(defender.ships, MAX_SIMULATED_SHIPS_PER_SIDE);
const aShips = sideShips(rules, attacker.empire, aCapped.simulated, 'attacker', seqCounter, attackerFormation);
const dShips = sideShips(rules, defender.empire, dCapped.simulated, 'defender', seqCounter, defenderFormation);
const layout = placeFleets(rules, aShips, dShips); const layout = placeFleets(rules, aShips, dShips);
// A defended colony fights as an extra immobile entity that cannot be // A defended colony fights as an extra immobile entity that cannot be
@ -350,14 +609,43 @@ export function createBattle(rules, opts) {
dShips.push(planet); dShips.push(planet);
} }
// Fixed centering anchor for computeCenteringForce — see its comment for
// why this is computed once here (from the actual starting layout, planet
// included) rather than derived from a generic world-size fraction or
// recomputed live as the battle plays out.
const allEntities = [...aShips, ...dShips];
let centerX = C2.worldWidth / 2;
let centerY = C2.worldHeight / 2;
if (allEntities.length) {
let sumX = 0;
let sumY = 0;
for (const e of allEntities) { sumX += e.x; sumY += e.y; }
centerX = sumX / allEntities.length;
centerY = sumY / allEntities.length;
}
let centeringComfortRadius = 0;
for (const e of allEntities) {
centeringComfortRadius = Math.max(centeringComfortRadius, Math.hypot(e.x - centerX, e.y - centerY));
}
centeringComfortRadius *= CENTERING_SLACK;
return { return {
rules, C: rules.combat, C2, rnd, starIdx, colony, rules, C: rules.combat, C2, rnd, starIdx, colony,
centerX, centerY, centeringComfortRadius,
attackerIdx: attacker.empireIdx, attackerIdx: attacker.empireIdx,
defenderIdx: defender.empireIdx, defenderIdx: defender.empireIdx,
attackerName: attacker.name ?? 'Attacker', attackerName: attacker.name ?? 'Attacker',
defenderName: defender.name ?? 'Defender', defenderName: defender.name ?? 'Defender',
attackerTraits: attacker.empire.traits ?? {}, attackerTraits: attacker.empire.traits ?? {},
defenderTraits: defender.empire.traits ?? {}, defenderTraits: defender.empire.traits ?? {},
// Full empire objects (not just traits) kept around ONLY so
// battleResult() can build a V1 mini-battle for the overflow, if any —
// see capFleetLines above. `[]` overflow (the common case) means these
// are never touched.
attackerEmpire: attacker.empire,
defenderEmpire: defender.empire,
attackerOverflow: aCapped.overflow,
defenderOverflow: dCapped.overflow,
attackerFormation, attackerFormation,
defenderFormation, defenderFormation,
ships: [...aShips, ...dShips], ships: [...aShips, ...dShips],
@ -469,24 +757,119 @@ function angularStep(facing, omega, desired, maxOmega, accel, dt) {
// "don't literally overlap" cushion rather than a real collision. // "don't literally overlap" cushion rather than a real collision.
const TARGET_AVOID_FRACTION = 0.35; const TARGET_AVOID_FRACTION = 0.35;
function computeSeparation(s, allLiving, excludeUid) { // Spatial-grid cell size for computeSeparation's neighbor lookup. Must be at
// least as large as the largest possible avoidRadius PAIR-SUM in the game,
// so a 3x3 block of cells around a ship's own cell is guaranteed to contain
// every ship it could possibly need to avoid — a ship in a cell more than 1
// away is already farther than any real minDist, by construction. Derived
// from data/mastervega-rules.json: max sizeScale is battleship's 2.5,
// avoidRadius = sizeScale * separationUnit (70) = 175 per ship, 350 for two
// battleships summed; 400 leaves headroom. Adding a bigger hull or raising
// separationUnit means revisiting this — section 11's spatial-grid
// equivalence check would catch a stale value first (it compares this
// grid's output against brute-force for a real mixed fleet).
const SEPARATION_CELL_SIZE = 400;
// Found necessary, not by guessing, but by measuring a real self-play soak:
// this loop is O(ships²) in a flat scan (34 ships = 1,156 pairs, cheap even
// unbucketed — but a real self-play soak produced a single battle with 278
// ships at one star, 77,284 pairs, and a single game turn stalling for over
// 46 SECONDS). The squared-distance early-out (still present per-pair below)
// cuts the COST of examining a far-apart pair, but not the NUMBER of pairs
// examined — that's still O(n²) in raw iteration count regardless. Bucketing
// ships into a uniform grid and only scanning a ship's own cell + its 8
// neighbors turns this into roughly O(n) for any reasonably-spread battle,
// which is what actually fixes the large-battle stall.
//
// NOT used unconditionally, though — building a Map (string keys, per-tick
// allocation) has a real constant-factor cost of its own, and MEASURING it
// (not assuming grid-is-always-better) found it actually LOSES to the plain
// flat scan at small ship counts, because the O(n²) scan is already cheap
// there and the grid's per-tick Map-building overhead dominates instead —
// forcing every battle through the grid unconditionally made small/typical
// battles measurably slower, not faster. (Exact crossover point is machine-
// and-load sensitive to pin precisely — this dev box runs a busy desktop
// alongside Node, so absolute ms figures vary run to run; the qualitative
// result, flat wins small and grid wins big by a wide margin, reproduced
// consistently.) `advance()` picks flat vs. grid once per tick based on
// `SEPARATION_GRID_THRESHOLD`, set well below where grid could plausibly
// help — most real battles stay on the always-simple flat path; only a
// genuinely huge melee (the actual failure case: a real self-play soak
// produced a single 278-ship battle that took 24-46+ SECONDS to resolve
// pre-fix, ~1.6-2s post-fix) pays the grid's overhead, which is exactly
// where it pays for itself many times over.
const SEPARATION_GRID_THRESHOLD = 60;
export function buildSeparationGrid(ships) {
const grid = new Map();
for (const s of ships) {
const key = `${Math.floor(s.x / SEPARATION_CELL_SIZE)},${Math.floor(s.y / SEPARATION_CELL_SIZE)}`;
let bucket = grid.get(key);
if (!bucket) { bucket = []; grid.set(key, bucket); }
bucket.push(s);
}
return grid;
}
// Two completely separate functions, not one with an internal branch on
// neighbor-source type. `advance()` decides which one to call ONCE per tick
// (`SEPARATION_GRID_THRESHOLD`), not per ship, so keeping each function
// small and single-purpose costs nothing at the dispatch level and leaves
// no ambiguity for a future reader about which code path a given battle
// size takes. A change to the avoidance math needs to land in both.
export function computeSeparationFlat(s, ships, excludeUid) {
let sepX = 0; let sepX = 0;
let sepY = 0; let sepY = 0;
for (const other of allLiving) { for (const other of ships) {
if (other === s) continue; if (other === s) continue;
const dx = s.x - other.x; const dx = s.x - other.x;
const dy = s.y - other.y; const dy = s.y - other.y;
const dist = Math.hypot(dx, dy);
const minDist = (s.avoidRadius + other.avoidRadius) * (other.uid === excludeUid ? TARGET_AVOID_FRACTION : 1); const minDist = (s.avoidRadius + other.avoidRadius) * (other.uid === excludeUid ? TARGET_AVOID_FRACTION : 1);
if (dist > 1e-6 && dist < minDist) { // Squared-distance reject BEFORE any sqrt — most pairs in a spread-out
// battle aren't within avoidance range at any given moment, so this
// skips the sqrt (and the rest of the per-pair math) for the common
// case rather than just cheapening it.
const distSq = dx * dx + dy * dy;
if (distSq >= minDist * minDist) continue;
const dist = Math.sqrt(distSq);
if (dist > 1e-6) {
const t = (minDist - dist) / minDist; const t = (minDist - dist) / minDist;
const strength = t * t; const strength = t * t;
const rx = dx / dist; const rx = dx / dist;
const ry = dy / dist; const ry = dy / dist;
const px = -ry; sepX += rx * strength + -ry * strength * 0.7;
const py = rx; sepY += ry * strength + rx * strength * 0.7;
sepX += rx * strength + px * strength * 0.7; }
sepY += ry * strength + py * strength * 0.7; }
return { x: sepX, y: sepY };
}
export function computeSeparationGrid(s, grid, excludeUid) {
let sepX = 0;
let sepY = 0;
const cx = Math.floor(s.x / SEPARATION_CELL_SIZE);
const cy = Math.floor(s.y / SEPARATION_CELL_SIZE);
for (let gx = cx - 1; gx <= cx + 1; gx += 1) {
for (let gy = cy - 1; gy <= cy + 1; gy += 1) {
const bucket = grid.get(`${gx},${gy}`);
if (!bucket) continue;
for (const other of bucket) {
if (other === s) continue;
const dx = s.x - other.x;
const dy = s.y - other.y;
const minDist = (s.avoidRadius + other.avoidRadius) * (other.uid === excludeUid ? TARGET_AVOID_FRACTION : 1);
const distSq = dx * dx + dy * dy;
if (distSq >= minDist * minDist) continue;
const dist = Math.sqrt(distSq);
if (dist > 1e-6) {
const t = (minDist - dist) / minDist;
const strength = t * t;
const rx = dx / dist;
const ry = dy / dist;
sepX += rx * strength + -ry * strength * 0.7;
sepY += ry * strength + rx * strength * 0.7;
}
}
} }
} }
return { x: sepX, y: sepY }; return { x: sepX, y: sepY };
@ -499,6 +882,46 @@ function computeSeparation(s, allLiving, excludeUid) {
// sits. // sits.
const BRAKE_SPEED_EPS = 2; const BRAKE_SPEED_EPS = 2;
// Gentle pull back toward roughly where the battle started (Brian: battles
// "look great at the beginning and then drift off screen towards the end").
// The camera pans once, at battle start, to frame the fleets' own starting
// footprint and never re-fits afterward (VegaCombatCamera.js's bindZoomPan)
// — so once pursuit/strafing/chasing survivors carries the fight far enough
// from where it began, it walks itself off the visible viewport with
// nothing pulling it back.
//
// The anchor is the STARTING centroid of every entity in the battle
// (`battle.centerX/centerY`, computed once in createBattle, never
// recomputed live) with a radius (`battle.centeringComfortRadius`) sized to
// the farthest any entity actually started from that centroid, times a
// small slack factor. This is deliberately NOT a live centroid recomputed
// every tick — that would only tighten how tightly ships cluster around
// wherever the fight currently is, it wouldn't stop the fight's location
// itself from translating, since a live centroid is by definition already
// wherever everyone currently is. Anchoring to the fixed starting point (and
// sizing the comfort radius to the battle's OWN starting spread, not a
// guessed fraction of world size) is also what keeps this invisible for a
// centered, freshly-placed battle regardless of formation or fleet size —
// Power Pressure's front/rear depth split and the trap-39 horizontal
// center-gap both spread ships out from world-center by design at t=0, and
// a generic fixed-fraction comfort radius could easily undercut that and
// immediately fight the placement this session already tuned.
const CENTERING_SLACK = 1.2;
function computeCenteringForce(s, center) {
if (!center || center.radius <= 0) return { x: 0, y: 0 };
const dx = center.x - s.x;
const dy = center.y - s.y;
// Squared-distance reject first, same reasoning as computeSeparation —
// this runs once per ship per tick, and most ships are inside the
// comfort radius (no force) most of the time.
if (dx * dx + dy * dy <= center.radius * center.radius) return { x: 0, y: 0 };
const dist = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(1, (dist - center.radius) / center.radius);
const strength = t * t;
return { x: (dx / dist) * strength, y: (dy / dist) * strength };
}
// Computes one tick's movement for a ship WITHOUT mutating it (banked for // Computes one tick's movement for a ship WITHOUT mutating it (banked for
// the same reason as before — see the long-standing comment on // the same reason as before — see the long-standing comment on
// computeSeparation and the advance() call site: reading every other ship's // computeSeparation and the advance() call site: reading every other ship's
@ -553,15 +976,69 @@ const BRAKE_SPEED_EPS = 2;
// still use the target's true position. // still use the target's true position.
const GOLDEN_ANGLE = 2.399963229728653; // radians; irrational turn fraction, spaces points evenly const GOLDEN_ANGLE = 2.399963229728653; // radians; irrational turn fraction, spaces points evenly
function computeShipMove(s, target, allLiving, dt, C2) { // Lead pursuit: predicts WHERE a moving target will be, not just where it
// is right now. Without this, a ship steers at its target's current
// position every tick — fine for a stationary target, but against a
// moving one (especially one pursuing a DIFFERENT ship entirely, so its
// motion has nothing to do with evading this shooter) a slow-turning hull
// can never actually close the gap: by the time it turns to head where the
// target WAS, the target has moved on, so it's perpetually chasing a point
// that keeps receding — visually indistinguishable from the target
// fleeing, even though neither ship is doing anything but pursuing its own
// unrelated business. Caught via a 2-battleship-per-side trace: one ship
// closed to 253 units of a target it was never actually going to catch,
// then watched the gap open back out to 1076 as the target's own
// (unrelated) pursuit carried it away again.
//
// Classic pursuit-intercept algebra: solve for the smallest positive t
// where a straight line from the shooter, covering distance `speed*t`,
// meets the target's projected position `targetPos + targetVel*t`. Squaring
// the distance equation gives a quadratic in t (`a*t^2 + b*t + c = 0`);
// degenerates to linear when the target's speed exactly matches the
// shooter's assumed closing speed (a≈0). No valid positive root means the
// target is unreachable on a straight course at this speed (moving away
// faster than the shooter can ever close, most commonly) — fall back to
// the target's current position, i.e. exactly today's plain pursuit, so
// this never produces worse aim than before, only better when an intercept
// actually exists. `shooterSpeed` uses the hull's max `effSpeed` (not
// current, possibly-still-accelerating speed) — a stable, standard
// approximation, not a physically exact solve accounting for the shooter's
// own turn/accel curve.
function predictIntercept(sx, sy, shooterSpeed, tx, ty, tvx, tvy) {
const px = tx - sx;
const py = ty - sy;
const a = tvx * tvx + tvy * tvy - shooterSpeed * shooterSpeed;
const b = 2 * (px * tvx + py * tvy);
const c = px * px + py * py;
let t = null;
if (Math.abs(a) < 1e-6) {
if (Math.abs(b) > 1e-6) {
const candidate = -c / b;
if (candidate > 1e-6) t = candidate;
}
} else {
const disc = b * b - 4 * a * c;
if (disc >= 0) {
const sqrtDisc = Math.sqrt(disc);
const t1 = (-b + sqrtDisc) / (2 * a);
const t2 = (-b - sqrtDisc) / (2 * a);
const positive = [t1, t2].filter((cand) => cand > 1e-6).sort((x, y) => x - y);
if (positive.length) [t] = positive;
}
}
if (t === null) return { x: tx, y: ty };
return { x: tx + tvx * t, y: ty + tvy * t };
}
function computeShipMove(s, target, separationFn, neighbors, dt, C2, center) {
const dx = target.x - s.x; const dx = target.x - s.x;
const dy = target.y - s.y; const dy = target.y - s.y;
const dist = Math.hypot(dx, dy); const dist = Math.sqrt(dx * dx + dy * dy);
const toTargetAngle = dist > 1e-6 ? Math.atan2(dy, dx) : s.facing; const toTargetAngle = dist > 1e-6 ? Math.atan2(dy, dx) : s.facing;
// Task direction: where the ship WANTS to thrust this tick. `hasTask` is // Task direction: where the ship WANTS to thrust this tick. `hasTask` is
// false only when already stopped and in range (nothing to do but hold). // false only when already stopped and in range (nothing to do but hold).
const speed = Math.hypot(s.vx, s.vy); const speed = Math.sqrt(s.vx * s.vx + s.vy * s.vy);
const inRange = dist <= C2.beamRange; const inRange = dist <= C2.beamRange;
const braking = inRange && speed > BRAKE_SPEED_EPS; const braking = inRange && speed > BRAKE_SPEED_EPS;
const retrogradeAngle = speed > BRAKE_SPEED_EPS ? wrapAngle(Math.atan2(s.vy, s.vx) + Math.PI) : null; const retrogradeAngle = speed > BRAKE_SPEED_EPS ? wrapAngle(Math.atan2(s.vy, s.vx) + Math.PI) : null;
@ -571,13 +1048,19 @@ function computeShipMove(s, target, allLiving, dt, C2) {
taskAngle = retrogradeAngle; taskAngle = retrogradeAngle;
hasTask = true; hasTask = true;
} else if (!inRange) { } else if (!inRange) {
// Aim at where the target is HEADING, not where it is right now — see
// predictIntercept's comment. `dist`/`inRange`/`toTargetAngle` above
// deliberately still use the target's TRUE current position (that's
// what actually determines weapon range and the braking trigger); only
// the approach steering aim point uses the prediction.
const lead = predictIntercept(s.x, s.y, s.effSpeed, target.x, target.y, target.vx ?? 0, target.vy ?? 0);
const approachAngle = wrapAngle(s.seq * GOLDEN_ANGLE); const approachAngle = wrapAngle(s.seq * GOLDEN_ANGLE);
const orbitR = s.avoidRadius + (target.avoidRadius ?? 0); const orbitR = s.avoidRadius + (target.avoidRadius ?? 0);
const aimX = target.x + Math.cos(approachAngle) * orbitR; const aimX = lead.x + Math.cos(approachAngle) * orbitR;
const aimY = target.y + Math.sin(approachAngle) * orbitR; const aimY = lead.y + Math.sin(approachAngle) * orbitR;
const adx = aimX - s.x; const adx = aimX - s.x;
const ady = aimY - s.y; const ady = aimY - s.y;
const aimDist = Math.hypot(adx, ady); const aimDist = Math.sqrt(adx * adx + ady * ady);
taskAngle = aimDist > 1e-6 ? Math.atan2(ady, adx) : toTargetAngle; taskAngle = aimDist > 1e-6 ? Math.atan2(ady, adx) : toTargetAngle;
hasTask = true; hasTask = true;
} }
@ -644,7 +1127,7 @@ function computeShipMove(s, target, allLiving, dt, C2) {
// parked, so an already-stopped ship still looks sensible) — this is // parked, so an already-stopped ship still looks sensible) — this is
// what makes turn rate gate thrust efficiency below, instead of being // what makes turn rate gate thrust efficiency below, instead of being
// purely cosmetic. // purely cosmetic.
const sep = computeSeparation(s, allLiving, target.uid); const sep = separationFn(s, neighbors, target.uid);
const baseX = Math.cos(facingBaseAngle); const baseX = Math.cos(facingBaseAngle);
const baseY = Math.sin(facingBaseAngle); const baseY = Math.sin(facingBaseAngle);
const combinedX = baseX + C2.separationWeight * sep.x; const combinedX = baseX + C2.separationWeight * sep.x;
@ -686,7 +1169,18 @@ function computeShipMove(s, target, allLiving, dt, C2) {
vx += sep.x * C2.separationWeight * C2.avoidAccel * dt; vx += sep.x * C2.separationWeight * C2.avoidAccel * dt;
vy += sep.y * C2.separationWeight * C2.avoidAccel * dt; vy += sep.y * C2.separationWeight * C2.avoidAccel * dt;
} }
const newSpeed = Math.hypot(vx, vy); // Same shape as avoidance directly above: its own acceleration budget
// (not the hull's tactical linearAccel, so it can't be starved by a
// low-thrust hull), never alignment-gated (works even facing the wrong
// way), and purely additive so real task/avoidance thrust can always
// override it rather than fight a hard constraint. See
// computeCenteringForce's comment for why the anchor is fixed, not live.
const centering = computeCenteringForce(s, center);
if (centering.x !== 0 || centering.y !== 0) {
vx += centering.x * C2.centeringAccel * dt;
vy += centering.y * C2.centeringAccel * dt;
}
const newSpeed = Math.sqrt(vx * vx + vy * vy);
if (newSpeed > s.effSpeed) { if (newSpeed > s.effSpeed) {
const k = s.effSpeed / newSpeed; const k = s.effSpeed / newSpeed;
vx *= k; vx *= k;
@ -907,10 +1401,21 @@ export function advance(b, dt, { allowRetreat = true } = {}) {
} }
// --- Movement, banked (see computeShipMove()'s comment for why) --- // --- Movement, banked (see computeShipMove()'s comment for why) ---
const center = { x: b.centerX, y: b.centerY, radius: b.centeringComfortRadius };
// Both the neighbor data AND which separation function to call are
// decided ONCE per tick (not per ship) — see computeSeparationFlat's
// comment for why this is two dedicated functions rather than one
// branching on the data shape. Below SEPARATION_GRID_THRESHOLD,
// `livingAll` itself (already built above) is passed straight through as
// the flat-scan neighbor list — no grid, no extra allocation, since the
// plain O(ships²) scan is already the cheaper option at that size.
const useGrid = livingAll.length > SEPARATION_GRID_THRESHOLD;
const neighbors = useGrid ? buildSeparationGrid(livingAll) : livingAll;
const separationFn = useGrid ? computeSeparationGrid : computeSeparationFlat;
const moves = new Map(); const moves = new Map();
for (const s of livingAll) { for (const s of livingAll) {
if (s.target && !s.immobile && b.orders[s.uid] !== 'hold') { if (s.target && !s.immobile && b.orders[s.uid] !== 'hold') {
moves.set(s, computeShipMove(s, s.target, livingAll, dt, C2)); moves.set(s, computeShipMove(s, s.target, separationFn, neighbors, dt, C2, center));
} }
} }
for (const [s, mv] of moves) { for (const [s, mv] of moves) {
@ -1002,6 +1507,74 @@ export function runBattle(b, { allowRetreat = true } = {}) {
return battleResult(b); return battleResult(b);
} }
// Combines two {hullId, mark, ...}[] lists, summing `field` for matching
// hullId|mark pairs — used to fold the overflow's result (see
// resolveOverflow) into the simulated portion's own grouped totals.
// `field` is 'count' for survivor lists, 'lost' for loss lists — V1's
// battleResult uses that same split naming, confirmed by reading it
// directly rather than assuming.
function mergeLines(a, b, field) {
const map = new Map();
for (const line of a) map.set(`${line.hullId}|${line.mark}`, { ...line });
for (const line of b) {
const key = `${line.hullId}|${line.mark}`;
const existing = map.get(key);
if (existing) existing[field] += line[field];
else map.set(key, { ...line });
}
return [...map.values()];
}
// Ships capped out of full simulation (see MAX_SIMULATED_SHIPS_PER_SIDE)
// never got a real fight of their own if only ONE side had any overflow —
// there was no opposing overflow force for them to meet, so they simply
// survive. Only when BOTH sides overflow do they get resolved against each
// other, via V1's much cheaper aggregate engine, isolated from the
// simulated fight (no colony — the planet, if any, is a single entity that
// never gets split across simulated/overflow, it stays entirely in the
// main battle). Returns null when there's no overflow at all (the common
// case), so battleResult can skip the merge entirely.
function resolveOverflow(b) {
const aOver = b.attackerOverflow;
const dOver = b.defenderOverflow;
if (!aOver.length && !dOver.length) return null;
if (aOver.length && dOver.length) {
const ovBattle = createBattleV1(b.rules, {
attacker: {
empireIdx: b.attackerIdx, name: b.attackerName, empire: b.attackerEmpire, ships: aOver,
},
defender: {
empireIdx: b.defenderIdx, name: b.defenderName, empire: b.defenderEmpire, ships: dOver,
},
rnd: b.rnd,
});
return runBattleV1(ovBattle);
}
return {
attackerSurvivors: aOver.length ? aOver : [],
defenderSurvivors: dOver.length ? dOver : [],
attackerLosses: [],
defenderLosses: [],
};
}
const totalCount = (lines) => lines.reduce((t, l) => t + l.count, 0);
// If merging the overflow in leaves one side with zero total ships and the
// other with some, that side wins outright — this CAN flip the simulated
// portion's own winner (e.g. a wiped-out simulated force whose untouched
// reserve overflow still holds the field). Otherwise (both sides still
// have ships, or both end at zero) the actually-fought simulated portion
// is the most meaningful signal available, so it decides.
function decideWinner(attackerSurvivors, defenderSurvivors, simulatedWinner) {
const aCount = totalCount(attackerSurvivors);
const dCount = totalCount(defenderSurvivors);
if (aCount > 0 && dCount === 0) return 'attacker';
if (dCount > 0 && aCount === 0) return 'defender';
if (aCount === 0 && dCount === 0) return 'draw';
return simulatedWinner;
}
// Same output shape as VegaCombat.js's battleResult(), computed by grouping // Same output shape as VegaCombat.js's battleResult(), computed by grouping
// individual ships by (hullId, mark) instead of reading `.count` off a // individual ships by (hullId, mark) instead of reading `.count` off a
// stack. Keeping this shape identical means every consumer of a battle // stack. Keeping this shape identical means every consumer of a battle
@ -1024,18 +1597,38 @@ export function battleResult(b) {
return [...map.values()]; return [...map.values()];
}; };
let attackerSurvivors = grouped('attacker', true);
let defenderSurvivors = grouped('defender', true);
let attackerLosses = grouped('attacker', false);
let defenderLosses = grouped('defender', false);
let { winner } = b;
const overflowResult = resolveOverflow(b);
if (overflowResult) {
attackerSurvivors = mergeLines(attackerSurvivors, overflowResult.attackerSurvivors, 'count');
defenderSurvivors = mergeLines(defenderSurvivors, overflowResult.defenderSurvivors, 'count');
attackerLosses = mergeLines(attackerLosses, overflowResult.attackerLosses, 'lost');
defenderLosses = mergeLines(defenderLosses, overflowResult.defenderLosses, 'lost');
winner = decideWinner(attackerSurvivors, defenderSurvivors, b.winner);
}
return { return {
winner: b.winner, winner,
rounds: b.elapsed, rounds: b.elapsed,
starIdx: b.starIdx, starIdx: b.starIdx,
attackerIdx: b.attackerIdx, attackerIdx: b.attackerIdx,
defenderIdx: b.defenderIdx, defenderIdx: b.defenderIdx,
attackerSurvivors: grouped('attacker', true), attackerSurvivors,
defenderSurvivors: grouped('defender', true), defenderSurvivors,
attackerLosses: grouped('attacker', false), attackerLosses,
defenderLosses: grouped('defender', false), defenderLosses,
planetDefenseLeft: b.planet ? Math.max(0, b.planet.hp) : 0, planetDefenseLeft: b.planet ? Math.max(0, b.planet.hp) : 0,
planetDestroyed: b.planet ? b.planet.hp <= 0 : false, planetDestroyed: b.planet ? b.planet.hp <= 0 : false,
log: b.log, log: b.log,
// Reserve-ship counts, if any, so the view can disclose that ships
// beyond what was shown fighting also took part — see
// VegaCombatViewV2.js. `[]` overflow (the common case) means both are 0.
attackerOverflowCount: totalCount(b.attackerOverflow),
defenderOverflowCount: totalCount(b.defenderOverflow),
}; };
} }

View File

@ -30,6 +30,7 @@ import {
import { shipFrame } from './VegaArt.js'; import { shipFrame } from './VegaArt.js';
import { buildParallax, bindZoomPan } from './VegaCombatCamera.js'; import { buildParallax, bindZoomPan } from './VegaCombatCamera.js';
import { formationName } from './VegaFormations.js'; import { formationName } from './VegaFormations.js';
import { makeCommanderPortrait } from './VegaShipMedia.js';
// Same baseline the live view used for every hull uniformly; here it's // Same baseline the live view used for every hull uniformly; here it's
// multiplied by the firing ship's hull.sizeScale instead. // multiplied by the firing ship's hull.sizeScale instead.
@ -163,6 +164,29 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
}).setOrigin(0.5); }).setOrigin(0.5);
layer.add(formationText); layer.add(formationText);
// Reserve-fleet disclosure (see MAX_SIMULATED_SHIPS_PER_SIDE in
// VegaCombatV2.js) — a fleet too large to fully simulate has its excess
// resolved via V1's cheap aggregate math and folded into the final
// survivor/loss counts, so this note is what keeps those final numbers
// from silently disagreeing with what was actually shown fighting on
// screen. Only created at all when there's something to disclose — the
// vast majority of battles never trigger this.
const overflowParts = [];
if (battle.attackerOverflow?.length) {
const n = battle.attackerOverflow.reduce((t, l) => t + l.count, 0);
overflowParts.push(`${battle.attackerName}: +${n} reserve ships also engaged`);
}
if (battle.defenderOverflow?.length) {
const n = battle.defenderOverflow.reduce((t, l) => t + l.count, 0);
overflowParts.push(`${battle.defenderName}: +${n} reserve ships also engaged`);
}
if (overflowParts.length) {
const overflowText = scene.add.text(GAME_WIDTH / 2, 158, overflowParts.join(' • '), {
fontFamily: FONT, fontSize: '13px', color: '#c9a86a',
}).setOrigin(0.5);
layer.add(overflowText);
}
// Fleet-composition readout stands in for per-ship labels (which would // Fleet-composition readout stands in for per-ship labels (which would
// clutter fast at 5-20 ships a side) — a placeholder overlay, same as // clutter fast at 5-20 ships a side) — a placeholder overlay, same as
// ship placement itself; a real formation UI is deferred to a later pass. // ship placement itself; a real formation UI is deferred to a later pass.
@ -189,6 +213,128 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
defenderSummary.setText(summarize('defender')); defenderSummary.setText(summarize('defender'));
} }
// Ship-type commander roster along the bottom (Brian's ask): one
// portrait per UNIQUE hull type actually simulated on each side (not
// overflow — see MAX_SIMULATED_SHIPS_PER_SIDE in VegaCombatV2.js — those
// never appear on screen at all, so a video for a type that's never
// rendered would be confusing), fixed for the whole battle (the roster
// is who STARTED fighting, not who's currently left — a wiped type's
// slot stays, it just goes dark, see below). Ordered smallest-hull-first
// outward from that side's own screen corner (attacker starts bottom-
// left, defender bottom-right), growing toward the horizontal center as
// `hull.sizeScale` increases — literally "smallest at the edge, biggest
// toward the middle," per Brian's spec. Icon size itself stays uniform
// across hulls (only POSITION encodes size) so a scout's portrait stays
// just as readable as a battleship's.
const ROSTER_ICON_SIZE = 76;
const ROSTER_ICON_GAP = 18;
const ROSTER_EDGE_MARGIN = 40;
const ROSTER_Y = GAME_HEIGHT - 150;
function uniqueHullsFor(side) {
const seen = new Set();
const out = [];
for (const s of battle.ships) {
if (s.side !== side || s.isPlanet || seen.has(s.hullId)) continue;
seen.add(s.hullId);
out.push(s.hullId);
}
out.sort((a, b) => (rules.hulls[a]?.sizeScale ?? 1) - (rules.hulls[b]?.sizeScale ?? 1));
return out;
}
const rosterLayer = scene.add.container(0, 0);
layer.add(rosterLayer);
const rosterSlots = [];
const snowTimers = [];
let snowSeq = 0;
// A tiny procedurally-generated "signal lost" static loop — no video
// asset needed. One canvas texture per wiped slot (not shared) so
// several lost commanders in the same battle don't flicker in lockstep,
// the way real uncorrelated static wouldn't. NEAREST filtering (not the
// engine default LINEAR) keeps it a crisp blocky noise pattern instead
// of blurring into grey mush.
function createSnowTexture() {
const key = `vega-combat-snow-${battle.starIdx}-${snowSeq += 1}`;
if (scene.textures.exists(key)) scene.textures.remove(key);
const size = 32;
const tex = scene.textures.createCanvas(key, size, size);
tex.setFilter(0); // Phaser.Textures.FilterMode.NEAREST
const ctx = tex.getContext();
const imgData = ctx.createImageData(size, size);
const refresh = () => {
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
tex.refresh();
};
refresh();
return { key, refresh };
}
function buildRoster() {
for (const side of ['attacker', 'defender']) {
const hulls = uniqueHullsFor(side);
const dir = side === 'attacker' ? 1 : -1;
const startX = side === 'attacker' ? ROSTER_EDGE_MARGIN : GAME_WIDTH - ROSTER_EDGE_MARGIN;
const speciesId = side === 'attacker' ? attackerSpecies : defenderSpecies;
hulls.forEach((hullId, i) => {
const x = startX + dir * (i * (ROSTER_ICON_SIZE + ROSTER_ICON_GAP) + ROSTER_ICON_SIZE / 2);
const c = scene.add.container(x, ROSTER_Y);
const ring = scene.add.graphics();
const ringColor = side === 'attacker' ? 0x9fd8ff : 0xffb0a0;
ring.lineStyle(2, ringColor, 0.8);
ring.strokeCircle(0, 0, ROSTER_ICON_SIZE / 2 + 3);
const label = scene.add.text(0, ROSTER_ICON_SIZE / 2 + 12, rules.hulls[hullId]?.name ?? hullId, {
fontFamily: FONT, fontSize: '12px', color: '#8fa8c0',
}).setOrigin(0.5, 0);
const slot = {
side, hullId, container: c, portrait: null, ring, label, wiped: false,
};
const portrait = makeCommanderPortrait(scene, rules, art, speciesId, hullId, 0, 0, ROSTER_ICON_SIZE,
(replacement) => { if (!slot.wiped) slot.portrait = replacement; });
slot.portrait = portrait;
c.add([portrait, ring, label]);
rosterLayer.add(c);
rosterSlots.push(slot);
});
}
}
function wipeRosterSlot(slot) {
if (slot.wiped) return;
slot.wiped = true;
slot.portrait.destroy();
const snow = createSnowTexture();
const img = scene.add.image(0, 0, snow.key).setDisplaySize(ROSTER_ICON_SIZE, ROSTER_ICON_SIZE);
slot.container.addAt(img, 0);
slot.portrait = img;
slot.ring.clear();
slot.ring.lineStyle(2, 0x555555, 0.8);
slot.ring.strokeCircle(0, 0, ROSTER_ICON_SIZE / 2 + 3);
slot.label.setColor('#5a5a5a');
slot.label.setText(`${rules.hulls[slot.hullId]?.name ?? slot.hullId} — LOST`);
const timer = scene.time.addEvent({ delay: 90, loop: true, callback: snow.refresh });
snowTimers.push(timer);
}
// Same "alive" definition syncMarkers already uses (hp>0 && !retreated)
// — a retreated ship isn't in the battle any more either, so its hull
// type goes dark the same as a destroyed one once none are left.
function refreshRoster() {
for (const slot of rosterSlots) {
if (slot.wiped) continue;
const stillFighting = battle.ships.some((s) => (
s.side === slot.side && s.hullId === slot.hullId && !s.isPlanet && s.hp > 0 && !s.retreated
));
if (!stillFighting) wipeRosterSlot(slot);
}
}
const markers = new Map(); const markers = new Map();
let lastSummaryAlive = -1; let lastSummaryAlive = -1;
@ -335,6 +481,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
} }
roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`); roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`);
syncMarkers(); syncMarkers();
refreshRoster();
if (battle.done) { if (battle.done) {
scene.events.off('update', onUpdate); scene.events.off('update', onUpdate);
scene.time.delayedCall(500, finish); scene.time.delayedCall(500, finish);
@ -344,6 +491,21 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
function teardown() { function teardown() {
scene.events.off('update', onUpdate); scene.events.off('update', onUpdate);
// Snow-static timers are a scene.time resource, not tied to any game
// object's own lifecycle — layer.destroy() below tears down the
// canvas-texture Images that display them, but the ticking refresh()
// calls that write into those textures keep firing forever otherwise,
// the same class of leak flagged for every other scene.time/input/
// events registration in this file. Textures are removed too, since
// `?movsim` and the real game both replay this view many times in one
// scene session and a leaked canvas texture per wiped ship type would
// accumulate across every battle played.
for (const timer of snowTimers) timer.remove();
for (const slot of rosterSlots) {
if (!slot.wiped) continue;
const key = slot.portrait.texture?.key;
if (key && scene.textures.exists(key)) scene.textures.remove(key);
}
camera.destroy(); camera.destroy();
fx.destroy(); fx.destroy();
layer.destroy(); layer.destroy();
@ -362,6 +524,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
scene.events.off('update', onUpdate); scene.events.off('update', onUpdate);
runBattle(battle); runBattle(battle);
syncMarkers(); syncMarkers();
refreshRoster();
roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`); roundText.setText(`t = ${battle.elapsed.toFixed(1)}s`);
finish(); finish();
}), { width: 220, height: 52 }); }), { width: 220, height: 52 });
@ -378,6 +541,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
buildMarkers(); buildMarkers();
syncMarkers(); syncMarkers();
buildRoster();
return { return {
layer, layer,
destroy: teardown, destroy: teardown,

View File

@ -14,7 +14,14 @@
import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js'; import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { techCost, techCostFactor } from './VegaRules.js'; import { techCost, techCostFactor } from './VegaRules.js';
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js'; import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
import { createBattle, runBattle, resolveInvasion } from './VegaCombat.js'; // Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only
// prototype) — Brian's explicit call, after V2's momentum/collision-avoidance/
// formation work matured and its per-tick cost was brought down (see
// docs/mastervega-build-plan.md's "V2 becomes the official combat engine"
// entry). resolveInvasion (ground combat) is untouched — it has no
// battle-shaped dependency on either engine.
import { createBattle, runBattle } from './VegaCombatV2.js';
import { resolveInvasion } from './VegaCombat.js';
import { breakStalemate, declareWar, runGalaxyDiplomacyPass } from './VegaDiplomacy.js'; import { breakStalemate, declareWar, runGalaxyDiplomacyPass } from './VegaDiplomacy.js';
export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research']; export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research'];
@ -1299,9 +1306,9 @@ function applyBattleLosses(rules, state, starIdx, e, survivors) {
} }
// Build the battle object for a pair at a system, WITHOUT resolving it. // Build the battle object for a pair at a system, WITHOUT resolving it.
// Split out of fightAt so the player can drive a battle round by round through // Split out of fightAt so the player can drive a battle tick by tick through
// VegaCombatView and then hand the outcome back — the interactive battle and // VegaCombatViewV2 and then hand the outcome back — the interactive battle
// auto-resolve therefore run the same engine and cannot diverge. // and auto-resolve therefore run the same engine and cannot diverge.
export function prepareBattleAt(rules, state, starIdx, a, b) { export function prepareBattleAt(rules, state, starIdx, a, b) {
const colony = colonyAt(state, starIdx); const colony = colonyAt(state, starIdx);
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b; const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;

View File

@ -12,7 +12,7 @@ import {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost, empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
} from './VegaLogic.js'; } from './VegaLogic.js';
import { import {
attitudeOf, moodOf, powerOf, canNegotiate, attitudeOf, moodOf, powerOf, canNegotiate, declareWar, makePeace,
} from './VegaDiplomacy.js'; } from './VegaDiplomacy.js';
import { leaderOffers } from './VegaLeaders.js'; import { leaderOffers } from './VegaLeaders.js';
@ -199,19 +199,48 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
const bx = shell.body.x + shell.body.w - 150; const bx = shell.body.x + shell.body.w - 150;
const canSeek = canNegotiate(rules, state, e, other.idx); const canSeek = canNegotiate(rules, state, e, other.idx);
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => { // A diplomacy-incapable species (Lithox: traits.diplomacy <= -100, same
shell.destroy(); // check canNegotiate itself makes) can never be negotiated with, so
// Same per-race ducking runAudienceQueue does for an AI-initiated // "Seek Audience" would just sit permanently disabled — no way to ever
// audience (MasterOfVegaGame.js) — only drop back to peace on close if // go to war OR back out of one. This replaces it with a direct,
// nothing else is queued up behind this one. // unilateral toggle for exactly that case (Brian's ask, after finding
scene.music?.setDiplomacy(other.speciesId); // the player had no path to war with Lithox at all): declareWar/
openAudienceScreen(scene, rules, state, e, other.idx, art, () => { // makePeace are the same functions the Audience screen's own Declare
if (!scene.pendingAudiences?.length) scene.music?.setDiplomacy(null); // War / accepted Sue for Peace already call, reused as-is rather than
onClose?.(); // inventing a parallel relationship state — this only changes WHO can
}, onChanged); // trigger them and how (instantly, no negotiation), not what they do.
}), { width: 200, height: 40 }); const incapable = (spec.traits.diplomacy ?? 0) <= -100;
if (!canSeek) b.setEnabled(false); if (incapable) {
shell.add(b); const atWarNow = treaty === 'war';
const wb = new Button(scene, bx, y + rowH / 2 - 20,
atWarNow ? 'Cease Hostilities' : 'Declare War', uiClick(scene, () => {
if (atWarNow) makePeace(rules, state, e, other.idx);
else declareWar(rules, state, e, other.idx);
// Tear down and reopen THIS screen with the same onClose/onChanged
// — not calling onClose itself — so the player stays on the
// diplomacy list and immediately sees the updated stance/status
// line, instead of the whole modal closing the way clicking Seek
// Audience does.
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}), { width: 200, height: 40, variant: atWarNow ? 'ghost' : 'solid' });
shell.add(wb);
} else {
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', uiClick(scene, () => {
shell.destroy();
// Same per-race ducking runAudienceQueue does for an AI-initiated
// audience (MasterOfVegaGame.js) — only drop back to peace on close if
// nothing else is queued up behind this one.
scene.music?.setDiplomacy(other.speciesId);
openAudienceScreen(scene, rules, state, e, other.idx, art, () => {
if (!scene.pendingAudiences?.length) scene.music?.setDiplomacy(null);
onClose?.();
}, onChanged);
}), { width: 200, height: 40 });
if (!canSeek) b.setEnabled(false);
shell.add(b);
}
}); });
return shell; return shell;
} }

View File

@ -39,7 +39,6 @@ const ROW_BG = 0x142238;
// fallback ladder), a building just its one icon. // fallback ladder), a building just its one icon.
const MEDIA_SIZE = 72; const MEDIA_SIZE = 72;
const HULL_ICON_SIZE = 52; const HULL_ICON_SIZE = 52;
const BUILDING_MEDIA_W = 90;
// Event types whose row gets a "View Star System" shortcut — anything // Event types whose row gets a "View Star System" shortcut — anything
// personal to a single one of the human's own stars. // personal to a single one of the human's own stars.
@ -96,17 +95,16 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
cy += headline.height + 10; cy += headline.height + 10;
if (isOpen) { if (isOpen) {
const linesTop = cy;
const isBuilding = ev.type === 'buildingDone'; const isBuilding = ev.type === 'buildingDone';
const isShip = ev.type === 'shipDone'; const isShip = ev.type === 'shipDone';
// A ship's media block now sits below its text (full-width synopsis); // Both a building's icon and a ship's hull icon + commander
// only a building's icon still sits beside its text, so only that // portrait now sit below their (full-width) synopsis text,
// case needs to reserve wrap width off to the side. // left-aligned to match the text above them — neither needs to
const mediaW = isBuilding ? BUILDING_MEDIA_W : 0; // reserve wrap width off to the side any more.
for (const ln of desc.lines) { for (const ln of desc.lines) {
const t = scene.add.text(16, cy, ln.text, { const t = scene.add.text(16, cy, ln.text, {
fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc', fontFamily: FONT, fontSize: '15px', color: ln.color ?? '#9fb6cc',
wordWrap: { width: w - 46 - mediaW }, wordWrap: { width: w - 46 },
}); });
col.content.add(t); col.content.add(t);
cy += t.height + 6; cy += t.height + 6;
@ -162,18 +160,17 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
} }
} }
// A building's picture sits right-aligned beside its synopsis text // A building's picture sits below its synopsis text, left-aligned
// (mediaW reserved off the wrap width above keeps long descriptions // to match the text above it — same placement as a ship's hull
// from running under it). A ship's hull icon + commander portrait sit // icon + commander portrait just below.
// below its (full-width) synopsis text instead, left-aligned to match
// the text above them.
if (isBuilding) { if (isBuilding) {
cy = Math.max(cy, linesTop + MEDIA_SIZE); const mediaTop = cy;
const midY = linesTop + MEDIA_SIZE / 2; const midY = mediaTop + MEDIA_SIZE / 2;
const icon = scene.add.image( const icon = scene.add.image(
w - 8 - MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId), 16 + MEDIA_SIZE / 2, midY, scene.art.buildings, buildingFrame(rules, ev.buildingId),
).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE); ).setDisplaySize(MEDIA_SIZE, MEDIA_SIZE);
col.content.add(icon); col.content.add(icon);
cy = mediaTop + MEDIA_SIZE + 10;
} else if (isShip) { } else if (isShip) {
const mediaTop = cy; const mediaTop = cy;
const midY = mediaTop + MEDIA_SIZE / 2; const midY = mediaTop + MEDIA_SIZE / 2;

View File

@ -3073,6 +3073,52 @@ section('11. Combat V2 (per-ship prototype)');
maxDeltaSpeedPerTick < 20, `max Δspeed/tick=${maxDeltaSpeedPerTick.toFixed(1)}`); maxDeltaSpeedPerTick < 20, `max Δspeed/tick=${maxDeltaSpeedPerTick.toFixed(1)}`);
} }
// Lead pursuit: a ship chasing a target that is itself independently
// pursuing a DIFFERENT enemy (its motion has nothing to do with evading
// THIS shooter) is exactly the scenario that exposed pure pursuit's
// failure — a slow-turning hull steering at its target's CURRENT position
// every tick can get momentarily close, then watch the gap reopen as the
// target's own (unrelated) pursuit carries it away, forever — visually
// indistinguishable from the target fleeing. Before predictIntercept()
// existed, a 2-battleship-per-side trace showed exactly this: distance to
// a live, still-being-chased target reached 1076+ units and was still
// climbing after a full minute. Battleships (worst turn rate, so the
// richest case for this failure) are used deliberately here rather than
// a more agile hull.
{
const emp7 = mkEmpV2('human', 7);
// Formation pinned explicitly (matching both sides) — since strategy
// formations now shape initial placement (this session's later "strategy
// formations" work), leaving it unset let this resolve to a random,
// occasionally MISMATCHED pair of strategies between the two sides,
// making the test's starting geometry — and therefore whether it still
// demonstrates the lead-pursuit scenario at all — nondeterministic.
const b = CombatV2.createBattle(RULES, {
attacker: {
empireIdx: 0, name: 'a', empire: emp7, ships: [{ hullId: 'battleship', count: 2 }], formationStrategy: 'power_pressure',
},
defender: {
empireIdx: 1, name: 'd', empire: emp7, ships: [{ hullId: 'battleship', count: 2 }], formationStrategy: 'power_pressure',
},
rnd: mulberry32(3),
});
const s0 = b.ships[0];
const windowTicks = Math.round(60 / CombatV2.SIM_DT);
for (let i = 0; i < windowTicks; i += 1) {
CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false });
if (b.done || s0.hp <= 0) break;
}
// A dead shooter or a dead/retreated target both mean the battle
// resolved some other way — not a failure of this check either way,
// only a still-alive-and-still-chasing pair with the gap never closing
// is the regression this protects against.
const stillChasing = s0.hp > 0 && s0.target && s0.target.hp > 0 && !s0.target.retreated;
const finalDist = stillChasing ? Math.hypot(s0.x - s0.target.x, s0.y - s0.target.y) : 0;
check('a ship chasing a target that is itself pursuing someone else still closes distance over a full minute (lead, not pure, pursuit)',
!stillChasing || finalDist < 3 * RULES.combatV2.beamRange,
`finalDist=${finalDist.toFixed(0)} (beamRange=${RULES.combatV2.beamRange})`);
}
// Zoom ladder sanity check for V2's fixed world — mirrors section 3b's // Zoom ladder sanity check for V2's fixed world — mirrors section 3b's
// headless galaxy-zoom-ladder check. // headless galaxy-zoom-ladder check.
{ {
@ -3103,36 +3149,221 @@ section('11. Combat V2 (per-ship prototype)');
pickFitZoomIndex(zooms, 1280, 720, 500) <= pickFitZoomIndex(zooms, 1280, 720, 0)); pickFitZoomIndex(zooms, 1280, 720, 500) <= pickFitZoomIndex(zooms, 1280, 720, 0));
} }
// Placement: the gap between the two fleets must actually grow with fleet // Placement: the overall footprint must actually grow with fleet size
// size (this is what makes the adaptive initial zoom below mean anything — // (this is what makes the adaptive initial zoom below mean anything — a
// a fixed margin from the world's edges regardless of fleet size was tried // fixed margin from the world's edges regardless of fleet size was tried
// and rejected during this session specifically because it made the // and rejected during this session specifically because it made the
// camera's "fit the fleet" zoom barely vary with ship count at all). // camera's "fit the fleet" zoom barely vary with ship count at all).
//
// Both formation-shape checks here pin an EXPLICIT, matching
// formationStrategy on both sides — since formation strategy now
// genuinely shapes placement (this session's "strategy formations" work;
// previously it was pure plumbing, stamped onto ships but never read),
// leaving it unset lets each side resolve to a DIFFERENT formation
// silently, which showed up as flaky failures here: Power Pressure and
// Speed Swarm have different footprint shapes (front cluster + rear
// column vs. a wide arc), so a battle where the two sides happen to pick
// different strategies isn't symmetric even for identical fleets, and
// isn't a fair "did size affect the footprint" comparison either. Uses
// overall `shipBounds` width (both fleets plus the gap between them), not
// literally "gap," since Speed Swarm's footprint growth is often more
// about spread than depth.
{ {
const gapFor = (n) => { const boundsWidthFor = (n, formationStrategy) => {
const b = CombatV2.createBattle(RULES, { const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }] }, attacker: {
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }] }, empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }], formationStrategy,
},
defender: {
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: n }], formationStrategy,
},
rnd: mulberry32(1), rnd: mulberry32(1),
}); });
const a = b.ships.filter((s) => s.side === 'attacker'); const bounds = CombatV2.shipBounds(b.ships);
const d = b.ships.filter((s) => s.side === 'defender'); return bounds.maxX - bounds.minX;
return Math.min(...d.map((s) => s.x)) - Math.max(...a.map((s) => s.x));
}; };
const gap1 = gapFor(1); for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
const gap20 = gapFor(20); const width1 = boundsWidthFor(1, formationStrategy);
check('the fleet-to-fleet gap grows with fleet size', gap20 > gap1, `1-ship gap ${gap1}, 20-ship gap ${gap20}`); const width20 = boundsWidthFor(20, formationStrategy);
check(`${formationStrategy}: overall footprint width grows with fleet size`,
width20 > width1, `1-ship width ${width1.toFixed(0)}, 20-ship width ${width20.toFixed(0)}`);
}
check('both fleets stay centered in the world (attacker/defender spans are symmetric)', (() => { check('both fleets stay centered in the world (attacker/defender spans are symmetric)', (() => {
const b = CombatV2.createBattle(RULES, { const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 7 }] }, attacker: {
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 3 }] }, empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 7 }], formationStrategy: 'power_pressure',
},
defender: {
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 3 }], formationStrategy: 'power_pressure',
},
rnd: mulberry32(1), rnd: mulberry32(1),
}); });
const bounds = CombatV2.shipBounds(b.ships); const bounds = CombatV2.shipBounds(b.ships);
const worldCenter = RULES.combatV2.worldWidth / 2; const worldCenter = RULES.combatV2.worldWidth / 2;
const boundsCenter = (bounds.minX + bounds.maxX) / 2; const boundsCenter = (bounds.minX + bounds.maxX) / 2;
return Math.abs(boundsCenter - worldCenter) < 1; // Loosened from <1 to <20 — the strategy-formations placement rewrite
// added a small per-ship DEPTH wobble (±4 units, on top of the
// pre-existing spread wobble) specifically to break exact-tie
// engagement geometry between same-tier ships (see
// placementWobbleDepth's comment); a rear-most ship can now land a
// few units past the nominal `maxDepth` the centering math is based
// on. Sub-unit centering was never the actual invariant that
// mattered here — "roughly centered so the camera frames it well,"
// which this still checks — just happened to be exactly achievable
// before wobble existed on this axis.
return Math.abs(boundsCenter - worldCenter) < 20;
})()); })());
}
// Strategy formations actually shape placement (Brian's ask — previously
// pure plumbing, stamped onto ships but never read by anything). Power
// Pressure: heavier hulls form a compact front cluster well ahead of a
// single tall rear column of everyone else. Speed Swarm: hulls fan out
// along a size-banded arc, smallest/fastest both furthest forward and
// furthest to the sides, heaviest nearest the rear centerline.
{
const mixedFleet = [
{ hullId: 'frigate', count: 5 }, { hullId: 'destroyer', count: 4 },
{ hullId: 'cruiser', count: 3 }, { hullId: 'battleship', count: 2 },
];
const battleFor = (formationStrategy) => CombatV2.createBattle(RULES, {
attacker: {
empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy,
},
defender: {
empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 1 }], formationStrategy: 'power_pressure',
},
rnd: mulberry32(1),
});
{
const b = battleFor('power_pressure');
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
const heavy = attackerShips.filter((s) => ['cruiser', 'battleship'].includes(s.hullId));
const light = attackerShips.filter((s) => ['frigate', 'destroyer'].includes(s.hullId));
check('power_pressure: every heavy (cruiser/battleship) ship sits ahead of every light (frigate/destroyer) ship',
Math.min(...heavy.map((s) => s.x)) > Math.max(...light.map((s) => s.x)));
const lightXRange = Math.max(...light.map((s) => s.x)) - Math.min(...light.map((s) => s.x));
const lightYRange = Math.max(...light.map((s) => s.y)) - Math.min(...light.map((s) => s.y));
// A "vertical line in the rear" means the group must vary far more in
// world-Y (spread) than world-X (depth) — the group's members are
// stacked one above another, not one behind another. This was
// inverted until the center-gap rework: the original packColumn
// stacked ships along depth while holding spread ~flat, so the "rear
// column" was actually a horizontal smear sitting at screen-vertical
// center the whole time (see build-plan trap for the fix).
check('power_pressure: the rear light-ship group is a true vertical column (varies in Y far more than X), not a blob',
lightYRange > lightXRange, `depth range=${lightXRange.toFixed(0)} spread range=${lightYRange.toFixed(0)}`);
}
{
const b = battleFor('speed_swarm');
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
const centerY = RULES.combatV2.worldHeight / 2;
const heavy = attackerShips.filter((s) => ['cruiser', 'battleship'].includes(s.hullId));
const light = attackerShips.filter((s) => ['frigate', 'destroyer'].includes(s.hullId));
const avgSpread = (ships) => ships.reduce((t, s) => t + Math.abs(s.y - centerY), 0) / ships.length;
check('speed_swarm: light hulls fan out wider than heavy hulls',
avgSpread(light) > avgSpread(heavy), `heavy=${avgSpread(heavy).toFixed(0)} light=${avgSpread(light).toFixed(0)}`);
const avgX = (ships) => ships.reduce((t, s) => t + s.x, 0) / ships.length;
check('speed_swarm: light hulls sit further toward the front than heavy hulls, on average',
avgX(light) > avgX(heavy), `heavyAvgX=${avgX(heavy).toFixed(0)} lightAvgX=${avgX(light).toFixed(0)}`);
}
// Brian's ask: "the middle 40% of the [horizontal] screen empty at the
// beginning of a battle" — the two sides' front lines must be at least
// CENTER_GAP_FRACTION of worldWidth apart (a HORIZONTAL/depth-axis
// requirement — clarified after an initial version of this check
// mistakenly measured vertical/spread distance from the centerline
// instead). Also confirms both formations still make real use of
// vertical space for their columns/arcs (not everyone collapsed onto
// the horizontal centerline).
for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
const b = battleFor(formationStrategy);
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
const defenderShips = b.ships.filter((s) => s.side === 'defender');
const requiredGap = RULES.combatV2.worldWidth * 0.4;
const attackerFrontX = Math.max(...attackerShips.map((s) => s.x));
const defenderFrontX = Math.min(...defenderShips.map((s) => s.x));
const actualGap = defenderFrontX - attackerFrontX;
check(`${formationStrategy}: horizontal front-to-front gap clears 40% of world width (>= ${requiredGap.toFixed(0)})`,
actualGap >= requiredGap - 1, `actual gap=${actualGap.toFixed(0)}`);
const centerY = RULES.combatV2.worldHeight / 2;
const upper = attackerShips.filter((s) => s.y < centerY).length;
const lower = attackerShips.filter((s) => s.y > centerY).length;
check(`${formationStrategy}: both upper and lower wings are populated (not all shoved to one side)`,
upper > 0 && lower > 0, `upper=${upper} lower=${lower}`);
}
// Neither formation shape should let same-side ships start closer than
// their own combined avoidRadius — the exact class of bug the size-aware
// placement rewrite exists to prevent (caught during development: Speed
// Swarm's rear-most band multiplied its per-ship offset by a fan
// magnitude that's deliberately 0 there, collapsing two battleships onto
// the exact same point).
for (const formationStrategy of ['power_pressure', 'speed_swarm']) {
const b = battleFor(formationStrategy);
const attackerShips = b.ships.filter((s) => s.side === 'attacker');
let worstRatio = Infinity;
for (let i = 0; i < attackerShips.length; i += 1) {
for (let j = i + 1; j < attackerShips.length; j += 1) {
const s1 = attackerShips[i]; const s2 = attackerShips[j];
const dist = Math.hypot(s1.x - s2.x, s1.y - s2.y);
const ratio = dist / (s1.avoidRadius + s2.avoidRadius);
if (ratio < worstRatio) worstRatio = ratio;
}
}
check(`${formationStrategy}: no same-side ship pair starts inside each other's combined avoidRadius`,
worstRatio >= 1, `worst ratio=${worstRatio.toFixed(2)}`);
}
// Centering: battles "look great at the beginning and then drift off
// screen towards the end" (Brian) — the camera pans once, at battle
// start, and never re-fits (VegaCombatCamera.js), so a fight that
// wanders far enough from where it began walks itself off the visible
// viewport. computeCenteringForce/createBattle's centerX/centerY/
// centeringComfortRadius address this; see VegaCombatV2.js's comment
// for why the anchor is the battle's own FIXED starting centroid, not a
// live one recomputed every tick (a live centroid can't counteract net
// drift — it's defined as wherever everyone already is).
{
const withCenteringAccel = (accel) => {
const r = JSON.parse(JSON.stringify(rulesJson));
r.combatV2.centeringAccel = accel;
return compileRules(r);
};
const b = battleFor('power_pressure');
const worstStart = Math.max(...b.ships.map((s) => Math.hypot(s.x - b.centerX, s.y - b.centerY)));
check('every entity starts inside its own battle\'s centering comfort radius (zero force at t=0)',
worstStart <= b.centeringComfortRadius + 1e-6, `worst=${worstStart.toFixed(0)} radius=${b.centeringComfortRadius.toFixed(0)}`);
const maxDriftOverRun = (rules, seed) => {
const battle = CombatV2.createBattle(rules, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: mixedFleet, formationStrategy: 'speed_swarm' },
rnd: mulberry32(seed),
});
let worst = 0;
let i = 0;
while (!battle.done && i < 20000) {
CombatV2.advance(battle, 1 / 30, { allowRetreat: true });
i += 1;
for (const s of battle.ships) {
if (s.hp > 0 && !s.retreated) worst = Math.max(worst, Math.hypot(s.x - battle.centerX, s.y - battle.centerY));
}
}
return worst;
};
const rulesOff = withCenteringAccel(0);
const rulesOn = withCenteringAccel(RULES.combatV2.centeringAccel);
let offTotal = 0;
let onTotal = 0;
const N = 5;
for (let seed = 1; seed <= N; seed += 1) {
offTotal += maxDriftOverRun(rulesOff, seed);
onTotal += maxDriftOverRun(rulesOn, seed);
}
check(`centering reduces average max-drift-from-start across ${N} mixed-fleet seeds`,
onTotal < offTotal, `off avg=${(offTotal / N).toFixed(0)} on avg=${(onTotal / N).toFixed(0)}`);
}
// shipBounds must include a defended planet even with no defender ships // shipBounds must include a defended planet even with no defender ships
// at all — the exact scenario that broke when the planet was still // at all — the exact scenario that broke when the planet was still
@ -3166,6 +3397,177 @@ section('11. Combat V2 (per-ship prototype)');
check('a bigger battle opens more zoomed out than a smaller one', check('a bigger battle opens more zoomed out than a smaller one',
zoomIndexFor(20) <= zoomIndexFor(1), `1-ship idx ${zoomIndexFor(1)}, 20-ship idx ${zoomIndexFor(20)}`); zoomIndexFor(20) <= zoomIndexFor(1), `1-ship idx ${zoomIndexFor(1)}, 20-ship idx ${zoomIndexFor(20)}`);
} }
// Large-battle performance fix (real self-play soak produced a 278-ship
// battle that stalled a single game turn for 24-46+ SECONDS before this):
// computeSeparation dispatches to a brute-force flat scan below
// SEPARATION_GRID_THRESHOLD and a spatial grid above it. The grid's 3x3
// neighbor-cell lookup MUST produce the same avoidance force as the flat
// scan for every ship, including ships sitting exactly on a cell boundary
// (SEPARATION_CELL_SIZE=400) — that's exactly where a grid radius/lookup
// bug would silently miss a real neighbor and only show up in a rare,
// hard-to-eyeball large battle. Tested directly against synthetic ship
// sets (not a real battle) since it's pure geometry, independent of
// combat/targeting.
{
function mkTestShip(uid, x, y, avoidRadius) {
return { uid, x, y, avoidRadius };
}
function seededShips(n, seed) {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ships = [];
for (let i = 0; i < n; i += 1) {
ships.push(mkTestShip(`s${i}`, rnd() * 2000 - 1000, rnd() * 2000 - 1000, 40 + rnd() * 140));
}
// Deliberately straddle a grid cell edge (x=400) — the case most
// likely to expose a neighbor-lookup bug.
ships.push(mkTestShip('boundaryA', 399, 100, 175));
ships.push(mkTestShip('boundaryB', 401, 100, 175));
ships.push(mkTestShip('boundaryC', 400, 400, 175));
ships.push(mkTestShip('boundaryD', 401, 401, 175));
return ships;
}
let worstDiff = 0;
let checked = 0;
for (const seed of [1, 2, 3, 4, 5]) {
const ships = seededShips(120, seed);
const grid = CombatV2.buildSeparationGrid(ships);
for (const s of ships) {
const flat = CombatV2.computeSeparationFlat(s, ships, null);
const gridResult = CombatV2.computeSeparationGrid(s, grid, null);
worstDiff = Math.max(worstDiff, Math.hypot(flat.x - gridResult.x, flat.y - gridResult.y));
checked += 1;
}
}
check(`spatial-grid separation matches brute-force flat separation exactly (${checked} ships, incl. cell-boundary cases)`,
worstDiff < 1e-9, `worst diff=${worstDiff}`);
// Same check with excludeUid (own-target reduced-avoidance) set.
const ships = seededShips(80, 7);
const grid = CombatV2.buildSeparationGrid(ships);
let worstExclude = 0;
ships.forEach((s, i) => {
const excludeUid = ships[(i + 5) % ships.length].uid;
const flat = CombatV2.computeSeparationFlat(s, ships, excludeUid);
const gridResult = CombatV2.computeSeparationGrid(s, grid, excludeUid);
worstExclude = Math.max(worstExclude, Math.hypot(flat.x - gridResult.x, flat.y - gridResult.y));
});
check('spatial-grid matches flat with excludeUid (own-target reduced avoidance) set',
worstExclude < 1e-9, `worst diff=${worstExclude}`);
}
// The actual regression this whole fix exists for: a battle far larger
// than any hand-tuned scenario elsewhere in this suite must still resolve
// in a reasonable time, not the 24-46+ SECONDS measured pre-fix. Mirrors
// the exact scale (278 ships) a real self-play soak produced.
{
const megaFleet = [
{ hullId: 'frigate', count: 60 }, { hullId: 'destroyer', count: 40 },
{ hullId: 'cruiser', count: 25 }, { hullId: 'battleship', count: 14 },
];
const t0 = performance.now();
const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: megaFleet, formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: megaFleet, formationStrategy: 'speed_swarm' },
rnd: mulberry32(1),
});
CombatV2.runBattle(b, { allowRetreat: true });
const dt = performance.now() - t0;
check('a 278-ship battle resolves in well under a second on the grid path (was 24-46+ SECONDS pre-spatial-grid)',
dt < 5000, `${dt.toFixed(0)}ms`);
}
// Even the spatial grid degrades at real self-play's actual extremes:
// late-game wars (turn 400-500+) produced battles up to 790 ships at one
// star (16.9s worst case even WITH the grid) — a fixed 3600x2400 world
// gets dense enough that the grid's O(n) advantage erodes. Brian's fix:
// cap how many ships per side get full simulation
// (MAX_SIMULATED_SHIPS_PER_SIDE, VegaCombatV2.js), folding the rest in
// via V1's cheap aggregate math (resolveOverflow/battleResult).
{
const totalCount = (lines) => lines.reduce((t, l) => t + (l.count ?? 0), 0);
const totalLost = (lines) => lines.reduce((t, l) => t + (l.lost ?? 0), 0);
// Typical battles are completely unaffected — this is a rare-case
// safety valve, not a general behavior change.
{
const fleet = [{ hullId: 'destroyer', count: 8 }];
const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
rnd: mulberry32(1),
});
check('typical battle: no overflow at all', b.attackerOverflow.length === 0 && b.defenderOverflow.length === 0);
}
// Proportional sampling + exact cap on a fleet well over the threshold.
{
const fleet = [
{ hullId: 'frigate', count: 500 }, { hullId: 'destroyer', count: 200 },
{ hullId: 'cruiser', count: 80 }, { hullId: 'battleship', count: 20 },
];
const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 5 }], formationStrategy: 'power_pressure' },
rnd: mulberry32(2),
});
const simulatedCount = b.ships.filter((s) => s.side === 'attacker').length;
check('mega-fleet: exactly 100 attacker ships simulated (the per-side cap)', simulatedCount === 100, `got ${simulatedCount}`);
const overflowTotal = b.attackerOverflow.reduce((t, l) => t + l.count, 0);
check('mega-fleet: simulated + overflow == original total (no ships lost to rounding)',
simulatedCount + overflowTotal === 800, `simulated=${simulatedCount} overflow=${overflowTotal}`);
const frigateSim = b.ships.filter((s) => s.side === 'attacker' && s.hullId === 'frigate').length;
check('mega-fleet: hull-type mix preserved in the simulated sample (frigates ~62.5% of 100)',
Math.abs(frigateSim - 62.5) <= 1.5, `frigateSim=${frigateSim}`);
}
// The actual regression this fix exists for: a battle at real self-play's
// measured extreme (790 ships/side) must resolve fast AND conserve every
// ship (survivors + losses == original count, on both sides).
{
const fleet = [
{ hullId: 'frigate', count: 350 }, { hullId: 'destroyer', count: 250 },
{ hullId: 'cruiser', count: 140 }, { hullId: 'battleship', count: 50 },
];
const t0 = performance.now();
const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: fleet, formationStrategy: 'speed_swarm' },
rnd: mulberry32(3),
});
const result = CombatV2.runBattle(b, { allowRetreat: true });
const dt = performance.now() - t0;
check('a 790-ship battle (real self-play\'s measured worst case) resolves in well under 5s (was 16.9s pre-cap)',
dt < 5000, `${dt.toFixed(0)}ms`);
const aTotal = totalCount(result.attackerSurvivors) + totalLost(result.attackerLosses);
const dTotal = totalCount(result.defenderSurvivors) + totalLost(result.defenderLosses);
check('790-ship battle: every attacker ship accounted for (survivors+losses == 790, none silently vanish)',
aTotal === 790, `got ${aTotal}`);
check('790-ship battle: every defender ship accounted for (survivors+losses == 790, none silently vanish)',
dTotal === 790, `got ${dTotal}`);
}
// Winner-decision edge case: only one side overflows (the other's whole
// fleet fit under the cap) — the untouched reserve must survive intact
// and be able to flip the outcome even if the simulated skirmish alone
// wouldn't have decided it.
{
const b = CombatV2.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 6), ships: [{ hullId: 'frigate', count: 300 }], formationStrategy: 'power_pressure' },
defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('human', 6), ships: [{ hullId: 'destroyer', count: 3 }], formationStrategy: 'power_pressure' },
rnd: mulberry32(4),
});
const result = CombatV2.runBattle(b, { allowRetreat: true });
const aTotal = totalCount(result.attackerSurvivors) + totalLost(result.attackerLosses);
check('one-sided overflow: attacker\'s reserve ships are conserved (total stays 300)', aTotal === 300, `got ${aTotal}`);
check('one-sided overflow: attacker wins (simulated force plus an untouched reserve vs. 3 destroyers)',
result.winner === 'attacker', `winner=${result.winner}`);
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------