fix(mastervega): balance planetary defense and improve AI attack logic
- Replace flat planetDefenseBase (20) with proportional planetDefenseScale (0.06), so colony defense damage scales purely with current defenseHp. A near-depleted colony now barely scratches attackers, while a fully tech'd one still tops out ~110 damage — fixing "attacked with the strength of a much-better-defended planet" (Brian, 2026-08-14). - Add AI war memory: track consecutive losses per enemy and escalate attackMultiplier up to 3x so repeated losing attacks stop dribbling small fleets. A win resets the streak. Prevents permanent phoney wars where both sides build to equal strength and nothing moves. - Show "Under Attack" notice (one-button, no formation picker) when AI attacks the player, since formation choice is meaningless for defense. - Add planetary beam fire SFX for combat view. - Add event `seq` cursor for war memory to survive event list trimming. - Full test coverage for planet damage formula and AI escalation behavior.
This commit is contained in:
parent
2b1fe96bf6
commit
3bad628968
Binary file not shown.
|
|
@ -501,7 +501,7 @@
|
|||
"missileRange": 6,
|
||||
"retreatAfterRound": 1,
|
||||
"disengageRound": 25,
|
||||
"planetDefenseBase": 20,
|
||||
"planetDefenseScale": 0.06,
|
||||
"bombardPopKill": 0.22,
|
||||
"groundOddsScale": 0.01,
|
||||
"cloakEvasion": 0.02,
|
||||
|
|
|
|||
|
|
@ -213,6 +213,10 @@ export const MANIFEST = {
|
|||
{ type: 'audio', key: 'sfx-vega-missle-launch-67', path: 'assets/fx/vega/vega-missle-launch-67.mp3' },
|
||||
{ type: 'audio', key: 'sfx-vega-missle-hit-12345', path: 'assets/fx/vega/vega-missle-hit-12345.mp3' },
|
||||
{ type: 'audio', key: 'sfx-vega-missle-hit-67', path: 'assets/fx/vega/vega-missle-hit-67.mp3' },
|
||||
// Planetary defence battery fire cue — a colony has no weapon mount to
|
||||
// Mark-band like a ship (VegaCombatV2.js's planet entity is a flat
|
||||
// damage scalar), so this is the one cue for every planet fire event.
|
||||
{ type: 'audio', key: 'sfx-vega-planet-beam', path: 'assets/fx/vega/vega-planet-beam.mp3' },
|
||||
// Per-warship-class destruction cues. Filename has a typo ("friggate")
|
||||
// that the cache key does not repeat — see Sounds.js.
|
||||
{ type: 'audio', key: 'sfx-vega-destroy-frigate', path: 'assets/fx/vega/vega-destroy-friggate.mp3' },
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { openCombatViewV2 } from './VegaCombatViewV2.js';
|
|||
import {
|
||||
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
|
||||
openSaveScreen, openLoadScreen, showVictoryOverlay, openFormationPicker,
|
||||
openAttackNotice,
|
||||
} from './VegaScreens.js';
|
||||
import { openColoniesScreen } from './VegaColoniesScreen.js';
|
||||
import { openResearchScreen } from './VegaResearchScreen.js';
|
||||
|
|
@ -1205,14 +1206,16 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
// driven tick by tick by the view, and its outcome handed straight back,
|
||||
// so a battle the player fights here and an AI-vs-AI one auto-resolved by
|
||||
// resolveCombats() still go through exactly the same prepareBattleAt/
|
||||
// applyBattleOutcome code, just with a human-chosen formation and a screen
|
||||
// to watch. Re-derives Logic.pendingBattlesFor fresh every call (a no-op
|
||||
// if nothing's pending), so it's safe to call from more than one place:
|
||||
// onEndTurn() after the human's own move, AND runToHumanTurn()'s AI loop
|
||||
// after every AI empire's move, since endEmpireTurn is told to defer any
|
||||
// applyBattleOutcome code, just with a screen to watch (and, when the
|
||||
// human started it, a formation choice). Re-derives Logic.pendingBattlesFor
|
||||
// fresh every call (a no-op if nothing's pending), so it's safe to call
|
||||
// from more than one place: onEndTurn() after the human's own move
|
||||
// (`attacked` left false — the human chose this fight, so they choose the
|
||||
// formation), AND runToHumanTurn()'s AI loop after every AI empire's move
|
||||
// (`attacked: true` — see below), since endEmpireTurn is told to defer any
|
||||
// battle touching the human rather than auto-resolve it (Brian's ask,
|
||||
// 2026-08-14) — this is what actually shows it.
|
||||
playPlayerBattles(done) {
|
||||
playPlayerBattles(done, { attacked = false } = {}) {
|
||||
const me = this.state.humanIndex;
|
||||
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
|
||||
if (!pending.length) { done(); return; }
|
||||
|
|
@ -1222,11 +1225,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
const { starIdx, other } = pending[i];
|
||||
this.map?.panToStar(starIdx, 260);
|
||||
this.modalOpen = true;
|
||||
// Forced (closable: false) — there's no sensible "cancel" once fleets
|
||||
// are already committed to this fight. Only the human's own side is
|
||||
// ever asked; the AI opponent always picks silently (Logic
|
||||
// .prepareBattleAt's own comment).
|
||||
openFormationPicker(this, (humanFormation) => {
|
||||
|
||||
const startBattle = (humanFormation) => {
|
||||
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other, { humanFormation });
|
||||
if (!prepared) { this.modalOpen = false; next(i + 1); return; }
|
||||
this.music?.setCategory('combat');
|
||||
|
|
@ -1242,7 +1242,28 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
next(i + 1);
|
||||
},
|
||||
});
|
||||
}, { closable: false });
|
||||
};
|
||||
|
||||
if (attacked) {
|
||||
// The formation picker is skipped here (Brian's ask, 2026-08-14):
|
||||
// VegaFormations.js's own header comment says a chosen strategy
|
||||
// doesn't change how the battle plays out yet, so asking the player
|
||||
// to pick one before a fight they didn't start is a pointless extra
|
||||
// click, not a real decision. Just say who's attacking and where,
|
||||
// then start the same prepared battle with both sides silently
|
||||
// randomised — prepareBattleAt's default when humanFormation is null,
|
||||
// same as any AI-vs-AI fight already gets.
|
||||
openAttackNotice(this, {
|
||||
attackerName: this.state.empires[other].name,
|
||||
starName: this.state.galaxy.stars[starIdx].name,
|
||||
}, () => startBattle(null));
|
||||
} else {
|
||||
// Forced (closable: false) — there's no sensible "cancel" once fleets
|
||||
// are already committed to this fight. Only the human's own side is
|
||||
// ever asked; the AI opponent always picks silently (Logic
|
||||
// .prepareBattleAt's own comment).
|
||||
openFormationPicker(this, startBattle, { closable: false });
|
||||
}
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
|
@ -1351,14 +1372,17 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
runAITurn(this.rules, this.state, e);
|
||||
Logic.endEmpireTurn(this.rules, this.state, e, { deferBattlesFor: this.state.humanIndex });
|
||||
// This AI empire's own move can bring it into contact with the human —
|
||||
// same interactive battle + formation picker the human's own movement
|
||||
// already gets (Brian's ask, 2026-08-14). playPlayerBattles re-derives
|
||||
// same interactive tactical view the human's own movement already gets
|
||||
// (Brian's ask, 2026-08-14), `attacked: true` so it's a one-button
|
||||
// "you're under attack" notice instead of the formation picker (that
|
||||
// choice is the human's to make when THEY start a fight, not something
|
||||
// to ask for on defense). playPlayerBattles re-derives
|
||||
// Logic.pendingBattlesFor fresh every call and returns immediately when
|
||||
// nothing is pending, so this costs nothing on the (common) turn where
|
||||
// no battle happened, and steps the human straight into the tactical
|
||||
// view the instant one did — before the loop moves on to the next
|
||||
// empire, so fights never stack up unresolved across turns.
|
||||
this.playPlayerBattles(() => this.time.delayedCall(60, step));
|
||||
this.playPlayerBattles(() => this.time.delayedCall(60, step), { attacked: true });
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,11 +356,16 @@ function manageFleets(rules, state, e, strat) {
|
|||
const guard = state.fleets
|
||||
.filter((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx)
|
||||
.reduce((t, f) => t + fleetPower(rules, state, f), 0) + c.defenseHp * 0.4;
|
||||
// Attack at rough parity. Demanding a clear local edge produced a
|
||||
// permanent phoney war: both sides built to the same strength, each
|
||||
// decided it was not quite winning enough, and nothing ever moved for
|
||||
// seven hundred turns.
|
||||
if (power < guard) continue;
|
||||
// Attack at rough parity — except against an enemy this empire has
|
||||
// just lost to repeatedly, where attackMultiplier raises the bar
|
||||
// instead of sending the next fleet in at the same losing size
|
||||
// (Brian's ask, 2026-08-14). Demanding a clear local edge as the
|
||||
// DEFAULT case produced a permanent phoney war: both sides built to
|
||||
// the same strength, each decided it was not quite winning enough,
|
||||
// and nothing ever moved for seven hundred turns — so the escalation
|
||||
// only ever kicks in reactively, after real losses, and resets the
|
||||
// moment an attack actually works.
|
||||
if (power < guard * attackMultiplier(emp, enemy.idx)) continue;
|
||||
// Concentrate on whoever is closest to collapse. Spreading pressure
|
||||
// evenly across every rival keeps them all alive indefinitely; a war
|
||||
// is only won by finishing somebody off.
|
||||
|
|
@ -420,12 +425,62 @@ function manageFleets(rules, state, e, strat) {
|
|||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// War memory — a per-enemy consecutive-loss streak, read once per turn from
|
||||
// the 'combat' events applyBattleOutcome (VegaLogic.js) already pushes.
|
||||
// Works identically whether the battle auto-resolved (AI-vs-AI) or was
|
||||
// fought out interactively by the human (MasterOfVegaGame.js's
|
||||
// playPlayerBattles) — same event, same shape, either way. A win against an
|
||||
// enemy resets that enemy's streak to 0; a loss increments it. Read by
|
||||
// manageFleets' attack decision below to demand progressively more force
|
||||
// before trying that enemy again, instead of feeding it the same losing
|
||||
// fleet size on repeat (Brian's ask, 2026-08-14).
|
||||
//
|
||||
// Cursored on each event's `seq` (VegaLogic.js's pushEvent), not on
|
||||
// state.turn: resolveCombats runs globally, from inside WHICHEVER empire's
|
||||
// endEmpireTurn happens to execute next, so a battle this empire is
|
||||
// "attacker" in can land in state.events before this empire's own turn is
|
||||
// even processed this calendar cycle — a turn-number cursor could line up
|
||||
// exactly with such an event's own turn and skip it. A `seq` cursor has no
|
||||
// such boundary case: anything with a higher seq than last time is new,
|
||||
// full stop, and it stays correct even across the trim-to-300 pushEvent
|
||||
// does once state.events passes 600 (an index or count-based cursor would
|
||||
// not survive that).
|
||||
function updateWarMemory(state, e) {
|
||||
const emp = state.empires[e];
|
||||
const from = emp.warMemorySeq;
|
||||
let maxSeq = from;
|
||||
for (const ev of state.events) {
|
||||
if (ev.seq > maxSeq) maxSeq = ev.seq;
|
||||
if (ev.type !== 'combat' || ev.attacker !== e || ev.seq <= from) continue;
|
||||
const mem = emp.warMemory[ev.defender] ?? (emp.warMemory[ev.defender] = { losses: 0 });
|
||||
mem.losses = ev.winner === 'attacker' ? 0 : mem.losses + 1;
|
||||
}
|
||||
emp.warMemorySeq = maxSeq;
|
||||
}
|
||||
|
||||
// Multiplier on a target's `guard` (manageFleets' local-attack gate) once
|
||||
// this empire has lost to it a couple of times running: unchanged at 1x for
|
||||
// the first loss (still just bad luck), then climbs — capped at 3x so a
|
||||
// structurally weaker empire cannot escalate itself into permanent
|
||||
// passivity, which is exactly the "phoney war" failure mode the plain
|
||||
// parity threshold below was chosen to avoid in the first place (see that
|
||||
// comment). A win resets the streak (updateWarMemory above), so this only
|
||||
// ever demands more after REPEATED failure, and eases back off the moment
|
||||
// an attack actually works.
|
||||
function attackMultiplier(emp, enemyIdx) {
|
||||
const losses = emp.warMemory[enemyIdx]?.losses ?? 0;
|
||||
if (losses <= 1) return 1;
|
||||
return Math.min(3, 1 + (losses - 1) * 0.5);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function runAITurn(rules, state, e) {
|
||||
const emp = state.empires[e];
|
||||
if (!emp.alive) return;
|
||||
|
||||
updateWarMemory(state, e);
|
||||
const strat = computeStrategy(rules, state, e);
|
||||
manageResearch(rules, state, e, strat);
|
||||
for (const colony of strat.colonies) manageColony(rules, state, e, colony, strat);
|
||||
|
|
|
|||
|
|
@ -93,7 +93,9 @@ export function createBattle(rules, opts) {
|
|||
x: C.gridCols - 1,
|
||||
speed: 0,
|
||||
immobile: true,
|
||||
damage: C.planetDefenseBase + colony.defenseHp * 0.05,
|
||||
// Pure proportional — see VegaCombatV2.js's identical formula for why
|
||||
// the old flat +20-base version was overpowered at low defenseHp.
|
||||
damage: colony.defenseHp * C.planetDefenseScale,
|
||||
salvoesLeft: new Map(),
|
||||
retreated: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -605,7 +605,18 @@ export function createBattle(rules, opts) {
|
|||
angularAccel: 0,
|
||||
avoidRadius: C2.separationUnit * 3,
|
||||
immobile: true,
|
||||
damage: rules.combat.planetDefenseBase + colony.defenseHp * 0.05,
|
||||
// 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,
|
||||
salvoesLeft: new Map(),
|
||||
retreated: false,
|
||||
target: null,
|
||||
|
|
|
|||
|
|
@ -431,7 +431,17 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
|
|||
scene.time.delayedCall(hitDelay, () => {
|
||||
if (to.container.active) fx.hit(to.container.x, to.container.y, missile ? 0xffb060 : 0xffd28a);
|
||||
});
|
||||
if (ev.weaponKind === 'beam' || missile) {
|
||||
// A planet has no weapon mount to band by Mark (ev.weaponKind is
|
||||
// null for its fire events — VegaCombatV2.js's queueDamage falls
|
||||
// back to a bare damage scalar), so it gets its own fixed cue
|
||||
// instead of falling through weaponSfxKey's beam/missile dispatch.
|
||||
if (from.ship.isPlanet) {
|
||||
const now = scene.time.now;
|
||||
if (now >= nextSoundAt) {
|
||||
nextSoundAt = now + SOUND_MIN_GAP_MS;
|
||||
playSound(scene, SFX.VEGA_PLANET_BEAM);
|
||||
}
|
||||
} else if (ev.weaponKind === 'beam' || missile) {
|
||||
const now = scene.time.now;
|
||||
if (now >= nextSoundAt) {
|
||||
nextSoundAt = now + SOUND_MIN_GAP_MS;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,13 @@ export function randInt(state, n) { return Math.floor(rand(state) * n); }
|
|||
|
||||
export function attachRules(state, rules) { state.rules = rules; return state; }
|
||||
|
||||
// `seq` is a permanent, ever-increasing id independent of the event's
|
||||
// position in `state.events` — VegaAI.js's updateWarMemory (per-empire
|
||||
// combat-outcome tracking) needs a cursor that survives the trim below
|
||||
// unscathed; an array-index or count-based cursor would silently go stale
|
||||
// (or worse, skip real events) the moment a trim ran between two reads.
|
||||
const pushEvent = (state, ev) => {
|
||||
ev.seq = state.nextEventSeq += 1;
|
||||
state.events.push(ev);
|
||||
if (state.events.length > 600) state.events = state.events.slice(-300);
|
||||
};
|
||||
|
|
@ -515,6 +521,7 @@ export function createGame(rules, opts) {
|
|||
fleets: [],
|
||||
nextColonyId: 0,
|
||||
nextFleetId: 0,
|
||||
nextEventSeq: 0,
|
||||
events: [],
|
||||
council: {
|
||||
nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false,
|
||||
|
|
@ -561,6 +568,16 @@ export function createGame(rules, opts) {
|
|||
// button could be clicked any number of times in one turn.
|
||||
lastBombardTurn: {},
|
||||
fleetIntrusions: {},
|
||||
// enemyIdx -> { losses }, a consecutive-attack-loss streak read by
|
||||
// VegaAI.js's manageFleets to demand progressively more force before
|
||||
// trying that enemy again rather than attacking at the same losing
|
||||
// fleet size on repeat. warMemorySeq is the highest event `seq`
|
||||
// (pushEvent, above) already scanned for 'combat' events (VegaAI.js's
|
||||
// updateWarMemory) — plain fields, not underscore-prefixed caches,
|
||||
// since unlike _comps/_range they aren't recomputable from current
|
||||
// tech and have to survive save/load.
|
||||
warMemory: {},
|
||||
warMemorySeq: 0,
|
||||
explored: {},
|
||||
spyPoints: 0,
|
||||
// otherIdx -> 'steal' | 'sabotage', missing entry means 'steal' (see
|
||||
|
|
@ -2882,6 +2899,14 @@ export function serialize(state) {
|
|||
export function deserialize(json) {
|
||||
const state = JSON.parse(json);
|
||||
if (state.version !== 1) return null;
|
||||
// A save from before pushEvent stamped events with `seq` has none on any
|
||||
// existing event — nextEventSeq starts at 0 either way (pushEvent uses
|
||||
// pre-increment), so the first NEW event in a reloaded old save gets seq 1,
|
||||
// safely past every un-seq'd historical event's implicit `undefined`
|
||||
// (always fails `ev.seq > cursor`, so old events are just never
|
||||
// war-memory-countable, same cold-start tradeoff as every other
|
||||
// back-filled field here).
|
||||
state.nextEventSeq ??= 0;
|
||||
for (const emp of state.empires) {
|
||||
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
|
||||
emp._designs = null; emp._designsAt = -1;
|
||||
|
|
@ -2896,6 +2921,8 @@ export function deserialize(json) {
|
|||
emp.lastBombardTurn ??= {};
|
||||
emp.fleetIntrusions ??= {};
|
||||
emp.espionageMission ??= {};
|
||||
emp.warMemory ??= {};
|
||||
emp.warMemorySeq ??= 0;
|
||||
}
|
||||
// Same convention for a save from before GNN existed — but a bare
|
||||
// `gnn ??= {history:[]}` is not enough here: state.events is never cleared
|
||||
|
|
|
|||
|
|
@ -225,6 +225,32 @@ export function openFormationPicker(scene, onPick, { closable = true } = {}) {
|
|||
return shell;
|
||||
}
|
||||
|
||||
/**
|
||||
* A forced one-button notice for a battle the player didn't start — an AI
|
||||
* empire attacking one of their fleets or colonies (MasterOfVegaGame.js's
|
||||
* playPlayerBattles, `attacked: true`). Skips the formation picker entirely:
|
||||
* VegaFormations.js's own header comment says the choice doesn't change how
|
||||
* a battle actually plays out yet, so asking the player to pick a doctrine
|
||||
* for a fight they didn't choose to start is a pointless extra click, not a
|
||||
* real decision (Brian's ask, 2026-08-14). Forced (closable: false) for the
|
||||
* same reason the formation picker is — nothing to cancel, the fleets are
|
||||
* already in contact.
|
||||
*/
|
||||
export function openAttackNotice(scene, { attackerName, starName }, onOk) {
|
||||
const shell = modalShell(scene, 'Under Attack', null, { width: 780, height: 280, closable: false });
|
||||
const { body } = shell;
|
||||
shell.add(scene.add.text(body.x + body.w / 2, body.y + body.h / 2 - 20,
|
||||
`${attackerName} attacks you at ${starName}.`, {
|
||||
fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', align: 'center', wordWrap: { width: body.w },
|
||||
}).setOrigin(0.5));
|
||||
const btn = new Button(scene, body.x + body.w / 2, body.y + body.h - 34, 'OK', uiClick(scene, () => {
|
||||
shell.destroy();
|
||||
onOk();
|
||||
}), { width: 160, height: 48, fontSize: 18 });
|
||||
shell.add(btn);
|
||||
return shell;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Research has its own file, VegaResearchScreen.js — it grew past this
|
||||
// module's shared factory-function pattern the same way Audience and the
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ export const SFX = {
|
|||
VEGA_MISSILE_LAUNCH_67: 'sfx-vega-missle-launch-67',
|
||||
VEGA_MISSILE_HIT_12345: 'sfx-vega-missle-hit-12345',
|
||||
VEGA_MISSILE_HIT_67: 'sfx-vega-missle-hit-67',
|
||||
// Planetary defence battery fire — VegaCombatViewV2.js's handleEvents
|
||||
// plays this instead of a weaponSfxKey() band whenever the shooter is the
|
||||
// planet (ev.weaponKind is null for a planet's fire events, since it has
|
||||
// no weapon mount to band by Mark).
|
||||
VEGA_PLANET_BEAM: 'sfx-vega-planet-beam',
|
||||
// One destroy cue per warship class — see VegaCombatView.js's/
|
||||
// VegaCombatViewV2.js's destroySfxKey(). Only the four combatant hulls
|
||||
// (frigate/destroyer/cruiser/battleship) have a clip; other hulls play no
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,39 @@ section('5. Combat');
|
|||
{ defenseHp: 600, shieldBonus: 5 }), 60);
|
||||
check('planetary defences make a difference', defended <= undefended, `${defended} vs ${undefended}`);
|
||||
|
||||
// Planet damage is pure proportional to current defenseHp — no flat floor
|
||||
// (Brian's ask, 2026-08-14: the old formula's large flat +20 base meant a
|
||||
// colony at 1/100 defenseHp fought back nearly as hard as one at its cap,
|
||||
// reported as "attacked with the strength of a much-better-defended
|
||||
// planet"). Checked numerically against createBattle's own planet entity
|
||||
// rather than just a win-rate proxy, since that is the exact value this
|
||||
// was miscalibrated on.
|
||||
{
|
||||
const bLow = Combat.createBattle(RULES, {
|
||||
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||||
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 5), ships: [] },
|
||||
colony: { defenseHp: 5, shieldBonus: 0 }, rnd: mulberry32(1),
|
||||
});
|
||||
const bHigh = Combat.createBattle(RULES, {
|
||||
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
|
||||
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 5), ships: [] },
|
||||
colony: { defenseHp: 600, shieldBonus: 0 }, rnd: mulberry32(1),
|
||||
});
|
||||
check('planet damage matches defenseHp * planetDefenseScale exactly',
|
||||
Math.abs(bLow.planet.damage - 5 * RULES.combat.planetDefenseScale) < 1e-9
|
||||
&& Math.abs(bHigh.planet.damage - 600 * RULES.combat.planetDefenseScale) < 1e-9,
|
||||
`${bLow.planet.damage} / ${bHigh.planet.damage}`);
|
||||
check('a near-dead colony hits far softer than a well-defended one, not roughly the same',
|
||||
bHigh.planet.damage > bLow.planet.damage * 10, `${bLow.planet.damage} vs ${bHigh.planet.damage}`);
|
||||
|
||||
const lowDefRate = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [],
|
||||
s, { defenseHp: 5, shieldBonus: 0 }), 60);
|
||||
const highDefRate = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [],
|
||||
s, { defenseHp: 600, shieldBonus: 0 }), 60);
|
||||
check('a nearly-depleted colony is a softer target than a well-defended one in actual play',
|
||||
lowDefRate >= highDefRate, `${lowDefRate} vs ${highDefRate}`);
|
||||
}
|
||||
|
||||
// Ground combat.
|
||||
const inv = Combat.resolveInvasion(RULES, mulberry32(7), 60, 50, { groundDefense: 0 }, 0, 40);
|
||||
check('a large invasion force takes a lightly held world', inv.captured);
|
||||
|
|
@ -1800,6 +1833,108 @@ section('5b. Deferred battles (human-visible combat)');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5c. AI attack-fleet escalation');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brian's ask, 2026-08-14: an AI empire that keeps losing small attacks
|
||||
// against the same enemy should wait and mass a bigger fleet instead of
|
||||
// feeding in the next ship the moment it locally outguns the target, rather
|
||||
// than dribbling out one-and-two-ship attacks forever. VegaAI.js's
|
||||
// updateWarMemory/attackMultiplier aren't exported (private to the AI
|
||||
// controller, same as everything else in that file except runAITurn), so
|
||||
// this drives the real decision through runAITurn itself and reads the
|
||||
// observable results: state.empires[e].warMemory, and whether the attack
|
||||
// fleet actually moved.
|
||||
{
|
||||
// A deliberately synthetic setup: small (so the two homeworlds are within
|
||||
// reach on a Thorium Fuel Cells grant), a big "threat" fleet parked at a
|
||||
// THIRD star (not the target colony — sitting it at the target would add
|
||||
// its power to that colony's own guard and confound the test) to push
|
||||
// computeStrategy into 'war' phase, and the target colony's defenseHp
|
||||
// hand-set so the attacking fleet's power lands in an exact, chosen ratio
|
||||
// to its guard (guard = defenseHp * 0.4, no defending fleet of its own).
|
||||
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);
|
||||
Logic.addFleet(RULES, st, 1, elsewhere, [{ hullId: 'battleship', mark: 1, count: 5 }]);
|
||||
const fleet = Logic.addFleet(RULES, st, 0, home0, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
|
||||
const defCol = st.colonies.find((c) => c.starIdx === home1);
|
||||
const dock = () => { fleet.starIdx = home0; fleet.toStar = -1; fleet.fromStar = -1; fleet.progress = 0; fleet.total = 0; };
|
||||
// Mirrors VegaLogic.js's own (unexported) pushEvent exactly — a synthetic
|
||||
// combat event pushed straight onto state.events with no `seq` would never
|
||||
// satisfy `ev.seq > cursor` (undefined comparisons are always false) and
|
||||
// so would either be silently ignored or, worse, reprocessed every call.
|
||||
const pushCombat = (winner, defenderIdx = 1) => {
|
||||
st.events.push({
|
||||
type: 'combat', starIdx: home1, attacker: 0, defender: defenderIdx, winner,
|
||||
turn: st.turn, seq: st.nextEventSeq += 1,
|
||||
});
|
||||
};
|
||||
|
||||
// --- fresh (no loss history): attacks at plain parity, same as before this
|
||||
// feature existed.
|
||||
{
|
||||
const power = Logic.fleetPower(RULES, st, fleet);
|
||||
defCol.defenseHp = Math.round((power * 0.9) / 0.4); // guard = 0.9x power: clears at 1x, fails at 1.5x+
|
||||
Logic.beginEmpireTurn(RULES, st, 0);
|
||||
AI.runAITurn(RULES, st, 0);
|
||||
check('a fresh empire (no loss history) still attacks a beatable target', fleet.toStar === home1);
|
||||
}
|
||||
|
||||
// --- two straight losses against this enemy: the SAME fleet/target ratio
|
||||
// (still only 0.9x guard headroom) now falls short of the escalated 1.5x
|
||||
// bar, so it waits instead of attacking again at the same losing size.
|
||||
{
|
||||
dock();
|
||||
st.turn = 3;
|
||||
pushCombat('defender');
|
||||
pushCombat('defender');
|
||||
Logic.beginEmpireTurn(RULES, st, 0);
|
||||
AI.runAITurn(RULES, st, 0);
|
||||
check('two straight losses are tallied into warMemory', st.empires[0].warMemory[1]?.losses === 2);
|
||||
check('with two losses on the books, the same marginal fleet no longer attacks',
|
||||
fleet.toStar < 0 && fleet.starIdx === home0);
|
||||
}
|
||||
|
||||
// --- a win against this enemy resets the streak, and the same marginal
|
||||
// fleet is willing to attack again immediately.
|
||||
{
|
||||
st.turn = 4;
|
||||
pushCombat('attacker');
|
||||
Logic.beginEmpireTurn(RULES, st, 0);
|
||||
AI.runAITurn(RULES, st, 0);
|
||||
check('a win resets the loss streak to zero', st.empires[0].warMemory[1].losses === 0);
|
||||
check('with the streak reset, the same marginal fleet attacks again', fleet.toStar === home1);
|
||||
}
|
||||
|
||||
// --- escalation is capped: even after many more losses, a fleet strong
|
||||
// enough to clear 3x guard still attacks — if the multiplier grew without
|
||||
// bound (e.g. to 5.5x at 10 losses) this same fleet/guard ratio would fail.
|
||||
{
|
||||
dock();
|
||||
st.turn = 5;
|
||||
for (let i = 0; i < 10; i += 1) pushCombat('defender');
|
||||
st.turn = 16;
|
||||
fleet.ships[0].count = 20;
|
||||
const power = Logic.fleetPower(RULES, st, fleet);
|
||||
defCol.defenseHp = Math.round((power / 3 * 0.95) / 0.4); // guard = power / 3.16: clears a 3x cap, would fail an uncapped 5.5x
|
||||
Logic.beginEmpireTurn(RULES, st, 0);
|
||||
AI.runAITurn(RULES, st, 0);
|
||||
check('ten losses are tallied', st.empires[0].warMemory[1].losses === 10);
|
||||
check('the escalation multiplier caps at 3x rather than growing without bound',
|
||||
fleet.toStar === home1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6. Colony economy');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue