Compare commits
2 Commits
869f041b39
...
033912bc73
| Author | SHA1 | Date |
|---|---|---|
|
|
033912bc73 | |
|
|
d920d8e476 |
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue