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:
parent
bfaee613c9
commit
c105ebd23c
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue