From 033912bc739d31294c98f736dc2eaf60542a8b26 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 15 Aug 2026 09:20:43 -0600 Subject: [PATCH] feat: redesign planetary defense as ammo model with range gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - defenseHp is now a pure per-battle ammo gauge (one full-strength shot per planetDefensePerShot points, remainder as fractional shot) instead of in-battle damage tracking; no longer written back after battle outcome - Planet now has a real range limit (planetRange, 1200) — previously the only entity with no range check at all - Per-shot damage scales with the defending empire's best known weapon tech (weaponAvgDmg) instead of a flat HP-proportion curve - defenseHp is only wiped to 0 when the planet is actually DESTROYED, fixing the bug where a defeated planet could be re-attacked multiple times in one turn with full strength each time - Added combat config: planetDefensePerShot (5), planetShotDamageMult (1) - Added combatV2 config: planetRange (1200) - Comprehensive tests for range gating, shot formula, ammo exhaustion, multi-battle-same-turn fix, and tech-scaled damage --- data/mastervega-rules.json | 7 +- src/games/mastervega/VegaCombatSim.js | 15 ++- src/games/mastervega/VegaCombatV2.js | 65 ++++++---- src/games/mastervega/VegaLogic.js | 26 +++- tools/verifyMasterOfVega.js | 163 ++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 25 deletions(-) diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 11c5e36..e2e3be9 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -498,7 +498,7 @@ }, "combat": { - "_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre. siegeFreezesDefense (default true) stops a colony's passive defenseHp trickle (and any defensive building at the front of its build queue) while a hostile warship fleet is present — set false to restore the old always-regen behaviour.", + "_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre. siegeFreezesDefense (default true) stops a colony's passive defenseHp trickle (and any defensive building at the front of its build queue) while a hostile warship fleet is present — set false to restore the old always-regen behaviour. planetDefensePerShot (5) and planetShotDamageMult (1) are VegaCombatV2.js-only: colony.defenseHp is read as an ammo count for that one battle (one full-strength shot per planetDefensePerShot points, any remainder firing as a single final fractional-strength shot), NOT as in-battle damage output any more, and it is never decremented by battle outcome — see applyBattleOutcome, which stops writing it back. planetShotDamageMult is a pure balance multiplier on top of the tech-scaled per-shot damage (VegaLogic.js's prepareBattleAt computes that from the defending empire's best known weapon). planetDefenseScale above is unaffected and continues to be read only by the V1 grid resolver (?movsim's Live toggle / resolveInvasion's dead-for-space-combat sibling). Brian's ask, 2026-08-15.", "gridCols": 12, "gridRows": 8, "maxRounds": 60, @@ -512,6 +512,8 @@ "retreatAfterRound": 1, "disengageRound": 25, "planetDefenseScale": 0.06, + "planetDefensePerShot": 5, + "planetShotDamageMult": 1, "bombardPopKill": 0.22, "bombardFactoryKillMult": 1, "bombardBuildingDestroyMult": 1, @@ -522,13 +524,14 @@ "siegeFreezesDefense": true }, - "_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.", + "_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. planetRange (1200, ~1/3 of the fully-zoomed-out visible battlefield width, 2x missileRange, 5.5x beamRange) is a new range limit for planetary defenses' own firing, gated in the tick loop exactly the way a ship's mountsMaxRange gates it — before this, the planet fired at any distance, the only entity in the battle with no range check at all. Brian's ask, 2026-08-15.", "combatV2": { "worldWidth": 3600, "worldHeight": 2400, "moveUnitsPerSpeed": 25, "beamRange": 220, "missileRange": 600, + "planetRange": 1200, "turnSeconds": 2, "turnRateScale": 0.25, "spinUpSeconds": 1.5, diff --git a/src/games/mastervega/VegaCombatSim.js b/src/games/mastervega/VegaCombatSim.js index 2a0bf3e..37f8745 100644 --- a/src/games/mastervega/VegaCombatSim.js +++ b/src/games/mastervega/VegaCombatSim.js @@ -25,7 +25,7 @@ import { Button } from './VegaButton.js'; import { queueGameAssets } from '../../services/assetLoader.js'; import { compileRules, markNumeral } from './VegaRules.js'; import { ensureSheets } from './VegaArt.js'; -import { designFor, stackPower } from './VegaShips.js'; +import { designFor, stackPower, bestComponents } from './VegaShips.js'; import { createBattle, runBattle } from './VegaCombat.js'; import { openCombatView } from './VegaCombatView.js'; import * as CombatV2 from './VegaCombatV2.js'; @@ -220,7 +220,18 @@ export default class VegaCombatSim extends Phaser.Scene { empire: { known: knownForMark(this.rules, d.mark), traits: this.rules.species[d.species]?.traits ?? {} }, ships: this.battleType === 'planet' ? [] : this.buildSideShips(d), }; - const colony = this.battleType === 'planet' ? { defenseHp: d.defenseHp, shieldBonus: d.shieldBonus } : null; + // weaponAvgDmg mirrors VegaLogic.js's prepareBattleAt — this dev tool + // calls CombatV2.createBattle directly (not through prepareBattleAt), so + // it has to independently supply the same tech-scaled per-shot damage + // source or the planet silently deals zero damage. Reuses the exact + // `known` map already built above for defender.empire.known. + const defWeapon = this.battleType === 'planet' + ? bestComponents(this.rules, knownForMark(this.rules, d.mark)).weapon : null; + const colony = this.battleType === 'planet' ? { + defenseHp: d.defenseHp, + shieldBonus: d.shieldBonus, + weaponAvgDmg: defWeapon ? (defWeapon.min + defWeapon.max) / 2 : 0, + } : null; return { attacker, defender, colony }; } diff --git a/src/games/mastervega/VegaCombatV2.js b/src/games/mastervega/VegaCombatV2.js index a8fe6b9..8875f0b 100644 --- a/src/games/mastervega/VegaCombatV2.js +++ b/src/games/mastervega/VegaCombatV2.js @@ -586,6 +586,33 @@ export function createBattle(rules, opts) { // (placeFleets), so worldWidth is that side's edge. let planet = null; if (colony && colony.defenseHp > 0) { + // Ammo model (Brian's ask, 2026-08-15): defenseHp is no longer spent as + // in-battle damage-dealt tracking (see applyBattleOutcome in + // VegaLogic.js — it's never written back post-battle any more). Instead + // it's a pure "how many shots do we have this fight" gauge: every + // planetDefensePerShot points of defenseHp is one full-strength shot, + // with any remainder firing as one final FRACTIONAL-strength shot + // rather than being dropped or rounded away (e.g. defenseHp=1 fires a + // single 20%-strength shot; defenseHp=100 fires 20 full-strength + // shots). Once shotsLeft hits 0 the planet goes silent for the rest of + // the battle — the same shape a ship's missile rack running dry already + // has via salvoesLeft — it does NOT keep firing at ever-smaller damage. + // + // Per-shot damage (full strength) mirrors exactly what a warship of the + // DEFENDING empire's best currently-known weapon would deal — same + // avg(min,max) * damageMultiplier math fireMounts() uses for a real + // weapon hit (colony.weaponAvgDmg is computed in VegaLogic.js's + // prepareBattleAt via empireComponents) — so planetary defense scales + // with weapons tech exactly like a ship's own guns do, not off a + // separate hand-tuned curve. planetShotDamageMult is a pure balance + // knob on top (default 1 = exactly matches a warship-equivalent hit). + const perShot = rules.combat.planetDefensePerShot ?? 5; + const shotMult = rules.combat.planetShotDamageMult ?? 1; + const totalShots = Math.ceil(colony.defenseHp / perShot); + // Computed FROM totalShots (not defenseHp % perShot) so an exact + // multiple of perShot correctly yields strength 1 (a full last shot), + // not 0. + const remainder = colony.defenseHp - (totalShots - 1) * perShot; planet = { uid: 'planet', seq: (seqCounter.next += 1), @@ -605,18 +632,10 @@ export function createBattle(rules, opts) { angularAccel: 0, avoidRadius: C2.separationUnit * 3, immobile: true, - // Pure proportional — no flat floor. The old formula (a flat +20 base - // plus only +0.05/HP) meant a colony at 1/100 defenseHp and one at a - // fully-teched-out 1800+/1800 both hit for roughly the same ~20-25 - // damage, comparable to a tier 5-6 weapon, regardless of how depleted - // its defences actually were — reported as "attacked with the - // strength of a much-better-defended planet" (Brian, 2026-08-14). - // Scaling purely off current HP means a near-dead colony now barely - // scratches anything, a fresh untechnologied one (100 cap) hits at an - // early-weapon-tier ~6, and a maxed-out one (~1848 cap, every - // planetaryShield/defense-building stacked) still tops out around 110 - // — same ceiling the old formula had, just actually earned. - damage: colony.defenseHp * rules.combat.planetDefenseScale, + shotDamage: (colony.weaponAvgDmg ?? 0) * C2.damageMultiplier * shotMult, + totalShots, + shotsLeft: totalShots, + finalShotStrength: remainder / perShot, salvoesLeft: new Map(), retreated: false, target: null, @@ -1449,12 +1468,18 @@ export function advance(b, dt, { allowRetreat = true } = {}) { if (!s.target || s.target.hp <= 0) continue; if (s.isPlanet) { - if (s.cooldown == null) s.cooldown = turnSeconds; - s.cooldown -= dt; - if (s.cooldown <= 0) { - const dmg = Math.max(0, s.damage - s.target.shield) * (0.75 + b.rnd() * 0.5); - queueDamage(b, s.target, dmg, events, s); - s.cooldown += turnSeconds; + if (s.shotsLeft <= 0) continue; // out of ammo — silent for the rest of the fight + const pDist = dist2D(s, s.target); + if (pDist <= C2.planetRange + RANGE_EPS) { + if (s.cooldown == null) s.cooldown = turnSeconds; + s.cooldown -= dt; + if (s.cooldown <= 0) { + const strength = s.shotsLeft === 1 ? s.finalShotStrength : 1; + const dmg = Math.max(0, s.shotDamage * strength - s.target.shield) * (0.75 + b.rnd() * 0.5); + queueDamage(b, s.target, dmg, events, s); + s.shotsLeft -= 1; + s.cooldown += turnSeconds; + } } continue; } @@ -1481,8 +1506,8 @@ export function advance(b, dt, { allowRetreat = true } = {}) { const aLeft = living(b, 'attacker'); const dLeft = living(b, 'defender'); - const aArmed = aLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet); - const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet); + const aArmed = aLeft.some((s) => (s.design?.damage ?? 0) > 0 || (s.isPlanet && s.shotsLeft > 0)); + const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || (s.isPlanet && s.shotsLeft > 0)); // A simultaneous wipe-out must be checked before either single-side case, // or it silently falls into "attacker has none left" and is scored as a diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 9a5e392..842f500 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1619,6 +1619,14 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = // .planetaryshield's shieldBonus:5) was silently never applied to a real // battle, only ever exercised through the standalone ?movsim simulator's // manual stepper — found while wiring the shield ring into the view. + // Best-known-weapon avg damage for the defending empire — the same + // (min+max)/2 shape VegaShips.js's own weapon-ranking uses — rides along + // so a planet's per-shot power scales with weapons tech exactly like a + // warship's own guns would (Brian's explicit ask over a flat value, + // 2026-08-15): VegaCombatV2.js's createBattle() turns this into the + // planet's shotDamage. + const defWeapon = defColony ? empireComponents(rules, state, defColony.empireIdx).weapon : null; + const defWeaponAvgDmg = defWeapon ? (defWeapon.min + defWeapon.max) / 2 : 0; const battle = createBattle(rules, { attacker, defender, @@ -1626,6 +1634,7 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = defenseHp: defColony.defenseHp, shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'), typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId, + weaponAvgDmg: defWeaponAvgDmg, } : null, starIdx, rnd: () => rand(state), @@ -1638,7 +1647,22 @@ export function applyBattleOutcome(rules, state, prepared, result) { const { starIdx, attackerIdx, defenderIdx, colony } = prepared; applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors); applyBattleLosses(rules, state, starIdx, defenderIdx, result.defenderSurvivors); - if (colony) colony.defenseHp = result.planetDefenseLeft; + // colony.defenseHp is a pure ammo gauge for the NEXT battle (governed + // otherwise only by the normal production trickle / siegeFreezesDefense + // economy) — a battle's outcome never partially drains it the way it used + // to. But if the planet was outright DESTROYED this battle (its in-battle + // hp reduced to 0 — the defending fleet, if any, plus the planet's own + // ammo were not enough to see off the whole attacking fleet), its + // batteries are wiped out for real, not just this one fight: without this, + // every separate battle at the same star this same turn (multiple + // attacking fleets, or several AI empires' turns before the human's) would + // find defenseHp untouched and re-fight a "fresh" fully-armed planet each + // time, which is what produced the reported "an empty planet gets attacked + // 6+ times in one turn" — prepareBattleAt/pendingBattlesFor only refuse a + // FUTURE battle once defenseHp is actually 0 (Brian's ask, 2026-08-15). + // Surviving (hp > 0) still means defenseHp is left completely alone, same + // as before. + if (colony && result.planetDestroyed) colony.defenseHp = 0; const loser = result.winner === 'attacker' ? defenderIdx : attackerIdx; if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser); diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index cc154ee..d0ceb77 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -4771,6 +4771,169 @@ section('11. Combat V2 (per-ship prototype)'); planetNoShield.shield === 0, `${planetNoShield.shield}`); } + // Planetary defense range + ammo model (Brian's ask, 2026-08-15): the + // planet now has a real weapon-range limit (planetRange) for the first + // time — previously it fired at any distance, the only entity in the + // battle exempt from a range check — and colony.defenseHp is read as a + // pure per-battle ammo count (one full-strength shot per + // planetDefensePerShot points, remainder firing as one final fractional + // shot) rather than being spent as in-battle damage output; it is no + // longer written back to the persisted colony after the battle (see + // VegaLogic.js's applyBattleOutcome). + { + const mkPlanetOnly = (defenseHp, weaponAvgDmg = 10, seed = 1) => CombatV2.createBattle(RULES, { + attacker: { empireIdx: 0, name: 'a', empire: mkEmpV2('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] }, + defender: { empireIdx: 1, name: 'd', empire: mkEmpV2('kkrix', 5), ships: [] }, + colony: { defenseHp, shieldBonus: 0, weaponAvgDmg }, + rnd: mulberry32(seed), + }); + + // --- range: no damage while out of range, damage starts once in range. + { + const b = mkPlanetOnly(100); + const planet = b.planet; + check('a defended-colony-only battle still produces a planet entity', !!planet); + const [mover] = b.ships.filter((s) => !s.isPlanet); + mover.x = planet.x - (RULES.combatV2.planetRange + 400); + mover.y = planet.y; + b.orders[mover.uid] = 'hold'; + const outOfRangeTicks = Math.round(10 / CombatV2.SIM_DT); + for (let i = 0; i < outOfRangeTicks && !b.done; i += 1) CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false }); + check('a target beyond planetRange takes zero damage from planetary defense, even across many ticks', + mover.hp === mover.hpMax, `${mover.hp}/${mover.hpMax}`); + + mover.x = planet.x - (RULES.combatV2.planetRange - 50); + const inRangeTicks = Math.round(5 / CombatV2.SIM_DT); + for (let i = 0; i < inRangeTicks && !b.done; i += 1) CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false }); + check('moving the same target inside planetRange lets planetary defense start damaging it', + mover.hp < mover.hpMax, `${mover.hp}/${mover.hpMax}`); + } + + // --- exact shot-count / fractional-last-shot formula. + { + const perShot = RULES.combat.planetDefensePerShot ?? 5; + const cases = [ + { defenseHp: 1, shots: 1, strength: 1 / perShot }, + { defenseHp: 34, shots: 7, strength: 4 / perShot }, + { defenseHp: 100, shots: 20, strength: 1 }, + { defenseHp: 99, shots: 20, strength: 4 / perShot }, + { defenseHp: 101, shots: 21, strength: 1 / perShot }, + ]; + for (const c of cases) { + const p = mkPlanetOnly(c.defenseHp).planet; + check(`defenseHp=${c.defenseHp} yields totalShots=${c.shots}`, p.totalShots === c.shots, `${p.totalShots}`); + check(`defenseHp=${c.defenseHp} yields finalShotStrength ~= ${c.strength.toFixed(2)}`, + Math.abs(p.finalShotStrength - c.strength) < 1e-9, `${p.finalShotStrength}`); + } + } + + // --- ammo exhaustion: fires exactly totalShots times, never more, even + // over a very long battle, then stays silent. + { + const b = mkPlanetOnly(12, 10, 7); // perShot=5 -> totalShots=3 + const planet = b.planet; + const [mover] = b.ships.filter((s) => !s.isPlanet); + // Both sides made effectively unkillable within this test, so neither + // dying (and ending the battle) confounds "did ammo alone silence it". + mover.hp = 1e6; mover.hpMax = 1e6; + planet.hp = 1e6; planet.hpMax = 1e6; + mover.x = planet.x - 100; mover.y = planet.y; // well inside range + b.orders[mover.uid] = 'hold'; + const maxTicks = Math.round(300 / CombatV2.SIM_DT); // far more than totalShots * turnSeconds could ever need + for (let i = 0; i < maxTicks && !b.done; i += 1) CombatV2.advance(b, CombatV2.SIM_DT, { allowRetreat: false }); + const fireEvents = b.log.filter((e) => e.kind === 'fire' && e.from === 'planet').length; + check('the planet fires exactly totalShots times, never more, even over a very long battle', + fireEvents === planet.totalShots, `${fireEvents} vs totalShots=${planet.totalShots}`); + check('shotsLeft reaches exactly 0 once ammo is exhausted', planet.shotsLeft === 0, `${planet.shotsLeft}`); + } + + // --- colony.defenseHp is left alone by a battle the planet SURVIVES + // (still a pure ammo gauge, unaffected by damage taken), but is wiped to + // 0 permanently the moment the planet is actually DESTROYED in battle — + // otherwise every separate battle at the same star in the same turn + // would re-fight a "fresh" fully-armed planet, which is what produced + // the reported "an empty planet gets attacked 6+ times in one turn" + // (Brian's ask, 2026-08-15). + { + const st = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'cluster', seed: 909, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + st.rules = RULES; + const attackerIdx = 0; + const defenderIdx = 1; + const defColony = st.colonies.find((c) => c.empireIdx === defenderIdx); + const starIdx = defColony.starIdx; + Diplo.declareWar(RULES, st, attackerIdx, defenderIdx); + + defColony.defenseHp = 150; + Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'frigate', mark: 1, count: 1 }]); + const before1 = defColony.defenseHp; + const prepared1 = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {}); + check('a battle prepares for the survives-case fixture', !!prepared1); + if (prepared1) { + Logic.applyBattleOutcome(RULES, st, prepared1, CombatV2.runBattle(prepared1.battle)); + check('colony.defenseHp is unchanged after a battle the planet survives', + defColony.defenseHp === before1, `${before1} -> ${defColony.defenseHp}`); + } + + st.fleets = st.fleets.filter((f) => f.empireIdx !== attackerIdx); + defColony.defenseHp = 5; + Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'battleship', mark: 7, count: 10 }]); + const before2 = defColony.defenseHp; + const prepared2 = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {}); + check('a battle prepares for the destroyed-case fixture', !!prepared2); + if (prepared2) { + const result2 = CombatV2.runBattle(prepared2.battle); + check('the destroyed-case fixture actually reflects a beaten planet (sanity check)', + result2.planetDestroyed === true); + Logic.applyBattleOutcome(RULES, st, prepared2, result2); + check('colony.defenseHp is wiped to 0 after a battle where the planet is destroyed', + before2 > 0 && defColony.defenseHp === 0, `${before2} -> ${defColony.defenseHp}`); + + // The actual reported bug: a second battle at the same star, same + // turn, must not find a fresh fully-armed planet to re-fight. + st.fleets = st.fleets.filter((f) => f.empireIdx !== attackerIdx); + Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'frigate', mark: 1, count: 1 }]); + const prepared3 = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {}); + check('a second battle at the same already-destroyed colony this same turn finds nothing left to fight', + prepared3 === null, JSON.stringify(prepared3?.battle.ships.find((s) => s.isPlanet))); + } + } + + // --- per-shot damage scales with the defending empire's weapons tech. + { + const mkState = () => { + const st = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'cluster', seed: 1717, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + st.rules = RULES; + return st; + }; + const attackerIdx = 0; + const defenderIdx = 1; + const lowSt = mkState(); + const hiSt = mkState(); + for (const st of [lowSt, hiSt]) { + Diplo.declareWar(RULES, st, attackerIdx, defenderIdx); + const defColony = st.colonies.find((c) => c.empireIdx === defenderIdx); + defColony.defenseHp = 100; + Logic.addFleet(RULES, st, attackerIdx, defColony.starIdx, [{ hullId: 'frigate', mark: 1, count: 1 }]); + } + Logic.grantTech(RULES, hiSt, defenderIdx, 'gravitonbeam'); + const lowColony = lowSt.colonies.find((c) => c.empireIdx === defenderIdx); + const hiColony = hiSt.colonies.find((c) => c.empireIdx === defenderIdx); + const preparedLow = Logic.prepareBattleAt(RULES, lowSt, lowColony.starIdx, attackerIdx, defenderIdx, {}); + const preparedHi = Logic.prepareBattleAt(RULES, hiSt, hiColony.starIdx, attackerIdx, defenderIdx, {}); + const planetLow = preparedLow?.battle.ships.find((s) => s.isPlanet); + const planetHi = preparedHi?.battle.ships.find((s) => s.isPlanet); + check('per-shot damage scales up with the defending empire\'s weapons tech', + !!planetLow && !!planetHi && planetHi.shotDamage > planetLow.shotDamage, + `${planetLow?.shotDamage} vs ${planetHi?.shotDamage}`); + } + } + // Determinism, and auto-resolve agreeing with a played-out battle — same // structural guarantee as the live engine's equivalent check. {