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:
Brian Fertig 2026-08-14 10:55:38 -06:00
parent 5ce7fc2367
commit c8e22a51a5
5 changed files with 337 additions and 14 deletions

View File

@ -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;

View File

@ -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));

View File

@ -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)

View File

@ -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.',
}; };

View File

@ -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;