feat: redesign planetary defense as ammo model with range gating

- 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
This commit is contained in:
Brian Fertig 2026-08-15 09:20:43 -06:00
parent d920d8e476
commit 033912bc73
5 changed files with 251 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@ -25,7 +25,7 @@ import { Button } from './VegaButton.js';
import { queueGameAssets } from '../../services/assetLoader.js'; import { queueGameAssets } from '../../services/assetLoader.js';
import { compileRules, markNumeral } from './VegaRules.js'; import { compileRules, markNumeral } from './VegaRules.js';
import { ensureSheets } from './VegaArt.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 { createBattle, runBattle } from './VegaCombat.js';
import { openCombatView } from './VegaCombatView.js'; import { openCombatView } from './VegaCombatView.js';
import * as CombatV2 from './VegaCombatV2.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 ?? {} }, empire: { known: knownForMark(this.rules, d.mark), traits: this.rules.species[d.species]?.traits ?? {} },
ships: this.battleType === 'planet' ? [] : this.buildSideShips(d), 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 }; return { attacker, defender, colony };
} }

View File

@ -586,6 +586,33 @@ export function createBattle(rules, opts) {
// (placeFleets), so worldWidth is that side's edge. // (placeFleets), so worldWidth is that side's edge.
let planet = null; let planet = null;
if (colony && colony.defenseHp > 0) { 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 = { planet = {
uid: 'planet', uid: 'planet',
seq: (seqCounter.next += 1), seq: (seqCounter.next += 1),
@ -605,18 +632,10 @@ export function createBattle(rules, opts) {
angularAccel: 0, angularAccel: 0,
avoidRadius: C2.separationUnit * 3, avoidRadius: C2.separationUnit * 3,
immobile: true, immobile: true,
// Pure proportional — no flat floor. The old formula (a flat +20 base shotDamage: (colony.weaponAvgDmg ?? 0) * C2.damageMultiplier * shotMult,
// plus only +0.05/HP) meant a colony at 1/100 defenseHp and one at a totalShots,
// fully-teched-out 1800+/1800 both hit for roughly the same ~20-25 shotsLeft: totalShots,
// damage, comparable to a tier 5-6 weapon, regardless of how depleted finalShotStrength: remainder / perShot,
// 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,
salvoesLeft: new Map(), salvoesLeft: new Map(),
retreated: false, retreated: false,
target: null, target: null,
@ -1449,12 +1468,18 @@ export function advance(b, dt, { allowRetreat = true } = {}) {
if (!s.target || s.target.hp <= 0) continue; if (!s.target || s.target.hp <= 0) continue;
if (s.isPlanet) { if (s.isPlanet) {
if (s.cooldown == null) s.cooldown = turnSeconds; if (s.shotsLeft <= 0) continue; // out of ammo — silent for the rest of the fight
s.cooldown -= dt; const pDist = dist2D(s, s.target);
if (s.cooldown <= 0) { if (pDist <= C2.planetRange + RANGE_EPS) {
const dmg = Math.max(0, s.damage - s.target.shield) * (0.75 + b.rnd() * 0.5); if (s.cooldown == null) s.cooldown = turnSeconds;
queueDamage(b, s.target, dmg, events, s); s.cooldown -= dt;
s.cooldown += turnSeconds; 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; continue;
} }
@ -1481,8 +1506,8 @@ export function advance(b, dt, { allowRetreat = true } = {}) {
const aLeft = living(b, 'attacker'); const aLeft = living(b, 'attacker');
const dLeft = living(b, 'defender'); const dLeft = living(b, 'defender');
const aArmed = aLeft.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); 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, // 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 // or it silently falls into "attacker has none left" and is scored as a

View File

@ -1619,6 +1619,14 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation =
// .planetaryshield's shieldBonus:5) was silently never applied to a real // .planetaryshield's shieldBonus:5) was silently never applied to a real
// battle, only ever exercised through the standalone ?movsim simulator's // battle, only ever exercised through the standalone ?movsim simulator's
// manual stepper — found while wiring the shield ring into the view. // 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, { const battle = createBattle(rules, {
attacker, attacker,
defender, defender,
@ -1626,6 +1634,7 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation =
defenseHp: defColony.defenseHp, defenseHp: defColony.defenseHp,
shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'), shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'),
typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId, typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId,
weaponAvgDmg: defWeaponAvgDmg,
} : null, } : null,
starIdx, starIdx,
rnd: () => rand(state), rnd: () => rand(state),
@ -1638,7 +1647,22 @@ export function applyBattleOutcome(rules, state, prepared, result) {
const { starIdx, attackerIdx, defenderIdx, colony } = prepared; const { starIdx, attackerIdx, defenderIdx, colony } = prepared;
applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors); applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors);
applyBattleLosses(rules, state, starIdx, defenderIdx, result.defenderSurvivors); 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; const loser = result.winner === 'attacker' ? defenderIdx : attackerIdx;
if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser); if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser);

View File

@ -4771,6 +4771,169 @@ section('11. Combat V2 (per-ship prototype)');
planetNoShield.shield === 0, `${planetNoShield.shield}`); 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 // Determinism, and auto-resolve agreeing with a played-out battle — same
// structural guarantee as the live engine's equivalent check. // structural guarantee as the live engine's equivalent check.
{ {