feat(mastervega): add Galactic Expansion/Expansion advisor focuses and fleet urgency system
- Add "Galactic Expansion" colony focus and "Expansion" allocation focus for early-game land grab phase (one colony ship per discovered open world, then 2 frigate escorts per colony ship) - Introduce fleetUrgency() — sliding-scale fleet recommendation based on the coldest contacted relationship, throttled pre-contact to avoid premature militarization - Hide Council menu button until the Council has actually convened (lastResult) - Add tooltip to disabled population send button explaining why it's off - Rename "Production" allocation preset to "Industrial Buildout" - Expand Colony Improvement recommendation to fire when factories are well below cap, not just near cap - Add comprehensive tests for all new logic paths
This commit is contained in:
parent
5ce7fc2367
commit
c8e22a51a5
|
|
@ -821,9 +821,16 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
onChanged: () => this.refreshAll(),
|
onChanged: () => this.refreshAll(),
|
||||||
onClose: done,
|
onClose: done,
|
||||||
}))],
|
}))],
|
||||||
['Council', () => this.openModal((done) =>
|
|
||||||
openCouncilScreen(this, this.rules, this.state, done))],
|
|
||||||
];
|
];
|
||||||
|
// The Council only earns a spot in this menu once it has actually
|
||||||
|
// convened for the first time — before that, `lastResult` is still null
|
||||||
|
// (VegaLogic.js's createGame) and the button would just open onto
|
||||||
|
// openCouncilScreen's "The Council has not yet convened" empty state
|
||||||
|
// (Brian's ask, 2026-08-14).
|
||||||
|
if (this.state.council.lastResult) {
|
||||||
|
items.push(['Council', () => this.openModal((done) =>
|
||||||
|
openCouncilScreen(this, this.rules, this.state, done))]);
|
||||||
|
}
|
||||||
|
|
||||||
const BTN_W = 200;
|
const BTN_W = 200;
|
||||||
const BTN_H = 42;
|
const BTN_H = 42;
|
||||||
|
|
|
||||||
|
|
@ -741,12 +741,40 @@ export function openColoniesScreen(scene, rules, state, e, art, opts = {}) {
|
||||||
(p) => openFocusDropdown(colony, 'alloc', p.x, p.y), { fontSize: 14 });
|
(p) => openFocusDropdown(colony, 'alloc', p.x, p.y), { fontSize: 14 });
|
||||||
|
|
||||||
const canSend = maxSendablePopulation(colony) > 0 && empireColonies(state, e).length > 1;
|
const canSend = maxSendablePopulation(colony) > 0 && empireColonies(state, e).length > 1;
|
||||||
pill(scroller.content, COL_SEND, y + 10, 44, 40, '⇄', armed ? '#0b1220' : '#cfe8ff',
|
const sendPill = pill(scroller.content, COL_SEND, y + 10, 44, 40, '⇄', armed ? '#0b1220' : '#cfe8ff',
|
||||||
armed ? 0xffd88a : 0x16253c, () => {
|
armed ? 0xffd88a : 0x16253c, () => {
|
||||||
armedSourceId = armed ? null : colony.id;
|
armedSourceId = armed ? null : colony.id;
|
||||||
refreshBanner();
|
refreshBanner();
|
||||||
rebuildRows();
|
rebuildRows();
|
||||||
}, { enabled: canSend, fontSize: 18 });
|
}, { enabled: canSend, fontSize: 18 });
|
||||||
|
// pill() only calls setInteractive() when enabled — done here too so a
|
||||||
|
// disabled send button can still explain over a tooltip WHY it's off.
|
||||||
|
if (!canSend) sendPill.setInteractive();
|
||||||
|
tooltip.attachTo(sendPill, () => ({
|
||||||
|
title: 'Send Population',
|
||||||
|
lines: [
|
||||||
|
{ text: "Ships part of this colony's population to another colony you own." },
|
||||||
|
!canSend
|
||||||
|
? {
|
||||||
|
text: empireColonies(state, e).length <= 1
|
||||||
|
? 'You need at least one other colony to send population to.'
|
||||||
|
: 'Too little population here to spare any — at least 0.5 must stay behind.',
|
||||||
|
color: '#e08a8a',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: armed
|
||||||
|
? 'Armed — click a destination colony now, or click this again to cancel.'
|
||||||
|
: `Click to arm this colony as the source, then click another colony's row to pick `
|
||||||
|
+ `an amount and send it. Up to ${maxSendablePopulation(colony).toFixed(1)} available.`,
|
||||||
|
color: '#9fd8ff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Travels as an unescorted transport — arrival takes time based on distance, and "
|
||||||
|
+ "anything beyond the destination's own population cap is wasted, not returned.",
|
||||||
|
color: '#6f8aa3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
pill(scroller.content, COL_MANAGE, y + 10, 100, 40, 'Manage', '#cfe8ff', 0x16253c,
|
pill(scroller.content, COL_MANAGE, y + 10, 100, 40, 'Manage', '#cfe8ff', 0x16253c,
|
||||||
() => openManage(colony));
|
() => openManage(colony));
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export const CHANNEL_COLOUR = {
|
||||||
// actually does; this is just the picker's label/description copy.
|
// actually does; this is just the picker's label/description copy.
|
||||||
export const COLONY_FOCUS_OPTIONS = [
|
export const COLONY_FOCUS_OPTIONS = [
|
||||||
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
|
{ value: 'manual', label: 'Manual', desc: 'No automation — you queue everything yourself.' },
|
||||||
|
{ value: 'expansion', label: 'Galactic Expansion', desc: 'Builds a colony ship for every known unclaimed world, then two frigate escorts per colony ship in the fleet once that demand is covered.' },
|
||||||
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
|
{ value: 'improvement', label: 'Colony Improvement', desc: 'Industry buildings first, then any other building not yet built.' },
|
||||||
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
|
{ value: 'research', label: 'Research Focus', desc: 'Only queues research buildings. Once none are left, the queue stays empty and construction spills into research.' },
|
||||||
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' },
|
{ value: 'fleet', label: 'Fleet Production', desc: 'Builds a diversified warship fleet at this system, targeting a 4:3:2:1 mix of frigates : destroyers : cruisers : battleships.' },
|
||||||
|
|
@ -96,8 +97,12 @@ export const ALLOCATION_FOCUS_OPTIONS = [
|
||||||
{ key: 'default', label: 'Default', sliders: { ships: 0.20, defense: 0.10, industry: 0.40, ecology: 0.10, research: 0.20 } },
|
{ key: 'default', label: 'Default', sliders: { ships: 0.20, defense: 0.10, industry: 0.40, ecology: 0.10, research: 0.20 } },
|
||||||
{ key: 'research', label: 'Research Focus', sliders: { ships: 0.10, defense: 0.05, industry: 0.20, ecology: 0.15, research: 0.50 } },
|
{ key: 'research', label: 'Research Focus', sliders: { ships: 0.10, defense: 0.05, industry: 0.20, ecology: 0.15, research: 0.50 } },
|
||||||
{ key: 'growth', label: 'Population Growth', sliders: { ships: 0.15, defense: 0.10, industry: 0.30, ecology: 0.35, research: 0.10 } },
|
{ key: 'growth', label: 'Population Growth', sliders: { ships: 0.15, defense: 0.10, industry: 0.30, ecology: 0.35, research: 0.10 } },
|
||||||
{ key: 'production', label: 'Production', sliders: { ships: 0.20, defense: 0.05, industry: 0.55, ecology: 0.10, research: 0.10 } },
|
{ key: 'production', label: 'Industrial Buildout', sliders: { ships: 0.20, defense: 0.05, industry: 0.55, ecology: 0.10, research: 0.10 } },
|
||||||
{ key: 'military', label: 'Military Buildup', sliders: { ships: 0.45, defense: 0.25, industry: 0.15, ecology: 0.10, research: 0.05 } },
|
{ key: 'military', label: 'Military Buildup', sliders: { ships: 0.45, defense: 0.25, industry: 0.15, ecology: 0.10, research: 0.05 } },
|
||||||
|
// Military Buildup with its Defence share folded into Construction instead
|
||||||
|
// — Expansion wants colony ships and their frigate escorts (both built via
|
||||||
|
// the ships/Construction channel), not planetary batteries.
|
||||||
|
{ key: 'expansion', label: 'Expansion', sliders: { ships: 0.70, defense: 0.00, industry: 0.15, ecology: 0.10, research: 0.05 } },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const etaText = (turns) => (Number.isFinite(turns)
|
export const etaText = (turns) => (Number.isFinite(turns)
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,41 @@ export function habitableForEmpire(rules, state, e, starIdx, orbit) {
|
||||||
return type.hostility <= comps.colonizeHostility;
|
return type.hostility <= comps.colonizeHostility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many colonizable worlds this empire has actually SEEN (explored[])
|
||||||
|
// that nobody has claimed yet — the "Galactic Expansion" Colony Focus's
|
||||||
|
// build target, and what the advisor recommendations gauge "is the galaxy
|
||||||
|
// still open" against. Deliberately explored-gated rather than every
|
||||||
|
// canColonize world in the galaxy (which would recommend expansion into
|
||||||
|
// worlds the player has no idea exist yet), and deliberately NOT further
|
||||||
|
// gated by current fuel range like VegaAI.js's own targeting scan — a world
|
||||||
|
// just past today's range is still "available" once a colony ship reaches
|
||||||
|
// it, and fuel range only grows with tech.
|
||||||
|
export function discoveredOpenWorlds(rules, state, e) {
|
||||||
|
const emp = state.empires[e];
|
||||||
|
let count = 0;
|
||||||
|
for (let starIdx = 0; starIdx < state.galaxy.stars.length; starIdx += 1) {
|
||||||
|
if (!emp.explored[starIdx]) continue;
|
||||||
|
const star = state.galaxy.stars[starIdx];
|
||||||
|
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
|
||||||
|
if (canColonize(rules, state, e, starIdx, orbit)) count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether "Galactic Expansion" (Colony Focus) / "Expansion" (Allocation
|
||||||
|
// Focus) should be the advisors' top pick right now (Brian's ask,
|
||||||
|
// 2026-08-14): early game, before any rival has been met, land grabs are
|
||||||
|
// unambiguously the right call — but the instant either condition flips
|
||||||
|
// (first contact, or every known world already claimed) it drops out of the
|
||||||
|
// running entirely rather than fading gradually, so the player gets a clean
|
||||||
|
// "that phase is over" signal instead of a slowly-diminishing nudge.
|
||||||
|
function expansionFavored(rules, state, e) {
|
||||||
|
const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
|
||||||
|
const openTargets = discoveredOpenWorlds(rules, state, e);
|
||||||
|
return { favored: !hasMetAnyone && openTargets > 0, openTargets };
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Range — the star map's "range as light", and the engine's movement rule.
|
// Range — the star map's "range as light", and the engine's movement rule.
|
||||||
|
|
||||||
|
|
@ -2174,10 +2209,53 @@ function pickFleet(rules, state, colony) {
|
||||||
enqueue(rules, state, colony, 'ship', best ?? 'frigate');
|
enqueue(rules, state, colony, 'ship', best ?? 'frigate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many of `hullId` this empire already has committed, empire-wide: built
|
||||||
|
// and sitting in a fleet, PLUS still under construction in any colony's
|
||||||
|
// queue. Counting the queue too keeps every expansion-focused colony reading
|
||||||
|
// the same live total instead of each one independently topping up toward
|
||||||
|
// the same target the instant its own queue goes empty — pickExpansion calls
|
||||||
|
// this every time it is asked for the next item, so double-committing would
|
||||||
|
// otherwise be the default outcome, not an edge case.
|
||||||
|
function empireHullCommitment(state, e, hullId) {
|
||||||
|
let n = 0;
|
||||||
|
for (const f of empireFleets(state, e)) {
|
||||||
|
for (const s of f.ships) if (s.hullId === hullId) n += s.count;
|
||||||
|
}
|
||||||
|
for (const colony of empireColonies(state, e)) {
|
||||||
|
for (const item of colony.queue) if (item.kind === 'ship' && item.id === hullId) n += 1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colony Focus: Galactic Expansion (Brian's ask, 2026-08-14). One colony
|
||||||
|
// ship for every discovered-but-unclaimed world (discoveredOpenWorlds), then
|
||||||
|
// — once that demand is covered — a standing 2-frigates-per-colony-ship
|
||||||
|
// escort ratio empire-wide. This is a RATIO, not a literal per-ship escort
|
||||||
|
// assignment (mastervega has no ship-to-ship escort tagging); "two frigates
|
||||||
|
// exist for every colony ship in the fleet" is what satisfies it. No
|
||||||
|
// fallback once both targets are met, same as pickResearchOnly — an empty
|
||||||
|
// queue is correct and processColony spills the unspent BC into research.
|
||||||
|
function pickExpansion(rules, state, colony) {
|
||||||
|
const e = colony.empireIdx;
|
||||||
|
const openTargets = discoveredOpenWorlds(rules, state, e);
|
||||||
|
if (empireHullCommitment(state, e, 'colonyship') < openTargets) {
|
||||||
|
enqueue(rules, state, colony, 'ship', 'colonyship');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let colonyShipsBuilt = 0;
|
||||||
|
for (const f of empireFleets(state, e)) {
|
||||||
|
for (const s of f.ships) if (s.hullId === 'colonyship') colonyShipsBuilt += s.count;
|
||||||
|
}
|
||||||
|
if (empireHullCommitment(state, e, 'frigate') < colonyShipsBuilt * 2) {
|
||||||
|
enqueue(rules, state, colony, 'ship', 'frigate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FOCUS_PICKERS = {
|
const FOCUS_PICKERS = {
|
||||||
improvement: pickImprovement,
|
improvement: pickImprovement,
|
||||||
research: pickResearchOnly,
|
research: pickResearchOnly,
|
||||||
fleet: pickFleet,
|
fleet: pickFleet,
|
||||||
|
expansion: pickExpansion,
|
||||||
growth: pickGrowth,
|
growth: pickGrowth,
|
||||||
trade: pickTrade,
|
trade: pickTrade,
|
||||||
defense: pickDefense,
|
defense: pickDefense,
|
||||||
|
|
@ -2218,6 +2296,33 @@ export function empireFleetPower(rules, state, e) {
|
||||||
// reused rather than inventing a separate number.
|
// reused rather than inventing a separate number.
|
||||||
const fleetAdequacyThreshold = (state) => 400 + state.turn * 4;
|
const fleetAdequacyThreshold = (state) => 400 + state.turn * 4;
|
||||||
|
|
||||||
|
// How urgently the advisors push "Fleet Production" (recommendColonyFocus's
|
||||||
|
// fleet rung) — a multiplier on fleetAdequacyThreshold rather than a flat
|
||||||
|
// yes/no, so it fires on a sliding bar instead of ever getting stuck fully
|
||||||
|
// on or off. Before first contact there is nobody to threaten the empire or
|
||||||
|
// be threatened by, so the rung is throttled hard rather than firing off the
|
||||||
|
// raw baseline alone (Brian's ask). Once contact exists, the COLDEST
|
||||||
|
// contacted relationship sets the tone rather than the average — one
|
||||||
|
// species turning hostile matters more than three others staying friendly —
|
||||||
|
// so a fleet the player hasn't needed yet can suddenly read as advisable
|
||||||
|
// again, and eases back off once every contact is warm. Mirrors
|
||||||
|
// VegaDiplomacy.js's moodOf tiers (-60/-20/20/60) without importing it:
|
||||||
|
// VegaDiplomacy.js imports FROM this file, so the reverse import would be
|
||||||
|
// circular.
|
||||||
|
export function fleetUrgency(state, e) {
|
||||||
|
const others = state.empires.filter((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
|
||||||
|
if (!others.length) return { multiplier: 0.25, threat: null };
|
||||||
|
let coldest = others[0];
|
||||||
|
for (const o of others) {
|
||||||
|
if ((o.attitude[e] ?? 0) < (coldest.attitude[e] ?? 0)) coldest = o;
|
||||||
|
}
|
||||||
|
const att = coldest.attitude[e] ?? 0;
|
||||||
|
if (att <= -60) return { multiplier: 1.5, threat: { empire: coldest, moodWord: 'hostile' } };
|
||||||
|
if (att <= -20) return { multiplier: 1.15, threat: { empire: coldest, moodWord: 'cold' } };
|
||||||
|
if (att < 20) return { multiplier: 0.85, threat: null };
|
||||||
|
return { multiplier: 0.5, threat: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First applicable rung of a priority ladder — deliberately mirrors
|
* First applicable rung of a priority ladder — deliberately mirrors
|
||||||
* FOCUS_PICKERS' order, but reports the first CATEGORY that applies rather
|
* FOCUS_PICKERS' order, but reports the first CATEGORY that applies rather
|
||||||
|
|
@ -2232,32 +2337,60 @@ export function recommendColonyFocus(rules, state, colony) {
|
||||||
const maxPop = colonyMaxPop(rules, state, colony);
|
const maxPop = colonyMaxPop(rules, state, colony);
|
||||||
const e = colony.empireIdx;
|
const e = colony.empireIdx;
|
||||||
|
|
||||||
|
const expansion = expansionFavored(rules, state, e);
|
||||||
|
if (expansion.favored) {
|
||||||
|
return {
|
||||||
|
value: 'expansion', label: 'Galactic Expansion',
|
||||||
|
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
|
||||||
|
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
|
||||||
|
+ 'grab territory now while the galaxy is still open.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const growthId = firstEligibleBuildingId(rules, state, colony, growthBuildingIds(rules));
|
const growthId = firstEligibleBuildingId(rules, state, colony, growthBuildingIds(rules));
|
||||||
if (colony.pop < maxPop * 0.7 && growthId) {
|
if (colony.pop < maxPop * 0.7 && growthId) {
|
||||||
const pct = Math.round((colony.pop / maxPop) * 100);
|
const pct = Math.round((colony.pop / maxPop) * 100);
|
||||||
return {
|
return {
|
||||||
value: 'growth', label: 'Population Growth',
|
value: 'growth', label: 'Population Growth',
|
||||||
reason: `This ${type.name.toLowerCase()} world is at ${pct}% of its population ceiling — `
|
reason: `This ${type.name.toLowerCase()} world is at ${pct}% of its population ceiling — `
|
||||||
+ `a ${rules.buildings[growthId].name} would raise it.`,
|
+ `a ${rules.buildings[growthId].name} would raise it before this colony turns to military objectives.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two separate reasons to recommend Colony Improvement here, both ahead of
|
||||||
|
// Fleet Production in this ladder: factories deep in the red (< 70% of cap
|
||||||
|
// — Brian's ask, 2026-08-14, same "red" line recommendAllocationFocus's
|
||||||
|
// own Industrial Buildout rung already used) mean this colony hasn't built
|
||||||
|
// out its own infrastructure yet and shouldn't be steered toward warships
|
||||||
|
// before it has; factories nearly AT cap (>= 90%) is the opposite
|
||||||
|
// situation — capacity is about to run out and another building keeps
|
||||||
|
// growth going. Both point at the same fix, so they share one rung.
|
||||||
const industryId = firstEligibleBuildingId(rules, state, colony, industryBuildingIds(rules));
|
const industryId = firstEligibleBuildingId(rules, state, colony, industryBuildingIds(rules));
|
||||||
const factoryCap = colonyFactoryCap(rules, state, colony);
|
const factoryCap = colonyFactoryCap(rules, state, colony);
|
||||||
const effF = effectiveFactories(rules, state, colony);
|
const effF = effectiveFactories(rules, state, colony);
|
||||||
if (effF >= factoryCap * 0.9 && industryId) {
|
const factoriesBehind = effF < factoryCap * 0.7;
|
||||||
|
const factoriesNearCap = effF >= factoryCap * 0.9;
|
||||||
|
if ((factoriesBehind || factoriesNearCap) && industryId) {
|
||||||
return {
|
return {
|
||||||
value: 'improvement', label: 'Colony Improvement',
|
value: 'improvement', label: 'Colony Improvement',
|
||||||
reason: `Factories are running at ${Math.floor(effF)}/${factoryCap} — `
|
reason: factoriesBehind
|
||||||
+ `a ${rules.buildings[industryId].name} would raise your production ceiling.`,
|
? `Factories are well below your cap (${Math.floor(effF)}/${factoryCap}) — build up this colony's `
|
||||||
|
+ `infrastructure with a ${rules.buildings[industryId].name} before turning toward military objectives.`
|
||||||
|
: `Factories are running at ${Math.floor(effF)}/${factoryCap} — `
|
||||||
|
+ `a ${rules.buildings[industryId].name} would raise your production ceiling.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) {
|
const { multiplier: fleetMultiplier, threat } = fleetUrgency(state, e);
|
||||||
|
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state) * fleetMultiplier) {
|
||||||
return {
|
return {
|
||||||
value: 'fleet', label: 'Fleet Production',
|
value: 'fleet', label: 'Fleet Production',
|
||||||
reason: "Empire-wide fleet strength is below what's typical this far into the game — "
|
reason: threat
|
||||||
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
|
? `Empire-wide fleet strength is below what's typical this far into the game, and relations `
|
||||||
|
+ `with ${threat.empire.name} have turned ${threat.moodWord} — additional warships would help, `
|
||||||
|
+ 'built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.'
|
||||||
|
: "Empire-wide fleet strength is below what's typical this far into the game — "
|
||||||
|
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2312,6 +2445,16 @@ export function recommendAllocationFocus(rules, state, colony) {
|
||||||
const effF = effectiveFactories(rules, state, colony);
|
const effF = effectiveFactories(rules, state, colony);
|
||||||
const e = colony.empireIdx;
|
const e = colony.empireIdx;
|
||||||
|
|
||||||
|
const expansion = expansionFavored(rules, state, e);
|
||||||
|
if (expansion.favored) {
|
||||||
|
return {
|
||||||
|
key: 'expansion', label: 'Expansion',
|
||||||
|
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
|
||||||
|
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
|
||||||
|
+ 'fund colony ships and their escorts over everything else.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (colony.pop < maxPop * 0.7 || colony.waste > 3) {
|
if (colony.pop < maxPop * 0.7 || colony.waste > 3) {
|
||||||
return {
|
return {
|
||||||
key: 'growth', label: 'Population Growth',
|
key: 'growth', label: 'Population Growth',
|
||||||
|
|
@ -2325,7 +2468,7 @@ export function recommendAllocationFocus(rules, state, colony) {
|
||||||
|
|
||||||
if (effF < factoryCap * 0.7) {
|
if (effF < factoryCap * 0.7) {
|
||||||
return {
|
return {
|
||||||
key: 'production', label: 'Production',
|
key: 'production', label: 'Industrial Buildout',
|
||||||
reason: `Factories are still well below your cap (${Math.floor(effF)}/${factoryCap}) — `
|
reason: `Factories are still well below your cap (${Math.floor(effF)}/${factoryCap}) — `
|
||||||
+ 'more Industry funding builds them out faster.',
|
+ 'more Industry funding builds them out faster.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1858,8 +1858,8 @@ section('6. Colony economy');
|
||||||
st2.rules = RULES;
|
st2.rules = RULES;
|
||||||
const c = st2.colonies[0];
|
const c = st2.colonies[0];
|
||||||
const emp2 = st2.empires[c.empireIdx];
|
const emp2 = st2.empires[c.empireIdx];
|
||||||
const COLONY_FOCUS_VALUES = new Set(['manual', 'improvement', 'research', 'fleet', 'growth', 'trade', 'defense']);
|
const COLONY_FOCUS_VALUES = new Set(['manual', 'expansion', 'improvement', 'research', 'fleet', 'growth', 'trade', 'defense']);
|
||||||
const ALLOC_FOCUS_KEYS = new Set(['default', 'research', 'growth', 'production', 'military']);
|
const ALLOC_FOCUS_KEYS = new Set(['default', 'research', 'growth', 'production', 'military', 'expansion']);
|
||||||
|
|
||||||
check('a fresh colony has no advisor tracking armed yet',
|
check('a fresh colony has no advisor tracking armed yet',
|
||||||
c.advisor.focusQuietUntil === null && c.advisor.allocQuietUntil === null
|
c.advisor.focusQuietUntil === null && c.advisor.allocQuietUntil === null
|
||||||
|
|
@ -1882,6 +1882,20 @@ section('6. Colony economy');
|
||||||
c.factories = factoryCap;
|
c.factories = factoryCap;
|
||||||
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||||||
check('capped factories recommend Colony Improvement', rec.value === 'improvement', rec.value);
|
check('capped factories recommend Colony Improvement', rec.value === 'improvement', rec.value);
|
||||||
|
|
||||||
|
// Factories deep in the red (well below cap) recommend Colony
|
||||||
|
// Improvement too — Brian's ask, 2026-08-14: infrastructure catch-up
|
||||||
|
// takes priority over military on both ladders. recommendAllocationFocus
|
||||||
|
// already had this via its Industrial Buildout rung; recommendColonyFocus
|
||||||
|
// previously only fired its improvement rung near the cap, never below it.
|
||||||
|
c.factories = Math.round(factoryCap * 0.5);
|
||||||
|
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||||||
|
check('factories well below cap recommend Colony Improvement, not Fleet Production',
|
||||||
|
rec.value === 'improvement', rec.value);
|
||||||
|
recA = Logic.recommendAllocationFocus(RULES, st2, c);
|
||||||
|
check('factories well below cap recommend Industrial Buildout, not Military Buildup',
|
||||||
|
recA.key === 'production', recA.key);
|
||||||
|
|
||||||
// Neither "capped" (colony focus) nor "still building out" (allocation
|
// Neither "capped" (colony focus) nor "still building out" (allocation
|
||||||
// focus) — clears both factory rungs so the fleet check below is the
|
// focus) — clears both factory rungs so the fleet check below is the
|
||||||
// first thing either ladder actually trips on.
|
// first thing either ladder actually trips on.
|
||||||
|
|
@ -1894,6 +1908,49 @@ section('6. Colony economy');
|
||||||
check('a fleetless empire this early recommends the Military Buildup allocation preset',
|
check('a fleetless empire this early recommends the Military Buildup allocation preset',
|
||||||
recA.key === 'military', recA.key);
|
recA.key === 'military', recA.key);
|
||||||
|
|
||||||
|
// fleetUrgency: how hard recommendColonyFocus's fleet rung pushes,
|
||||||
|
// gauged off the COLDEST contacted relationship rather than fleet power
|
||||||
|
// alone (Brian's ask, 2026-08-14). Tested directly rather than through
|
||||||
|
// recommendColonyFocus's power/threshold comparison, which would need a
|
||||||
|
// fragile hand-picked fleet size to land in the right band.
|
||||||
|
{
|
||||||
|
const other = st2.empires[1]; // kkrix, per this block's speciesIds order
|
||||||
|
let urg = Logic.fleetUrgency(st2, c.empireIdx);
|
||||||
|
check('with nobody met yet, fleet-production urgency is throttled well below baseline',
|
||||||
|
urg.multiplier < 0.5 && urg.threat === null, JSON.stringify(urg));
|
||||||
|
|
||||||
|
emp2.contacted[other.idx] = true;
|
||||||
|
other.contacted[emp2.idx] = true;
|
||||||
|
other.attitude[emp2.idx] = 0;
|
||||||
|
urg = Logic.fleetUrgency(st2, c.empireIdx);
|
||||||
|
check('a single neutral contact no longer throttles urgency to the pre-contact floor',
|
||||||
|
urg.multiplier > 0.5 && urg.threat === null, JSON.stringify(urg));
|
||||||
|
|
||||||
|
other.attitude[emp2.idx] = -70;
|
||||||
|
urg = Logic.fleetUrgency(st2, c.empireIdx);
|
||||||
|
check('a hostile contact raises urgency above the plain baseline',
|
||||||
|
urg.multiplier > 1 && urg.threat?.moodWord === 'hostile' && urg.threat.empire.idx === other.idx,
|
||||||
|
JSON.stringify(urg));
|
||||||
|
|
||||||
|
other.attitude[emp2.idx] = -30;
|
||||||
|
urg = Logic.fleetUrgency(st2, c.empireIdx);
|
||||||
|
check('a merely cold contact raises urgency less than an outright hostile one',
|
||||||
|
urg.multiplier > 1 && urg.multiplier < 1.5 && urg.threat?.moodWord === 'cold', JSON.stringify(urg));
|
||||||
|
|
||||||
|
other.attitude[emp2.idx] = 40;
|
||||||
|
urg = Logic.fleetUrgency(st2, c.empireIdx);
|
||||||
|
check('an all-warm galaxy lowers urgency below the plain baseline',
|
||||||
|
urg.multiplier < 1 && urg.threat === null, JSON.stringify(urg));
|
||||||
|
|
||||||
|
// Undo contact/attitude entirely so later checks in this block (which
|
||||||
|
// reuse st2/c, including the still-no-contact Galactic Expansion
|
||||||
|
// checks further down) aren't reading a met, warmed-up galaxy by
|
||||||
|
// accident.
|
||||||
|
other.attitude[emp2.idx] = 0;
|
||||||
|
emp2.contacted[other.idx] = false;
|
||||||
|
other.contacted[emp2.idx] = false;
|
||||||
|
}
|
||||||
|
|
||||||
// A strong fleet moves the recommendation on.
|
// A strong fleet moves the recommendation on.
|
||||||
Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'battleship', mark: 1, count: 10 }]);
|
Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'battleship', mark: 1, count: 10 }]);
|
||||||
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
rec = Logic.recommendColonyFocus(RULES, st2, c);
|
||||||
|
|
@ -2004,6 +2061,89 @@ section('6. Colony economy');
|
||||||
dirtyLines.some((l) => l.toLowerCase().includes('waste')));
|
dirtyLines.some((l) => l.toLowerCase().includes('waste')));
|
||||||
c.waste = savedWaste;
|
c.waste = savedWaste;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Galactic Expansion (Colony Focus) / Expansion (Allocation Focus) —
|
||||||
|
// Brian's ask, 2026-08-14: favored pre-contact while an unclaimed world
|
||||||
|
// is still on the map, and pickExpansion's actual build behavior once
|
||||||
|
// selected — a colony ship per open world, then frigate escorts at 2:1
|
||||||
|
// once colony-ship demand is covered.
|
||||||
|
{
|
||||||
|
const findExpansionTarget = () => {
|
||||||
|
for (let si = 0; si < st2.galaxy.stars.length; si += 1) {
|
||||||
|
if (si === c.starIdx) continue;
|
||||||
|
const star = st2.galaxy.stars[si];
|
||||||
|
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
|
||||||
|
if (Logic.canColonize(RULES, st2, c.empireIdx, si, orbit)) return si;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
const targetStar = findExpansionTarget();
|
||||||
|
check('a colonizable target exists somewhere in this galaxy for the expansion test', targetStar >= 0);
|
||||||
|
|
||||||
|
if (targetStar >= 0) {
|
||||||
|
const before = Logic.discoveredOpenWorlds(RULES, st2, c.empireIdx);
|
||||||
|
emp2.explored[targetStar] = true;
|
||||||
|
const after = Logic.discoveredOpenWorlds(RULES, st2, c.empireIdx);
|
||||||
|
check('discovering an open world raises discoveredOpenWorlds by exactly one',
|
||||||
|
after === before + 1, `${before} -> ${after}`);
|
||||||
|
|
||||||
|
check('with no contact and an open world, Colony Focus favors Galactic Expansion',
|
||||||
|
Logic.recommendColonyFocus(RULES, st2, c).value === 'expansion');
|
||||||
|
check('with no contact and an open world, Allocation Focus favors Expansion',
|
||||||
|
Logic.recommendAllocationFocus(RULES, st2, c).key === 'expansion');
|
||||||
|
|
||||||
|
// First contact takes it off the table immediately, even though the
|
||||||
|
// world is still open — "quickly takes a back seat," not a fade.
|
||||||
|
const rival = st2.empires.find((o) => o.idx !== c.empireIdx && o.alive);
|
||||||
|
emp2.contacted[rival.idx] = true;
|
||||||
|
rival.contacted[emp2.idx] = true;
|
||||||
|
check('first contact drops Galactic Expansion even with an open world still on the map',
|
||||||
|
Logic.recommendColonyFocus(RULES, st2, c).value !== 'expansion');
|
||||||
|
check('first contact drops Expansion from Allocation Focus too',
|
||||||
|
Logic.recommendAllocationFocus(RULES, st2, c).key !== 'expansion');
|
||||||
|
emp2.contacted[rival.idx] = false;
|
||||||
|
rival.contacted[emp2.idx] = false;
|
||||||
|
|
||||||
|
// pickExpansion via autoQueueColonies: a colony ship first... Every
|
||||||
|
// empire starts with one colony ship already in its fleet
|
||||||
|
// (createGame's opening scout+colonyship pair), which alone would
|
||||||
|
// already cover a single discovered target — stripped out here (not
|
||||||
|
// the whole fleet, which may also carry the starting scout) so the
|
||||||
|
// commitment count starts at zero and this test actually exercises
|
||||||
|
// "build one," not "recognize we already have one."
|
||||||
|
for (const f of st2.fleets) {
|
||||||
|
if (f.empireIdx !== c.empireIdx) continue;
|
||||||
|
f.ships = f.ships.filter((s) => s.hullId !== 'colonyship');
|
||||||
|
}
|
||||||
|
st2.fleets = st2.fleets.filter((f) => f.ships.length > 0);
|
||||||
|
c.focus = 'expansion';
|
||||||
|
c.queue = [];
|
||||||
|
Logic.autoQueueColonies(RULES, st2, c.empireIdx);
|
||||||
|
check('Galactic Expansion queues a colony ship while an open world is unclaimed',
|
||||||
|
c.queue.length === 1 && c.queue[0].kind === 'ship' && c.queue[0].id === 'colonyship',
|
||||||
|
JSON.stringify(c.queue[0]));
|
||||||
|
|
||||||
|
// ...then, once every open world already has a colony ship
|
||||||
|
// committed, frigate escorts at a 2:1 ratio.
|
||||||
|
Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'colonyship', mark: 1, count: 1 }]);
|
||||||
|
c.queue = [];
|
||||||
|
Logic.autoQueueColonies(RULES, st2, c.empireIdx);
|
||||||
|
check('Galactic Expansion switches to frigate escorts once colony-ship demand is met',
|
||||||
|
c.queue.length === 1 && c.queue[0].kind === 'ship' && c.queue[0].id === 'frigate',
|
||||||
|
JSON.stringify(c.queue[0]));
|
||||||
|
|
||||||
|
Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'frigate', mark: 1, count: 2 }]);
|
||||||
|
c.queue = [];
|
||||||
|
Logic.autoQueueColonies(RULES, st2, c.empireIdx);
|
||||||
|
check('Galactic Expansion leaves the queue empty once colony ships and escorts both meet target',
|
||||||
|
c.queue.length === 0, JSON.stringify(c.queue));
|
||||||
|
|
||||||
|
// Leave st2/c/emp2 as found for anything appended after this block.
|
||||||
|
emp2.explored[targetStar] = false;
|
||||||
|
c.focus = 'trade';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
colony.sliders = restore.sliders;
|
colony.sliders = restore.sliders;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue