Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig c8e22a51a5 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
2026-08-14 10:55:38 -06:00
Brian Fertig 5ce7fc2367 feat(mastervega): contact-gate relationsRows and expand alert suppression to tech
- relationsRows now filters out empires the human hasn't contacted, matching
  the existing contact-gating behavior of rankingRows (Brian, 2026-08-14)
- Expand GNN alert suppression from espionage-only to include tech breakthroughs
  via new ALERT_STORY_KINDS set, skipping ranking/relations pages for these
  one-off notifications
- Update verifyMasterOfVega tests to assert uncontacted empires are excluded
  from relationsRows while confirming names can still appear in contacted
  rows' war/trade/ally lists
2026-08-14 09:52:57 -06:00
Brian Fertig c84b668553 feat(mastervega): add allocation icons and tooltips to colony sliders
- Replace □/■ glyphs with channel-specific icons inside the lock toggle boxes
- Introduce `allocation.png` (5 frames) and register it in the artwork config
- Add hover tooltips explaining each channel's production role and MOO1-style
  overflow behavior
- Expose slider `zone` for tooltip attachment and adjust icon/layout positioning
- Update sprite documentation and include minor audio asset update
2026-08-14 09:52:36 -06:00
14 changed files with 525 additions and 69 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

View File

@ -317,6 +317,16 @@
"cols": 10, "cols": 10,
"rows": 10, "rows": 10,
"_layout": "One frame per individual tech (techs[].iconFrame), 0-90 — the 60 original techs plus two MOO1-branching passes added later, in rules order. Split out of the combined tech sheet so field icons and tech icons can be painted/sized independently. 91-99 spare." "_layout": "One frame per individual tech (techs[].iconFrame), 0-90 — the 60 original techs plus two MOO1-branching passes added later, in rules order. Split out of the combined tech sheet so field icons and tech icons can be painted/sized independently. 91-99 spare."
},
"allocation": {
"key": "vega-allocation",
"path": "assets/images/vega/allocation.png",
"kind": "icon",
"frameWidth": 48,
"frameHeight": 48,
"cols": 5,
"rows": 1,
"_layout": "One frame per allocation channel, in CHANNELS order (VegaLogic.js): 0 ships (Construction), 1 defense (Defence), 2 industry (Industry), 3 ecology (Ecology), 4 research (Research). Shown beside each slider label on the colony screen (VegaColonyView.js)."
} }
} }
} }

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

@ -42,7 +42,7 @@ import { createShipMediaPool, makeShipIcon } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js'; import { openShipDetail } from './VegaShipDetail.js';
import { openLeaderDetail } from './VegaLeaderDetail.js'; import { openLeaderDetail } from './VegaLeaderDetail.js';
import { leaderOf } from './VegaLeaders.js'; import { leaderOf } from './VegaLeaders.js';
import { describeBuildingTooltip, describeLeaderTooltip } from './VegaTooltips.js'; import { describeBuildingTooltip, describeLeaderTooltip, describeAllocationTooltip } from './VegaTooltips.js';
import { import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
@ -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)
@ -432,11 +437,18 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// renormalise (its `others.length === 0` early return), freezing the // renormalise (its `others.length === 0` early return), freezing the
// allocation with no way back out. So the last one open cannot be shut. // allocation with no way back out. So the last one open cannot be shut.
const lockable = locked || lockedCount < CHANNELS.length - 1; const lockable = locked || lockedCount < CHANNELS.length - 1;
const box = add(scene.add.rectangle(x + 11, y + 28, 22, 22, // The box itself is the lock toggle — its fill/border shows locked
locked ? 0x2a3550 : 0x16253c).setStrokeStyle(1, ACCENT, lockable ? 0.55 : 0.18)); // state, and the channel's icon sits inside it (in place of the old
add(scene.add.text(x + 11, y + 27, locked ? '■' : '□', { // □/■ glyph) instead of getting its own separate column. Centred at
fontFamily: FONT, fontSize: '15px', color: locked ? '#ffd88a' : (lockable ? '#8fa8c0' : '#3c4c60'), // y+19: the midpoint between the slider's label (top, ~y+10) and its
}).setOrigin(0.5)); // track (y+28), so the icon reads as belonging to both.
const boxCx = x + 17;
const boxCy = y + 19;
const box = add(scene.add.rectangle(boxCx, boxCy, 34, 34,
locked ? 0x2a3550 : 0x16253c)
.setStrokeStyle(locked ? 3 : 2, CHANNEL_COLOUR[ch], lockable ? 0.85 : 0.18));
add(scene.add.image(boxCx, boxCy, art.allocation, i).setDisplaySize(26, 26)
.setTint(locked ? 0xffd88a : (lockable ? 0xe8f4ff : 0x445566)));
if (lockable) { if (lockable) {
box.setInteractive({ useHandCursor: true }); box.setInteractive({ useHandCursor: true });
box.on('pointerup', () => { box.on('pointerup', () => {
@ -446,7 +458,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
}); });
} }
const s = slider(scene, x + 34, y, w - 34, const s = slider(scene, x + 42, y, w - 42,
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => { rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
setSlider(rules, state, colony, ch, v); setSlider(rules, state, colony, ch, v);
// setSlider renormalises everything else, so they all have to be // setSlider renormalises everything else, so they all have to be
@ -463,6 +475,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
onChanged?.(); onChanged?.();
}, CHANNEL_COLOUR[ch]); }, CHANNEL_COLOUR[ch]);
panelLayer.add(s.container); panelLayer.add(s.container);
tooltip.attachTo(s.zone, () => describeAllocationTooltip(rules, state, colony, ch));
sliders.push(s); sliders.push(s);
y += 56; y += 56;
}); });

View File

@ -204,33 +204,42 @@ export function rankingRows(rules, state, metricId) {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Diplomatic relations — the always-accessible page listing who's at war, // Diplomatic relations — the always-accessible page listing who's at war,
// who has a trade agreement, and who's allied with whom. Deliberately NOT // who has a trade agreement, and who's allied with whom. Contact-gated same
// contact-gated like rankingRows: war/peace/alliance are already broadcast // as rankingRows (Brian's ask, 2026-08-14): a species the human hasn't met
// galaxy-wide regardless of whether the human has met either party (see // yet gets a full name/portrait row on a page that otherwise reads as "here
// VegaTurnReport.js's PERSONAL_TYPES — diplomacy events are never personal), // is everyone" before the human has actually discovered them, which spoils
// so this page is just that same "public knowledge" precedent laid out as a // the reveal that first contact is supposed to be (checkContactAt's own
// standing reference instead of one-off headlines. // comment in VegaLogic.js). War/peace/alliance news for two OTHER empires
// still broadcasts galaxy-wide as its own headline regardless of contact
// (VegaTurnReport.js's PERSONAL_TYPES) — that is unaffected, since this only
// gates which rows appear on the standing reference table, not whether the
// one-off headline fires. An uncontacted empire's NAME can still show up
// inside a contacted row's war/trade/ally list; only the row itself is
// gated.
export function relationsRows(rules, state) { export function relationsRows(rules, state) {
const me = state.humanIndex;
const name = (i) => state.empires[i]?.name ?? '?'; const name = (i) => state.empires[i]?.name ?? '?';
return state.empires.filter((e) => e.alive).map((e) => { return state.empires
const atWar = []; .filter((e) => e.alive && (e.idx === me || state.empires[me].contacted[e.idx]))
const allied = []; .map((e) => {
const trade = []; const atWar = [];
for (const o of state.empires) { const allied = [];
if (o.idx === e.idx || !o.alive) continue; const trade = [];
const treaty = e.treaties[o.idx]; for (const o of state.empires) {
if (treaty === 'war') atWar.push(name(o.idx)); if (o.idx === e.idx || !o.alive) continue;
else if (treaty === 'alliance') allied.push(name(o.idx)); const treaty = e.treaties[o.idx];
// A trade agreement is a separate, coexisting relationship (see if (treaty === 'war') atWar.push(name(o.idx));
// VegaDiplomacy.js's formTradeAgreement comment) — not another rung of else if (treaty === 'alliance') allied.push(name(o.idx));
// the treaties[] ladder — so it's checked independently of the switch // A trade agreement is a separate, coexisting relationship (see
// above rather than as another `else if`. // VegaDiplomacy.js's formTradeAgreement comment) — not another rung
if (e.tradeAgreements[o.idx]) trade.push(name(o.idx)); // of the treaties[] ladder — so it's checked independently of the
} // switch above rather than as another `else if`.
return { if (e.tradeAgreements[o.idx]) trade.push(name(o.idx));
idx: e.idx, name: e.name, color: e.color, speciesId: e.speciesId, atWar, allied, trade, }
}; return {
}); idx: e.idx, name: e.name, color: e.color, speciesId: e.speciesId, atWar, allied, trade,
};
});
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------

View File

@ -71,6 +71,12 @@ const STORY_COLORS = {
const hex = (c) => `#${c.toString(16).padStart(6, '0')}`; const hex = (c) => `#${c.toString(16).padStart(6, '0')}`;
const fmtValue = (v) => Math.round(v).toLocaleString(); const fmtValue = (v) => Math.round(v).toLocaleString();
// Story kinds (Gnn.describeGnnStory's `kind` field) that read as a targeted,
// one-off alert rather than a newscast — openGnnScreen skips the ranking and
// relations pages for these so a fresh notification doesn't get buried under
// unrelated charts. See openGnnScreen's isAlertPage for the full reasoning.
const ALERT_STORY_KINDS = new Set(['espionage', 'espionageResult', 'tech']);
// -------------------------------------------------------------- anchor panel // -------------------------------------------------------------- anchor panel
function buildAnchorFallback(scene, container, w, h) { function buildAnchorFallback(scene, container, w, h) {
@ -583,27 +589,28 @@ export function openGnnScreen(scene, rules, state, art, opts = {}) {
const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending; const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending;
const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) })); const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) }));
// A spying/sabotage notification is a targeted alert, not a newscast — // A spying/sabotage notification — or a tech breakthrough — is a targeted,
// skip the ranking/relations pages so it reads as "here's what just // one-off alert, not a newscast — skip the ranking/relations pages so it
// happened," not "here's what just happened, now flip through five // reads as "here's what just happened," not "here's what just happened,
// unrelated charts" (Brian's ask). Only suppresses on a FRESH pending // now flip through five unrelated charts" (Brian's ask; tech added to the
// notification; reopening GNN on demand with nothing pending // same set on the same ask, 2026-08-14). ALERT_STORY_KINDS is the place to
// (usingHistory) always gets the full experience back — that reopen IS // add another kind later. Only suppresses on a FRESH pending notification;
// the escape hatch for a player who does want the charts. // reopening GNN on demand with nothing pending (usingHistory) always gets
const isEspionageAlert = !usingHistory // the full experience back — that reopen IS the escape hatch for a player
&& storyPages.some((p) => p.desc.kind === 'espionage' || p.desc.kind === 'espionageResult'); // who does want the charts.
const isAlertPage = !usingHistory && storyPages.some((p) => ALERT_STORY_KINDS.has(p.desc.kind));
// The charts rank the human against every empire it has met (rankingRows // The charts rank the human against every empire it has met (rankingRows
// is contact-gated) — with nobody met yet that's just a one-bar "race" // is contact-gated) — with nobody met yet that's just a one-bar "race"
// against yourself, which reads as broken rather than informative. So the // against yourself, which reads as broken rather than informative. So the
// whole ranking rotation stays off the page list until first contact // whole ranking rotation stays off the page list until first contact
// (Brian's ask), same suppression flag as the espionage-alert case above. // (Brian's ask), same suppression flag as the alert-page case above.
const me = state.humanIndex; const me = state.humanIndex;
const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== me && state.empires[me].contacted[o.idx]); const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== me && state.empires[me].contacted[o.idx]);
const rankingPages = (isEspionageAlert || !hasMetAnyone) const rankingPages = (isAlertPage || !hasMetAnyone)
? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric })); ? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric }));
// Always-accessible otherwise: present every time GNN opens, independent // Always-accessible otherwise: present every time GNN opens, independent
// of whether there's a pending story — same footing as the ranking pages. // of whether there's a pending story — same footing as the ranking pages.
const relationsPage = isEspionageAlert ? null : { kind: 'relations' }; const relationsPage = isAlertPage ? null : { kind: 'relations' };
const pages = [...storyPages, ...rankingPages, ...(relationsPage ? [relationsPage] : [])]; const pages = [...storyPages, ...rankingPages, ...(relationsPage ? [relationsPage] : [])];
const root = scene.add.container(0, 0).setDepth(D.gnn); const root = scene.add.container(0, 0).setDepth(D.gnn);

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

@ -175,6 +175,7 @@ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT)
return { return {
container: c, container: c,
zone, // exposed so callers can tooltip.attachTo(s.zone, ...)
setValue(v) { setValue(v) {
fill.width = w * v; fill.width = w * v;
knob.x = w * v; knob.x = w * v;

View File

@ -4,7 +4,10 @@
// component; these functions only build the {title, lines} content it wants. // component; these functions only build the {title, lines} content it wants.
import { COLORS } from '../../config.js'; import { COLORS } from '../../config.js';
import { coloniesAt, habitableForEmpire } from './VegaLogic.js'; import {
coloniesAt, habitableForEmpire, colonyProduction, colonyFactoryCap,
effectiveFactories, colonyDefenseCap,
} from './VegaLogic.js';
import { techCost, techCostFactor } from './VegaRules.js'; import { techCost, techCostFactor } from './VegaRules.js';
import { leaderSkillSummary } from './VegaLeaders.js'; import { leaderSkillSummary } from './VegaLeaders.js';
@ -109,6 +112,49 @@ export function describeBuildingTooltip(rules, buildingId) {
}; };
} }
// Hover tooltip for one of the colony screen's five allocation sliders
// (VegaColonyView.js). Explains what that channel spends its share of
// production on and what the MOO1-style spillover (VegaLogic.js's
// processColony) does with any of it the channel can't use.
export function describeAllocationTooltip(rules, state, colony, ch) {
const label = rules.economy.channelNames[ch] ?? ch;
const prod = colonyProduction(rules, state, colony);
const share = prod * (colony.sliders[ch] ?? 0);
const lines = [{ text: `${share.toFixed(1)} BC/turn from this channel`, color: COLORS.goldHex }];
switch (ch) {
case 'ships':
lines.push({ text: 'Pays for whatever is at the front of the build queue — ships and buildings, in order.' });
lines.push({ text: 'Once the queue is empty, this overflows into Research instead of going to waste.', color: COLORS.mutedHex });
break;
case 'defense': {
const cap = colonyDefenseCap(rules, state, colony);
lines.push({ text: `Raises planetary defences (${Math.round(colony.defenseHp)} / ${cap}), capped by your defence tech.` });
lines.push({ text: 'Once defences are maxed, this overflows into Construction instead of going to waste.', color: COLORS.mutedHex });
break;
}
case 'industry': {
const cap = colonyFactoryCap(rules, state, colony);
const effF = effectiveFactories(rules, state, colony);
lines.push({ text: `Builds factories (${Math.floor(effF)} / ${cap} staffed), which raise this colony's production every turn.` });
lines.push({ text: 'Once factories are maxed, this overflows into Construction instead of going to waste.', color: COLORS.mutedHex });
break;
}
case 'ecology':
lines.push({ text: `Cleans industrial waste (backlog ${colony.waste.toFixed(1)}), which otherwise caps this colony's population.` });
lines.push({ text: 'Mandatory — a shortfall is drawn from the other four channels automatically. Any surplus overflows into Research.', color: COLORS.mutedHex });
break;
case 'research':
lines.push({ text: "Funds tech research toward your empire's research queue." });
lines.push({ text: 'Also absorbs unused overflow from Ecology and Construction, so it is rarely wasted.', color: COLORS.mutedHex });
break;
default:
break;
}
return { title: label, titleColor: COLORS.goldHex, lines };
}
// Hover tooltip for a colony's posted administrator, shown beside the // Hover tooltip for a colony's posted administrator, shown beside the
// building icon row. `leader` is the rules leader def (name/bio/skills). // building icon row. `leader` is the rules leader def (name/bio/skills).
export function describeLeaderTooltip(leader) { export function describeLeaderTooltip(leader) {

View File

@ -333,8 +333,53 @@ story page.
## 8. Menu icon — ✅ `assets/images/game-icons.png` frame **92** ## 8. Menu icon — ✅ `assets/images/game-icons.png` frame **92**
The shared 660 × 660 sheet, 44 × 44 cells, 15 per row. Frame 92 is row 6, Not part of any Vega sheet — this is the small tile icon shown for *Master of
column 2 → pixel origin **(88, 264)**. **Painted.** Vega* on the main game-selection screen's `arcade-console-pc` category list,
i.e. outside the game itself, the thing you click to launch it. The sheet is
shared by **every game in the app**, one frame each, claimed via `iconFrame`
in `src/data/gamesRegistry.js` (`registerGame({ slug: 'mastervega', ...,
iconFrame: 92 })`). Editing frame 92 only touches Vega's tile; the sheet
itself is common infrastructure, not Vega's to redesign.
660 × 660 total, 44 × 44 cells, 15 per row. Frame 92 is row 6, column 2 →
pixel origin **(88, 264)**. **Painted — nothing to do here.**
---
## 9. `allocation` — ✅ `assets/images/vega/allocation.png`
| | |
|---|---|
| Sheet size | **240 × 48** |
| Frame | **48 × 48** |
| Grid | 5 cols × 1 row = 5 frames |
One frame per allocation channel, in `CHANNELS` order (`VegaLogic.js`) — the
same five sliders on the colony screen (`VegaColonyView.js`), each of which
also carries a hover tooltip explaining what it does
(`describeAllocationTooltip` in `VegaTooltips.js`):
| Frame | Channel id | UI label | Icon idea |
|---|---|---|---|
| 0 | `ships` | Construction | Shipyard gantry or a hull under construction — feeds the build queue |
| 1 | `defense` | Defence | Shield or planetary turret — raises defence batteries |
| 2 | `industry` | Industry | Factory or gear — builds factories |
| 3 | `ecology` | Ecology | Leaf or recycling arrows — cleans waste |
| 4 | `research` | Research | Flask or atom — funds tech |
Shown at 26px, inside each slider's 34px lock toggle box — replacing the old
□/■ text glyph, not sitting in a separate column. The box sits vertically
centred between the slider's label and its track, so the icon reads as
belonging to both. Tinted gold when that channel is locked, near-white when
unlockable, and dim grey on the one channel that can't be locked (the last
one open); the box's stroke is that channel's own bar colour
(`CHANNEL_COLOUR` in `VegaColonyView.js` — e.g. Construction blue, Defence
red), thicker when locked. Declared in `data/mastervega-artwork.json`'s
`sheets.allocation` (`"kind": "icon"`, same pattern as
`buildings`/`techfields`); drawn by `VegaColonyView.js`'s slider loop as
`art.allocation` frame `i` (the same index the loop already walks `CHANNELS`
with), on top of the same rectangle that already handles the lock/unlock
click.
--- ---

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;
@ -3231,9 +3371,12 @@ section('7b. Galactic News Network');
// --- relationsRows: war/trade/alliance are three independent columns // --- relationsRows: war/trade/alliance are three independent columns
// (formTradeAgreement's own comment: trade coexists with any treaty rung // (formTradeAgreement's own comment: trade coexists with any treaty rung
// rather than being another value on it), and — unlike rankingRows — // rather than being another value on it). Contact-gated same as
// NOT contact-gated, since diplomacy events already broadcast regardless // rankingRows (Brian's ask, 2026-08-14): an uncontacted empire gets no row
// of contact (VegaTurnReport.js's PERSONAL_TYPES). // of its own, even though a NAME it's involved in can still surface inside
// a contacted empire's own war/trade/ally lists (those are public
// broadcast data — VegaTurnReport.js's PERSONAL_TYPES — untouched by this
// gate).
{ {
const st6 = Logic.createGame(RULES, { const st6 = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 1010, difficultyId: 'normal', sizeId: 'medium', shapeId: 'elliptical', seed: 1010, difficultyId: 'normal',
@ -3245,10 +3388,12 @@ section('7b. Galactic News Network');
Diplo.declareWar(RULES, st6, 0, 1); Diplo.declareWar(RULES, st6, 0, 1);
Diplo.formAlliance(RULES, st6, 1, 2); Diplo.formAlliance(RULES, st6, 1, 2);
Diplo.formTradeAgreement(RULES, st6, 0, 2); Diplo.formTradeAgreement(RULES, st6, 0, 2);
st6.empires[0].contacted[1] = true; st6.empires[1].contacted[0] = true;
st6.empires[0].contacted[2] = true; st6.empires[2].contacted[0] = true;
const relRows = Gnn.relationsRows(RULES, st6); const relRows = Gnn.relationsRows(RULES, st6);
check('relationsRows returns one row per alive empire, contact or not', check('relationsRows excludes an empire the human has never contacted',
relRows.length === 4); relRows.length === 3 && !relRows.some((r) => r.idx === 3));
const byIdx = Object.fromEntries(relRows.map((r) => [r.idx, r])); const byIdx = Object.fromEntries(relRows.map((r) => [r.idx, r]));
check('war is reciprocal', byIdx[0].atWar.includes(st6.empires[1].name) check('war is reciprocal', byIdx[0].atWar.includes(st6.empires[1].name)
&& byIdx[1].atWar.includes(st6.empires[0].name)); && byIdx[1].atWar.includes(st6.empires[0].name));
@ -3257,10 +3402,12 @@ section('7b. Galactic News Network');
&& byIdx[1].atWar.length === 1 && !byIdx[1].atWar.includes(st6.empires[2].name)); && byIdx[1].atWar.length === 1 && !byIdx[1].atWar.includes(st6.empires[2].name));
check('trade agreement is reciprocal and independent of treaty state', check('trade agreement is reciprocal and independent of treaty state',
byIdx[0].trade.includes(st6.empires[2].name) && byIdx[2].trade.includes(st6.empires[0].name)); byIdx[0].trade.includes(st6.empires[2].name) && byIdx[2].trade.includes(st6.empires[0].name));
check('an empire with no relations at all has three empty lists',
byIdx[3].atWar.length === 0 && byIdx[3].trade.length === 0 && byIdx[3].allied.length === 0);
check('an empire not at war with everyone does not falsely list the uninvolved', check('an empire not at war with everyone does not falsely list the uninvolved',
!byIdx[0].atWar.includes(st6.empires[2].name) && !byIdx[0].atWar.includes(st6.empires[3].name)); !byIdx[0].atWar.includes(st6.empires[2].name));
// 3 is uncontacted, so it has no row — but it can still surface as a
// NAME inside a contacted empire's own lists, unaffected by the gate.
// No such reference exists in this fixture (3 has no relations at all),
// so there is nothing further to assert here beyond its row being gone.
} }
} }