Compare commits
3 Commits
44868e5032
...
c8e22a51a5
| Author | SHA1 | Date |
|---|---|---|
|
|
c8e22a51a5 | |
|
|
5ce7fc2367 | |
|
|
c84b668553 |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue