Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 033912bc73 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
2026-08-15 09:20:43 -06:00
Brian Fertig d920d8e476 feat(combat): freeze colony defense regen and defensive builds while under siege
Add siegeFreezesDefense mechanic (default true) that stops a colony's
passive defenseHp trickle and blocks defensive building completion at the
front of the build queue while a hostile warship fleet is present.

This fixes the recurring "Under Attack" notification loop reported by Brian
where defenseHp would regenerate each turn, re-arming the pendingBattlesFor
gate and triggering fresh popup notices for an already-beaten colony.

Also adds an AI dispatch throttle in manageFleets: idle fleets no longer
open redundant attack orders against colonies the empire already holds orbit
over with defenseHp beaten to 0. Excluded fleets fall through to rally logic
and naturally join the existing siege or find a fresh front.

New helpers: isDefensiveBuildingId, isHostileWarshipFleet, hasHostileWarshipFleet
— shared across processColony, pendingBattlesFor, and VegaAI to ensure a
single definition of "under active siege".

Includes tests (sections 5d, 5e) covering regen freeze, build queue freeze,
siegeFreezesDefense=false toggle, and AI throttle.
2026-08-15 08:00:40 -06:00
6 changed files with 480 additions and 32 deletions

File diff suppressed because one or more lines are too long

View File

@ -399,6 +399,21 @@ function manageFleets(rules, state, e, strat) {
for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) {
if (!reach[c.starIdx]) continue;
// Nothing left to shoot at: batteries are down, no defending fleet
// of the enemy's own is present, and we already hold orbit here
// with warships of our own. Sending another warship fleet at an
// already-beaten colony achieves nothing (prepareBattleAt returns
// null once defenseHp<=0 with no defender) and used to mean idle
// fleet after idle fleet independently retargeting the same dying
// colony turn after turn instead of opening a fresh front or
// reinforcing a fight that still needs winning (Brian's ask,
// 2026-08-15). Excluded fleets fall through to step 5's rally
// logic below, which naturally joins the fleet already here.
const alreadyBeaten = c.defenseHp <= 0
&& !state.fleets.some((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx && f.ships.length);
const weHoldOrbitHere = state.fleets
.some((f) => f.starIdx === c.starIdx && f.empireIdx === e && f.ships.length);
if (alreadyBeaten && weHoldOrbitHere) continue;
// Planetary defences count for less than live warships: they cannot
// chase, cannot reinforce, and can be ground down across several
// turns of siege. Weighting them one-for-one against fleet power made

View File

@ -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 };
}

View File

@ -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

View File

@ -151,6 +151,15 @@ function buildingEffectMult(rules, colony, key) {
}
return m;
}
// The four buildings that raise a colony's defenseHp cap (channel: 'defense'
// — missilebase/planetaryshield/artemisnet) or its ground defence against
// invasion (groundbattery, channel: null but effects.groundDefense). Derived
// from the data rather than a hardcoded id list so a future defensive
// building picks this up automatically.
function isDefensiveBuildingId(rules, id) {
const b = rules.buildings[id];
return !!b && (b.channel === 'defense' || b.effects?.groundDefense != null);
}
export function colonyMaxPop(rules, state, colony) {
const emp = state.empires[colony.empireIdx];
@ -201,6 +210,27 @@ export function colonyDefenseCap(rules, state, colony) {
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
}
// A fleet only threatens a colony if it can actually fire a shot — a pure
// troop-transport or colony-ship fleet never can (pendingBattlesFor's own
// comment). Factored out so every place that cares whether a colony is under
// active siege (defenseHp regen freeze + defensive-building queue freeze in
// processColony, pendingBattlesFor's own foe scan, VegaAI's attack-fleet
// dispatch throttle) agrees on exactly the same definition and can never
// drift apart.
function isHostileWarshipFleet(rules, state, fleet, ownerIdx) {
return fleet.empireIdx !== ownerIdx
&& atWar(state, ownerIdx, fleet.empireIdx)
&& fleet.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0);
}
// True when a live hostile fleet with at least one warship-role ship sits at
// `starIdx`, at war with `ownerIdx` — "this colony is under active siege"
// (Brian's ask, 2026-08-15). A stray scout or unarmed transport does not
// count.
export function hasHostileWarshipFleet(rules, state, starIdx, ownerIdx) {
return state.fleets.some((f) => f.starIdx === starIdx && isHostileWarshipFleet(rules, state, f, ownerIdx));
}
/**
* BC per turn actually reaching the build queue, used for the "N turns" figures
* the colony screen quotes. That is the `ships` share PLUS the two spillovers
@ -942,10 +972,20 @@ function processColony(rules, state, colony) {
indSpill = Math.max(0, share.industry - built * factoryCost);
}
// --- Defence: planetary batteries, capped by tech.
// --- Defence: planetary batteries, capped by tech. Frozen entirely while a
// live hostile warship fleet sits in orbit (rules.combat.siegeFreezesDefense
// !== false) — this passive trickle, completely independent of siege state,
// used to mean beating a colony's batteries to 0 in one battle only ever
// bought ONE turn's peace, since this line put them straight back above 0
// the very next beginEmpireTurn, re-arming pendingBattlesFor's/
// prepareBattleAt's own `defenseHp > 0` gates below with a fresh "Under
// Attack" popup every turn (Brian's ask, 2026-08-15). Frozen production
// spills into the build queue exactly like an already-capped colony's does.
const capD = colonyDefenseCap(rules, state, colony);
const sieged = rules.combat.siegeFreezesDefense !== false
&& hasHostileWarshipFleet(rules, state, colony.starIdx, e);
let defSpill = 0;
if (colony.defenseHp >= capD) {
if (colony.defenseHp >= capD || sieged) {
defSpill = share.defense;
} else {
const added = Math.min(capD - colony.defenseHp, share.defense);
@ -953,13 +993,29 @@ function processColony(rules, state, colony) {
defSpill = Math.max(0, share.defense - added);
}
// --- Construction: the build queue (ships and buildings).
// --- Construction: the build queue (ships and buildings). A defensive
// building (missilebase/groundbattery/planetaryshield/artemisnet) at the
// FRONT of the queue is also frozen while sieged — completing one mid-siege
// (or letting it creep to 100% progress, ready to pop the instant the enemy
// fleet leaves) is the same class of bug as the defenseHp trickle above,
// just one queue slot over (Brian's ask, 2026-08-15). Deliberately uniform:
// an item already sitting at high progress when the siege starts is frozen
// exactly like one at 0%, rather than special-cased to still complete.
// Strict FIFO: a blocked defensive building at the front stalls the whole
// queue behind it (matches how an empty queue already behaves), so
// remaining build this turn spills to research rather than skipping ahead
// to fund something else.
let build = share.ships + indSpill + defSpill;
let buildSpill = 0;
let blockedFront = false;
let guard = 0;
while (build > 0 && colony.queue.length > 0 && guard < 20) {
guard += 1;
const item = colony.queue[0];
if (sieged && item.kind === 'building' && isDefensiveBuildingId(rules, item.id)) {
blockedFront = true;
break;
}
const cost = queueItemCost(rules, state, colony, item);
const need = cost - item.progress;
if (build >= need) {
@ -971,7 +1027,7 @@ function processColony(rules, state, colony) {
build = 0;
}
}
if (colony.queue.length === 0) buildSpill = build;
if (colony.queue.length === 0 || blockedFront) buildSpill = build;
// --- Research absorbs everything left over.
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
@ -1563,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,
@ -1570,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),
@ -1582,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);
@ -1622,8 +1702,7 @@ export function pendingBattlesFor(rules, state, e) {
// got an Under Attack notice (and often several, once per AI empire's
// move that turn) for a fight that could never do anything to it
// (Brian's ask, 2026-08-14).
if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)
&& g.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0)) {
if (g.starIdx === starIdx && isHostileWarshipFleet(rules, state, g, e)) {
foes.add(g.empireIdx);
}
}

View File

@ -2287,6 +2287,157 @@ section('5c. AI attack-fleet escalation');
}
}
// ---------------------------------------------------------------------------
section('5d. Siege freezes passive defenseHp regen (processColony)');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: a colony beaten to defenseHp 0 must STAY at 0 for
// as long as a hostile warship fleet remains, not regenerate straight back
// above 0 next turn and re-arm the pendingBattlesFor/prepareBattleAt gates
// section 5b already covers — that one-turn reprieve was the actual cause of
// a fresh "Under Attack" notice popping every single turn a siege dragged on.
{
const mk = (speciesIds) => {
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 202, difficultyId: 'normal',
speciesIds, humanIndex: 0,
});
st.rules = RULES;
st.fleets = [];
return st;
};
// --- the trickle itself: frozen while sieged, resumes once the fleet leaves.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
Logic.beginEmpireTurn(RULES, st, 0);
check('defenseHp does not regenerate while a hostile warship fleet is present',
colony.defenseHp === 0, `${colony.defenseHp}`);
st.fleets = st.fleets.filter((f) => f.empireIdx !== 1);
Logic.beginEmpireTurn(RULES, st, 0);
check('defenseHp resumes regenerating once the hostile fleet is gone',
colony.defenseHp > 0, `${colony.defenseHp}`);
}
// --- across several sieged turns in a row, defenseHp never re-arms, so
// pendingBattlesFor/prepareBattleAt never surface a battle for it — the
// exact recurring-notification scenario Brian reported.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
for (let i = 0; i < 3; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('after several sieged turns, defenseHp is still 0 (never re-armed)', colony.defenseHp === 0);
check('pendingBattlesFor never surfaces it',
!Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1));
check('prepareBattleAt returns null (no battle at all)',
Logic.prepareBattleAt(RULES, st, home, 0, 1) === null);
}
// --- a defensive building at the front of the queue does not complete
// while sieged, and completes normally once the siege lifts.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 0, industry: 1, ecology: 0, research: 0 };
Logic.grantTech(RULES, st, 0, 'hypervrockets');
Logic.enqueue(RULES, st, colony, 'building', 'missilebase');
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
for (let i = 0; i < 30; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('a defensive building at the front of the queue does not complete while sieged',
!colony.buildings.includes('missilebase'), JSON.stringify(colony.queue));
st.fleets = st.fleets.filter((f) => f.empireIdx !== 1);
for (let i = 0; i < 30; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('the same building completes normally once the siege lifts',
colony.buildings.includes('missilebase'));
}
// --- the siegeFreezesDefense knob: false restores the old always-regen
// behaviour exactly, so a difficulty preset (or a future save) can opt out.
{
const noFreezeRules = compileRules({ ...rulesJson, combat: { ...rulesJson.combat, siegeFreezesDefense: false } });
const st = Logic.createGame(noFreezeRules, {
sizeId: 'medium', shapeId: 'spiral', seed: 202, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
st.rules = noFreezeRules;
st.fleets = [];
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(noFreezeRules, st, 0, 1);
Logic.addFleet(noFreezeRules, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
Logic.beginEmpireTurn(noFreezeRules, st, 0);
check('siegeFreezesDefense: false restores the old always-regen behaviour',
colony.defenseHp > 0, `${colony.defenseHp}`);
}
}
// ---------------------------------------------------------------------------
section('5e. AI siege dispatch throttle');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: a fresh idle warship fleet must not open a second
// attack order against an enemy colony this empire already holds orbit over
// with defenseHp beaten to 0 — there's nothing left to shoot at, and doing so
// used to mean idle fleet after idle fleet dribbling in on separate turns
// instead of reinforcing the siege already under way (via step 5's rally) or
// finding a fresh front.
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1, difficultyId: 'normal',
speciesIds: ['kkrix', 'rrashaa'], humanIndex: -1,
});
st.rules = RULES;
st.fleets = [];
Logic.grantTech(RULES, st, 0, 'thoriumcells');
const home0 = st.galaxy.homeIdx[0];
const home1 = st.galaxy.homeIdx[1];
const elsewhere = st.galaxy.stars.findIndex((s, i) => i !== home0 && i !== home1);
check('a third star exists to park the threat fleet away from the target', elsewhere >= 0);
Diplo.declareWar(RULES, st, 0, 1);
// Push computeStrategy into 'war' phase, same as section 5c.
Logic.addFleet(RULES, st, 1, elsewhere, [{ hullId: 'battleship', mark: 1, count: 5 }]);
const defCol = st.colonies.find((c) => c.starIdx === home1);
defCol.defenseHp = 0;
// A fresh idle warship fleet at home, created (and so processed by
// manageFleets) BEFORE the siege fleet below — this fixture has only one
// owned colony, so an idle fleet with no bigger friend nearby gets pulled
// back to it by step 5's unrelated pre-existing "gather at the colony
// nearest the front" fallback; creating fresh first means it's evaluated
// while the siege fleet still genuinely holds home1, isolating the guard
// clause this test exists for from that unrelated ordering quirk.
const fresh = Logic.addFleet(RULES, st, 0, home0, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
// We already hold orbit over the beaten colony with a live warship fleet,
// EQUAL power to fresh (so step 5's rally has no strictly-bigger friend to
// chase toward home1 either).
const siege = Logic.addFleet(RULES, st, 0, home1, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
check('the siege and fresh fleets start at equal power (isolates the guard clause)',
Logic.fleetPower(RULES, st, siege) === Logic.fleetPower(RULES, st, fresh));
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('a fresh fleet does not open a redundant attack order against an already-beaten, already-held colony',
fresh.starIdx === home0 && fresh.toStar < 0,
JSON.stringify({ starIdx: fresh.starIdx, toStar: fresh.toStar }));
}
// ---------------------------------------------------------------------------
section('6. Colony economy');
// ---------------------------------------------------------------------------
@ -4620,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.
{