Compare commits

..

No commits in common. "033912bc739d31294c98f736dc2eaf60542a8b26" and "869f041b397fe173f7dadd3d5504e208b7836bc1" have entirely different histories.

6 changed files with 32 additions and 480 deletions

File diff suppressed because one or more lines are too long

View File

@ -399,21 +399,6 @@ function manageFleets(rules, state, e, strat) {
for (const enemy of strat.enemies) { for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) { for (const c of empireColonies(state, enemy.idx)) {
if (!reach[c.starIdx]) continue; 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 // Planetary defences count for less than live warships: they cannot
// chase, cannot reinforce, and can be ground down across several // chase, cannot reinforce, and can be ground down across several
// turns of siege. Weighting them one-for-one against fleet power made // 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 { 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, bestComponents } from './VegaShips.js'; import { designFor, stackPower } 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,18 +220,7 @@ 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),
}; };
// weaponAvgDmg mirrors VegaLogic.js's prepareBattleAt — this dev tool const colony = this.battleType === 'planet' ? { defenseHp: d.defenseHp, shieldBonus: d.shieldBonus } : null;
// 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,33 +586,6 @@ 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),
@ -632,10 +605,18 @@ export function createBattle(rules, opts) {
angularAccel: 0, angularAccel: 0,
avoidRadius: C2.separationUnit * 3, avoidRadius: C2.separationUnit * 3,
immobile: true, immobile: true,
shotDamage: (colony.weaponAvgDmg ?? 0) * C2.damageMultiplier * shotMult, // Pure proportional — no flat floor. The old formula (a flat +20 base
totalShots, // plus only +0.05/HP) meant a colony at 1/100 defenseHp and one at a
shotsLeft: totalShots, // fully-teched-out 1800+/1800 both hit for roughly the same ~20-25
finalShotStrength: remainder / perShot, // 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,
salvoesLeft: new Map(), salvoesLeft: new Map(),
retreated: false, retreated: false,
target: null, target: null,
@ -1468,19 +1449,13 @@ 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.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; if (s.cooldown == null) s.cooldown = turnSeconds;
s.cooldown -= dt; s.cooldown -= dt;
if (s.cooldown <= 0) { if (s.cooldown <= 0) {
const strength = s.shotsLeft === 1 ? s.finalShotStrength : 1; const dmg = Math.max(0, s.damage - s.target.shield) * (0.75 + b.rnd() * 0.5);
const dmg = Math.max(0, s.shotDamage * strength - s.target.shield) * (0.75 + b.rnd() * 0.5);
queueDamage(b, s.target, dmg, events, s); queueDamage(b, s.target, dmg, events, s);
s.shotsLeft -= 1;
s.cooldown += turnSeconds; s.cooldown += turnSeconds;
} }
}
continue; continue;
} }
@ -1506,8 +1481,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 && s.shotsLeft > 0)); const aArmed = aLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || (s.isPlanet && s.shotsLeft > 0)); const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
// 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

@ -151,15 +151,6 @@ function buildingEffectMult(rules, colony, key) {
} }
return m; 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) { export function colonyMaxPop(rules, state, colony) {
const emp = state.empires[colony.empireIdx]; const emp = state.empires[colony.empireIdx];
@ -210,27 +201,6 @@ export function colonyDefenseCap(rules, state, colony) {
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense')); 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 * 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 * the colony screen quotes. That is the `ships` share PLUS the two spillovers
@ -972,20 +942,10 @@ function processColony(rules, state, colony) {
indSpill = Math.max(0, share.industry - built * factoryCost); indSpill = Math.max(0, share.industry - built * factoryCost);
} }
// --- Defence: planetary batteries, capped by tech. Frozen entirely while a // --- Defence: planetary batteries, capped by tech.
// 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 capD = colonyDefenseCap(rules, state, colony);
const sieged = rules.combat.siegeFreezesDefense !== false
&& hasHostileWarshipFleet(rules, state, colony.starIdx, e);
let defSpill = 0; let defSpill = 0;
if (colony.defenseHp >= capD || sieged) { if (colony.defenseHp >= capD) {
defSpill = share.defense; defSpill = share.defense;
} else { } else {
const added = Math.min(capD - colony.defenseHp, share.defense); const added = Math.min(capD - colony.defenseHp, share.defense);
@ -993,29 +953,13 @@ function processColony(rules, state, colony) {
defSpill = Math.max(0, share.defense - added); defSpill = Math.max(0, share.defense - added);
} }
// --- Construction: the build queue (ships and buildings). A defensive // --- Construction: the build queue (ships and buildings).
// 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 build = share.ships + indSpill + defSpill;
let buildSpill = 0; let buildSpill = 0;
let blockedFront = false;
let guard = 0; let guard = 0;
while (build > 0 && colony.queue.length > 0 && guard < 20) { while (build > 0 && colony.queue.length > 0 && guard < 20) {
guard += 1; guard += 1;
const item = colony.queue[0]; 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 cost = queueItemCost(rules, state, colony, item);
const need = cost - item.progress; const need = cost - item.progress;
if (build >= need) { if (build >= need) {
@ -1027,7 +971,7 @@ function processColony(rules, state, colony) {
build = 0; build = 0;
} }
} }
if (colony.queue.length === 0 || blockedFront) buildSpill = build; if (colony.queue.length === 0) buildSpill = build;
// --- Research absorbs everything left over. // --- Research absorbs everything left over.
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1); const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
@ -1619,14 +1563,6 @@ 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,
@ -1634,7 +1570,6 @@ 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),
@ -1647,22 +1582,7 @@ 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);
// colony.defenseHp is a pure ammo gauge for the NEXT battle (governed if (colony) colony.defenseHp = result.planetDefenseLeft;
// 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);
@ -1702,7 +1622,8 @@ export function pendingBattlesFor(rules, state, e) {
// got an Under Attack notice (and often several, once per AI empire's // 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 // move that turn) for a fight that could never do anything to it
// (Brian's ask, 2026-08-14). // (Brian's ask, 2026-08-14).
if (g.starIdx === starIdx && isHostileWarshipFleet(rules, state, g, e)) { 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)) {
foes.add(g.empireIdx); foes.add(g.empireIdx);
} }
} }

View File

@ -2287,157 +2287,6 @@ 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'); section('6. Colony economy');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -4771,169 +4620,6 @@ 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.
{ {