diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 10e7b87..11c5e36 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -498,7 +498,7 @@ }, "combat": { - "_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre.", + "_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre. siegeFreezesDefense (default true) stops a colony's passive defenseHp trickle (and any defensive building at the front of its build queue) while a hostile warship fleet is present — set false to restore the old always-regen behaviour.", "gridCols": 12, "gridRows": 8, "maxRounds": 60, @@ -518,7 +518,8 @@ "bombardDefensiveBuildingDestroyMult": 0.25, "groundOddsScale": 0.01, "cloakEvasion": 0.02, - "singularityShieldPierce": 0.5 + "singularityShieldPierce": 0.5, + "siegeFreezesDefense": true }, "_combatV2Readme": "Constants for the VegaCombatV2 per-ship prototype only (behind ?movsim's Live/V2 toggle) — completely separate from 'combat' above, which the live engine still reads unmodified. World space is continuous 2D, not a lane, and combat runs in continuous simulated TIME, not discrete rounds: every ship has its own firing cooldown (turnSeconds, seconds between shots — armed the moment it first comes into weapon range of its target, matching real-time engagement rather than a synchronized lockstep round) and moves/turns continuously every tick rather than snapping once per round. moveUnitsPerSpeed is world units per SECOND now (was per old discrete round, at 100) — deliberately slowed down, not just time-converted, per Brian's explicit ask for a slower, more deliberate pace. turnRateScale converts each hull's turnRateBase (still degrees, still hand-tuned per hull, still living in the hulls block) into a per-second max angular velocity; spinUpSeconds is how long a ship takes to spin up to that max rate from a standing start, which is what gives turning real momentum instead of an instant snap. separationUnit/separationWeight tune the collision-avoidance steering (VegaCombatV2.js's computeSeparation) — separationUnit is the 'personal space' radius per point of a hull's sizeScale, so bigger ships keep proportionally more distance. Ships also carry real LINEAR momentum (vx/vy, independent of facing) — each hull's brakeSeconds (hulls block) sets how hard it can actually decelerate; a hull whose stopping distance at full speed exceeds beamRange physically cannot stop before reaching its target and blows through for another pass instead (see computeShipMove's comment). avoidAccel is a flat, hull-independent acceleration budget for collision avoidance ONLY, deliberately NOT drawn from a hull's own (possibly weak) linearAccel — several ships converging on the same weighted-random target approach nearly in formation, and a frigate's deliberately poor brakes must not also mean it can't swerve around a teammate on that same course. Kept deliberately gentle (not a strong repulsion) per Brian's explicit ask — ships should attempt to avoid each other, not bounce, and overlapping when their actual objectives require it (e.g. a strafing pass, or several ships converging on one target) is fine. beamRange/missileRange are unchanged by any of this (spatial, not temporal) — every other combat constant (hit-chance coefficients, cloakEvasion, singularityShieldPierce) is shared by reading rules.combat directly, since none of it is range/position/time-scaled; retreatAfterRound/disengageRound are reinterpreted as seconds (×turnSeconds) rather than duplicated here. damageMultiplier scales each weapon's raw output (before shield mitigation) in VegaCombatV2.js's fireMounts() ONLY — the live engine's weapon damage (read from the same shared VegaShips.js designs) is completely untouched, so this shortens V2 battle length without changing live-game balance at all. Brian's explicit ask, after watching V2 play out live and finding fights took too many exchanges to resolve. centeringAccel is a flat, hull-independent acceleration budget (same shape as avoidAccel) that gently pulls a ship back toward roughly where the battle STARTED once it has wandered meaningfully beyond that starting footprint — VegaCombatV2.js's computeCenteringForce/createBattle for the full mechanism; Brian's ask after noticing battles look great early on and then drift off the fixed camera framing by the end. Zero force within the battle's own actual starting spread (formation-and-fleet-size-aware, not a guessed world fraction), ramping in only beyond it. maxDurationSeconds is V2's OWN total time budget (VegaCombatV2.js's maxDurationSec(b) helper), deliberately decoupled from rules.combat.maxRounds (shared with the live engine's round cap) — measuring actual battle outcomes found the disengage timer was firing in essentially every simulated battle before combat concluded naturally regardless of damage level, so raising the ceiling (120s -> 240s) and disengageFraction (0.85 -> 0.9, now 216s before the weaker side is forced to flee, was 102s) gives combat meaningfully more room to resolve on its own merits. IMPORTANT interaction: raising the duration ceiling alone made battles LONGER, not shorter (removing what had effectively been an implicit cap) — damageMultiplier is the lever that actually shortens things once the ceiling isn't artificially truncating fights; went 1.6 -> 2.8 after measuring that duration keeps dropping sharply as multiplier climbs while decisiveness (fraction resolving by real destruction, not forced retreat) holds or improves, not degrades.", diff --git a/src/games/mastervega/VegaAI.js b/src/games/mastervega/VegaAI.js index 9700fad..8184065 100644 --- a/src/games/mastervega/VegaAI.js +++ b/src/games/mastervega/VegaAI.js @@ -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 diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index dc0af6d..9a5e392 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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); @@ -1622,8 +1678,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); } } diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 7b8ced8..cc154ee 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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'); // ---------------------------------------------------------------------------