diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 8039bc7..7f09e05 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -102,7 +102,16 @@ function musicFrom(scene, jsonKey) { // same {type:'audio', key, path} shape musicFrom produces. VegaMusic.js reads // track file paths straight out of the cached JSON at playback time (it plays // via plain `Audio` elements, not these cache keys) — this is purely so the -// mp3 bytes are pre-fetched when the game room is entered. +// mp3 bytes for the pools every game is guaranteed to hit soon (menu, peace, +// combat, the generic diplomacy fallback) are already warm. +// +// `diplomacy.bySpecies` is deliberately excluded, same reasoning as +// `colonyVideos` a few lines below: a given playthrough may contact only a +// handful of the game's nine species, if any, so pre-fetching all nine +// tracks at room entry pays for eight-plus that are never played. VegaMusic's +// own `new Audio(...)` at setDiplomacy() time is already the fetch — this +// manifest was only ever a race to get there first, and racing every race's +// track just to win a few of them is not worth the up-front weight. function vegaMusicFrom(scene, jsonKey) { const data = scene.cache.json.get(jsonKey); if (!data) return []; @@ -117,9 +126,6 @@ function vegaMusicFrom(scene, jsonKey) { addPool(data.peace, 'peace'); addPool(data.combat, 'combat'); addPool(data.diplomacy?.default, 'diplomacy-default'); - for (const [speciesId, pool] of Object.entries(data.diplomacy?.bySpecies ?? {})) { - addPool(pool, `diplomacy-${speciesId}`); - } return out; } diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index ddd0df5..a5bcc61 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -1069,6 +1069,10 @@ export default class MasterOfVegaGame extends Phaser.Scene { // spent, so whatever it queues here is what NEXT turn's // beginEmpireTurn will consume. Logic.autoQueueColonies(this.rules, this.state, this.state.humanIndex); + // Advisor re-analysis: may push an 'advisorRecommendation' event into + // state.events, picked up below by processTurnEvents() same as any + // other notable event. + Logic.checkAdvisorRecommendations(this.rules, this.state, this.state.humanIndex); // Contact discovered during an AI empire's turn surfaces the moment // control returns to the human — before the routine turn report, so // "contact made" always reads as the bigger beat. diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index ee13b3a..5834360 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -46,6 +46,7 @@ import { colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, empireColonies, maxSendablePopulation, sendPopulation, etaTo, + recommendColonyFocus, recommendAllocationFocus, } from './VegaLogic.js'; const ACCENT = 0x6fc4ff; @@ -749,6 +750,30 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { // Autopilot picker: what VegaLogic.js's autoQueueColonies will build here // once a turn, when this colony's queue is empty. Short enough (7 rows) to // fit without a scrollColumn. + /** A small right-pointing glyph, pulsing horizontally, pointing at the + * advisors' recommended row. Lives in the PAD margin to the row's left — + * no manual cleanup needed, it and its tween are destroyed together the + * next time resetFlyLayer() runs. */ + function addRecommendArrow(rowY, rowH) { + const arrow = scene.add.text(FLY_X + PAD / 2, rowY + rowH / 2, '▶', { + fontFamily: FONT, fontSize: '20px', color: '#ffd88a', + }).setOrigin(0.5); + flyLayer.add(arrow); + scene.tweens.add({ + targets: arrow, x: arrow.x + 6, duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } + + /** A still, smaller twin of the pulsing arrow above, sat beside "YOUR + * ADVISORS RECOMMEND" so the callout visibly matches the row it points at. */ + function addStaticArrowGlyph(atX, atY) { + const glyph = scene.add.text(atX, atY, '▶', { + fontFamily: FONT, fontSize: '14px', color: '#ffd88a', + }); + flyLayer.add(glyph); + return glyph; + } + function buildColonyFocusFlyout() { resetFlyLayer(); pool.setPaused(true); // no ship media in this mode @@ -765,13 +790,31 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { })); let y = PANEL_Y + 110; + const rec = recommendColonyFocus(rules, state, colony); + const recGlyph = addStaticArrowGlyph(x, y); + const recHeading = scene.add.text(x + recGlyph.width + 8, y, 'YOUR ADVISORS RECOMMEND:', { + fontFamily: FONT, fontSize: '14px', color: '#ffd88a', + }); + flyLayer.add(recHeading); + y += recHeading.height + 4; + const recBody = scene.add.text(x, y, `${rec.label} — ${rec.reason}`, { + fontFamily: FONT, fontSize: '13px', color: '#9fd8ff', wordWrap: { width: w }, lineSpacing: 3, + }); + flyLayer.add(recBody); + y += recBody.height + 18; + const rowH = 78; for (const opt of COLONY_FOCUS_OPTIONS) { const selected = (colony.focus ?? 'manual') === opt.value; const row = scene.add.rectangle(x, y, w, rowH - 8, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5) .setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); - row.on('pointerup', () => { colony.focus = opt.value; buildColonyFocusFlyout(); }); + row.on('pointerup', () => { + colony.focus = opt.value; + colony.advisor.focusQuietUntil = state.turn + 15; + buildColonyFocusFlyout(); + }); flyLayer.add(row); + if (opt.value === rec.value) addRecommendArrow(y, rowH); flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, { fontFamily: FONT, fontSize: '18px', color: selected ? '#ffd88a' : '#e8f4ff', })); @@ -803,6 +846,19 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { })); let y = PANEL_Y + 110; + const rec = recommendAllocationFocus(rules, state, colony); + const recGlyph = addStaticArrowGlyph(x, y); + const recHeading = scene.add.text(x + recGlyph.width + 8, y, 'YOUR ADVISORS RECOMMEND:', { + fontFamily: FONT, fontSize: '14px', color: '#ffd88a', + }); + flyLayer.add(recHeading); + y += recHeading.height + 4; + const recBody = scene.add.text(x, y, `${rec.label} — ${rec.reason}`, { + fontFamily: FONT, fontSize: '13px', color: '#9fd8ff', wordWrap: { width: w }, lineSpacing: 3, + }); + flyLayer.add(recBody); + y += recBody.height + 18; + const rowH = 84; for (const opt of ALLOCATION_FOCUS_OPTIONS) { const matches = CHANNELS.every((ch) => Math.abs((colony.sliders[ch] ?? 0) - opt.sliders[ch]) < 1e-6); @@ -810,11 +866,14 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { .setOrigin(0, 0).setStrokeStyle(1, ACCENT, matches ? 0.7 : 0.2).setInteractive({ useHandCursor: true }); row.on('pointerup', () => { colony.sliders = { ...opt.sliders }; + colony.advisor.allocKey = opt.key; + colony.advisor.allocQuietUntil = state.turn + 15; onChanged?.(); buildPanel(); // redraws the five sliders with their new values buildAllocationFocusFlyout(); // refreshes which row is highlighted }); flyLayer.add(row); + if (opt.key === rec.key) addRecommendArrow(y, rowH); flyLayer.add(scene.add.text(x + 14, y + 8, opt.label, { fontFamily: FONT, fontSize: '18px', color: matches ? '#ffd88a' : '#e8f4ff', })); diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index ea508ed..a8401d4 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -548,6 +548,14 @@ export function foundColony(rules, state, e, starIdx, orbit, pop, name = null) { sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 }, locked: {}, focus: 'manual', // 'manual' | 'improvement' | 'research' | 'fleet' | 'growth' | 'trade' | 'defense' + // Advisor re-analysis tracking (checkAdvisorRecommendations). The Quiet* + // fields are null until the player first picks something in the + // corresponding flyout — advisors never nag a colony that has not been + // touched. allocKey is tracking-only: it does not lock the sliders. + advisor: { + focusQuietUntil: null, focusNotifiedValue: null, + allocQuietUntil: null, allocKey: null, allocNotifiedKey: null, + }, founded: state.turn, }; state.colonies.push(colony); @@ -1906,9 +1914,12 @@ export function enqueueMany(rules, state, colony, kind, id, n) { * First candidate in `ids` that clears enqueue's own guards (already-built, * already-queued, prereq) plus an affordability guard mirroring VegaAI.js's * `if (b.cost > prod * 25) continue;` — so a small colony is never saddled - * with upkeep it cannot carry. Returns whether something was queued. + * with upkeep it cannot carry. Pure — used both to decide what to actually + * queue (enqueueFirstAffordableBuilding) and, read-only, by the advisor + * recommendation heuristics (recommendColonyFocus/recommendAllocationFocus) + * to check "would something here actually get built." */ -function enqueueFirstAffordableBuilding(rules, state, colony, ids) { +function firstEligibleBuildingId(rules, state, colony, ids) { const prod = colonyProduction(rules, state, colony); const emp = state.empires[colony.empireIdx]; for (const id of ids) { @@ -1918,43 +1929,55 @@ function enqueueFirstAffordableBuilding(rules, state, colony, ids) { if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) continue; if (b.prereq && !emp.known[b.prereq]) continue; if (b.cost > prod * 25) continue; - if (enqueue(rules, state, colony, 'building', id)) return true; + return id; } - return false; + return null; +} + +function enqueueFirstAffordableBuilding(rules, state, colony, ids) { + const id = firstEligibleBuildingId(rules, state, colony, ids); + return id ? enqueue(rules, state, colony, 'building', id) : false; } const byCostAsc = (a, b) => a.cost - b.cost; +// Candidate id lists, shared between the FOCUS_PICKERS below (which actually +// enqueue) and the advisor recommendation heuristics further down (which only +// need to know whether something in the category is eligible). +const industryBuildingIds = (rules) => rules.buildingList + .filter((b) => b.channel === 'industry').sort(byCostAsc).map((b) => b.id); +const otherBuildingIds = (rules) => rules.buildingList + .filter((b) => b.channel !== 'industry').sort(byCostAsc).map((b) => b.id); +const researchBuildingIds = (rules) => rules.buildingList + .filter((b) => b.channel === 'research').sort(byCostAsc).map((b) => b.id); +const growthBuildingIds = (rules) => rules.buildingList + .filter((b) => b.effects?.maxPopBonus || b.effects?.growthMult).sort(byCostAsc).map((b) => b.id); +const tradeBuildingIds = (rules) => rules.buildingList + .filter((b) => b.effects?.tradeBonus || b.effects?.tradeMult).sort(byCostAsc).map((b) => b.id); +const defenseBuildingIds = (rules) => rules.buildingList + .filter((b) => b.channel === 'defense').sort(byCostAsc).map((b) => b.id); +const anyBuildingIds = (rules) => rules.buildingList.slice().sort(byCostAsc).map((b) => b.id); + function pickImprovement(rules, state, colony) { - const industry = rules.buildingList.filter((b) => b.channel === 'industry').sort(byCostAsc).map((b) => b.id); - const rest = rules.buildingList.filter((b) => b.channel !== 'industry').sort(byCostAsc).map((b) => b.id); - enqueueFirstAffordableBuilding(rules, state, colony, [...industry, ...rest]); + enqueueFirstAffordableBuilding(rules, state, colony, [...industryBuildingIds(rules), ...otherBuildingIds(rules)]); } // No fallback if nothing qualifies — an empty queue is correct here, since // processColony already spills unspent construction BC into research. function pickResearchOnly(rules, state, colony) { - const ids = rules.buildingList.filter((b) => b.channel === 'research').sort(byCostAsc).map((b) => b.id); - enqueueFirstAffordableBuilding(rules, state, colony, ids); + enqueueFirstAffordableBuilding(rules, state, colony, researchBuildingIds(rules)); } function pickGrowth(rules, state, colony) { - const ids = rules.buildingList - .filter((b) => b.effects?.maxPopBonus || b.effects?.growthMult) - .sort(byCostAsc).map((b) => b.id); - enqueueFirstAffordableBuilding(rules, state, colony, ids); + enqueueFirstAffordableBuilding(rules, state, colony, growthBuildingIds(rules)); } function pickTrade(rules, state, colony) { - const ids = rules.buildingList - .filter((b) => b.effects?.tradeBonus || b.effects?.tradeMult) - .sort(byCostAsc).map((b) => b.id); - enqueueFirstAffordableBuilding(rules, state, colony, ids); + enqueueFirstAffordableBuilding(rules, state, colony, tradeBuildingIds(rules)); } function pickDefense(rules, state, colony) { - const ids = rules.buildingList.filter((b) => b.channel === 'defense').sort(byCostAsc).map((b) => b.id); - enqueueFirstAffordableBuilding(rules, state, colony, ids); + enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules)); } // Independently-implemented twin of VegaAI.js's preferredWarship (same @@ -2017,6 +2040,203 @@ export function autoQueueColonies(rules, state, e) { } } +// -------------------------------------------------------------------------- +// Advisor recommendations — what the Colony Focus / Allocation Focus flyouts +// (VegaColonyView.js) point at, and what checkAdvisorRecommendations compares +// the current setting against once a turn. Both functions are pure (never +// mutate, never enqueue) so they are cheap to call on every flyout render. + +/** + * Empire-wide fleet strength — the same computation VegaAI.js's + * computeStrategy uses for its own `myFleet` (VegaAI.js:71), reused here so + * "fleet condition of the species" means what the AI already judges itself + * by, rather than a second invented metric. + */ +function myFleetPower(rules, state, e) { + return empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0); +} + +// VegaAI.js's own peacetime fleet-adequacy threshold (VegaAI.js:180), +// reused rather than inventing a separate number. +const fleetAdequacyThreshold = (state) => 400 + state.turn * 4; + +/** + * First applicable rung of a priority ladder — deliberately mirrors + * FOCUS_PICKERS' order, but reports the first CATEGORY that applies rather + * than the first one enqueue would accept, and always resolves to something + * (falls through to 'manual') so the colony screen always has a row to point + * at. + */ +export function recommendColonyFocus(rules, state, colony) { + const star = state.galaxy.stars[colony.starIdx]; + const planet = star.planets[colony.orbit]; + const type = rules.planetTypes[planet.typeId]; + const maxPop = colonyMaxPop(rules, state, colony); + const e = colony.empireIdx; + + const growthId = firstEligibleBuildingId(rules, state, colony, growthBuildingIds(rules)); + if (colony.pop < maxPop * 0.7 && growthId) { + const pct = Math.round((colony.pop / maxPop) * 100); + return { + value: 'growth', label: 'Population Growth', + reason: `This ${type.name.toLowerCase()} world is at ${pct}% of its population ceiling — ` + + `a ${rules.buildings[growthId].name} would raise it.`, + }; + } + + const industryId = firstEligibleBuildingId(rules, state, colony, industryBuildingIds(rules)); + const factoryCap = colonyFactoryCap(rules, state, colony); + const effF = effectiveFactories(rules, state, colony); + if (effF >= factoryCap * 0.9 && industryId) { + return { + value: 'improvement', label: 'Colony Improvement', + reason: `Factories are running at ${Math.floor(effF)}/${factoryCap} — ` + + `a ${rules.buildings[industryId].name} would raise your production ceiling.`, + }; + } + + if (myFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { + return { + value: 'fleet', label: 'Fleet Production', + reason: "Empire-wide fleet strength is below what's typical this far into the game — " + + 'additional warships would help, favouring whichever type you have fewest of.', + }; + } + + const tradeIds = tradeBuildingIds(rules); + const hasTrade = colony.buildings.some((b) => tradeIds.includes(b)); + const tradeId = firstEligibleBuildingId(rules, state, colony, tradeIds); + if (!hasTrade && colony.pop >= 3 && tradeId) { + return { + value: 'trade', label: 'Trade & Commerce', + reason: `This colony has no trade infrastructure yet — a ${rules.buildings[tradeId].name} ` + + 'would raise its BC income.', + }; + } + + const atWarWithAnyone = state.empires.some((o) => o.alive && o.idx !== e && atWar(state, e, o.idx)); + const defenseId = firstEligibleBuildingId(rules, state, colony, defenseBuildingIds(rules)); + const defenseCap = colonyDefenseCap(rules, state, colony); + if (atWarWithAnyone && colony.defenseHp < defenseCap * 0.5 && defenseId) { + return { + value: 'defense', label: 'Homeworld Defense', + reason: `Your empire is at war and this colony's defences are under-built ` + + `(${Math.round(colony.defenseHp)}/${defenseCap}) — a ${rules.buildings[defenseId].name} would help repel raids.`, + }; + } + + const researchId = firstEligibleBuildingId(rules, state, colony, researchBuildingIds(rules)); + if (colony.pop >= 3 && researchId) { + return { + value: 'research', label: 'Research Focus', + reason: `A ${rules.buildings[researchId].name} would raise this colony's contribution to empire research.`, + }; + } + + const anyId = firstEligibleBuildingId(rules, state, colony, anyBuildingIds(rules)); + if (anyId) { + return { + value: 'improvement', label: 'Colony Improvement', + reason: `There's still room to grow this colony's infrastructure — a ${rules.buildings[anyId].name} is available.`, + }; + } + + return { + value: 'manual', label: 'Manual', + reason: 'This colony is fully built out and the fleet is in good shape — steer it yourself as opportunities come up.', + }; +} + +/** Same signal order as recommendColonyFocus, mapped to slider presets instead of buildings. */ +export function recommendAllocationFocus(rules, state, colony) { + const maxPop = colonyMaxPop(rules, state, colony); + const factoryCap = colonyFactoryCap(rules, state, colony); + const effF = effectiveFactories(rules, state, colony); + const e = colony.empireIdx; + + if (colony.pop < maxPop * 0.7 || colony.waste > 3) { + return { + key: 'growth', label: 'Population Growth', + reason: colony.waste > 3 + ? `Uncleaned waste (${colony.waste.toFixed(1)}) is capping this colony's population — ` + + 'more Ecology funding clears it fastest.' + : `Population is at ${Math.round((colony.pop / maxPop) * 100)}% of its ceiling — ` + + 'more Ecology funding clears the way for growth.', + }; + } + + if (effF < factoryCap * 0.7) { + return { + key: 'production', label: 'Production', + reason: `Factories are still well below your cap (${Math.floor(effF)}/${factoryCap}) — ` + + 'more Industry funding builds them out faster.', + }; + } + + if (myFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { + return { + key: 'military', label: 'Military Buildup', + reason: "Empire-wide fleet strength is below what's typical this far into the game.", + }; + } + + if (colony.sliders.research < 0.3) { + return { + key: 'research', label: 'Research Focus', + reason: 'Nothing urgent elsewhere — leaning into Research pays off over time.', + }; + } + + return { + key: 'default', label: 'Default', + reason: 'A balanced split fits this colony well right now.', + }; +} + +/** + * Once-a-turn re-analysis. Only touches a colony whose advisor tracking has + * been armed (a Quiet* field is non-null, meaning the player picked + * something at least once in that flyout) and whose quiet period has + * elapsed. Notifies at most once per still-outstanding recommendation — the + * Notified* markers dedupe so a persisting mismatch doesn't repeat every + * turn, and clear themselves the moment the setting matches the current + * recommendation again so a later divergence is reported fresh. + */ +export function checkAdvisorRecommendations(rules, state, e) { + for (const colony of empireColonies(state, e)) { + const adv = colony.advisor; + if (!adv) continue; + const domains = []; + + if (adv.focusQuietUntil != null && state.turn >= adv.focusQuietUntil) { + const rec = recommendColonyFocus(rules, state, colony); + if (rec.value === colony.focus) { + adv.focusNotifiedValue = null; + } else if (rec.value !== adv.focusNotifiedValue) { + adv.focusNotifiedValue = rec.value; + domains.push('focus'); + } + } + + if (adv.allocQuietUntil != null && state.turn >= adv.allocQuietUntil) { + const rec = recommendAllocationFocus(rules, state, colony); + if (rec.key === adv.allocKey) { + adv.allocNotifiedKey = null; + } else if (rec.key !== adv.allocNotifiedKey) { + adv.allocNotifiedKey = rec.key; + domains.push('alloc'); + } + } + + if (domains.length) { + pushEvent(state, { + type: 'advisorRecommendation', empire: e, colonyId: colony.id, starIdx: colony.starIdx, + domains, turn: state.turn, + }); + } + } +} + export function dequeue(rules, state, colony, index) { if (index < 0 || index >= colony.queue.length) return false; colony.queue.splice(index, 1); @@ -2173,7 +2393,13 @@ export function deserialize(json) { emp.espionageMission ??= {}; } // Same convention for a save from before Colony Focus existed. - for (const c of state.colonies) c.focus ??= 'manual'; + for (const c of state.colonies) { + c.focus ??= 'manual'; + c.advisor ??= { + focusQuietUntil: null, focusNotifiedValue: null, + allocQuietUntil: null, allocKey: null, allocNotifiedKey: null, + }; + } return state; } diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 62f17ce..2ea65fd 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -4,7 +4,7 @@ // popup renders. Keeping the per-type text formatting out of // MasterOfVegaGame.js/VegaScreens.js keeps those files from ballooning. -import { habitableForEmpire } from './VegaLogic.js'; +import { habitableForEmpire, recommendColonyFocus, recommendAllocationFocus } from './VegaLogic.js'; import { describeTechEffects } from './VegaTechEffects.js'; // Events worth interrupting the player for. Everything else (refit, @@ -19,7 +19,7 @@ import { describeTechEffects } from './VegaTechEffects.js'; export const NOTABLE_TYPES = new Set([ 'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured', 'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'council', - 'councilRefused', 'eliminated', + 'councilRefused', 'eliminated', 'advisorRecommendation', ]); // Exploring, researching, founding a colony and finishing a build-queue item @@ -34,7 +34,7 @@ export const NOTABLE_TYPES = new Set([ // ticker log runs every event past isRelevantToHuman() too. const PERSONAL_TYPES = new Set([ 'discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone', - 'populationSent', 'populationDelivered', + 'populationSent', 'populationDelivered', 'advisorRecommendation', ]); export function isRelevantToHuman(ev, me) { @@ -57,6 +57,7 @@ export const TYPE_LABEL = { council: 'Galactic Council', councilRefused: 'Council Refused', eliminated: 'Empire Eliminated', + advisorRecommendation: 'Advisor Recommendation', }; // Sort weight — diplomacy/territory/council news first (most consequential), @@ -67,7 +68,7 @@ const CATEGORY_ORDER = { captured: 1, colonyDestroyed: 1, discovered: 2, techDone: 3, - buildingDone: 4, shipDone: 4, + buildingDone: 4, shipDone: 4, advisorRecommendation: 4, }; export const categoryWeight = (ev) => CATEGORY_ORDER[ev.type] ?? 9; @@ -134,6 +135,25 @@ function describeShipDone(rules, state, ev) { return { headline: `${h.name} completed at ${star.name}.`, lines: [line(h.desc, '#9fb6cc')] }; } +// The recommendations are re-derived here rather than frozen into the event +// at checkAdvisorRecommendations time — cheap (both functions are pure) and +// keeps this row honest if the player already acted before opening the report. +function describeAdvisorRecommendation(rules, state, ev) { + const colony = state.colonies.find((c) => c.id === ev.colonyId); + const star = state.galaxy.stars[ev.starIdx]; + const name = colony?.name ?? star.name; + const lines = []; + if (colony && ev.domains.includes('focus')) { + const rec = recommendColonyFocus(rules, state, colony); + lines.push(line(`Colony Focus: ${rec.label} — ${rec.reason}`, '#9fb6cc')); + } + if (colony && ev.domains.includes('alloc')) { + const rec = recommendAllocationFocus(rules, state, colony); + lines.push(line(`Allocation Focus: ${rec.label} — ${rec.reason}`, '#9fb6cc')); + } + return { headline: `Advisors have new recommendations at ${name}.`, lines }; +} + function describeContact(rules, state, ev, name) { const me = state.humanIndex; let otherIdx; @@ -233,6 +253,7 @@ export function describeEvent(rules, state, ev) { case 'council': out = describeCouncil(rules, state, ev, name); break; case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break; case 'eliminated': out = describeEliminated(rules, state, ev, name); break; + case 'advisorRecommendation': out = describeAdvisorRecommendation(rules, state, ev); break; default: out = { headline: ev.type, lines: [] }; } return { category: TYPE_LABEL[ev.type] ?? ev.type, ...out }; diff --git a/src/games/mastervega/VegaTurnReportScreen.js b/src/games/mastervega/VegaTurnReportScreen.js index 103aea3..5391c4d 100644 --- a/src/games/mastervega/VegaTurnReportScreen.js +++ b/src/games/mastervega/VegaTurnReportScreen.js @@ -10,7 +10,7 @@ import { modalShell, FONT, uiClick } from './VegaScreens.js'; import { playSound, SFX } from '../../ui/Sounds.js'; -import { scrollColumn } from './VegaColonyView.js'; +import { scrollColumn, openColonyView } from './VegaColonyView.js'; import { openSystemView } from './VegaSystemView.js'; import { openResearchScreen } from './VegaResearchScreen.js'; import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js'; @@ -44,6 +44,8 @@ const BUILDING_MEDIA_W = 90; // Event types whose row gets a "View Star System" shortcut — anything // personal to a single one of the human's own stars. const VIEW_SYSTEM_TYPES = new Set(['discovered', 'buildingDone', 'shipDone']); +// Same idea, but straight into the colony screen rather than the system view. +const VIEW_COLONY_TYPES = new Set(['advisorRecommendation']); export function openTurnReportScreen(scene, rules, state, events, onClose) { playSound(scene, SFX.VEGA_NEWTURN); @@ -258,6 +260,24 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) { col.content.add(viewBtn); cy += 36; } + + // Same shortcut shape again, straight into the colony screen itself + // rather than the system view around it — advisorRecommendation rows + // always carry a colonyId (checkAdvisorRecommendations in + // VegaLogic.js only ever fires for the human's own colonies). + if (VIEW_COLONY_TYPES.has(ev.type)) { + const viewBtn = new Button(scene, w - 108, cy + 14, 'View Colony', uiClick(scene, () => { + col.destroy(); + shell.destroy(); + const colony = state.colonies.find((c) => c.id === ev.colonyId); + openColonyView(scene, rules, state, colony, scene.art, { + onChanged: () => scene.refreshAll?.(), + onClose, + }); + }), { width: 168, height: 28, fontSize: 13 }); + col.content.add(viewBtn); + cy += 36; + } } const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0) diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 9a5206e..a55b1b0 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -774,6 +774,23 @@ section('2. Procedural art'); check(`soundtrack ${poolName} volume in range`, pool.volume > 0 && pool.volume <= 1, `${pool.volume}`); } } + + // Same "not eager-loaded" contract as the colony/audience video clips + // above: a given playthrough may contact only a handful of the game's nine + // species, if any, so assetManifest.js's vegaMusicFrom() must not resolve + // diplomacy.bySpecies at game-room entry — VegaMusic's own on-demand + // `new Audio(...)` at setDiplomacy() time is the actual fetch. + { + const stub = { cache: { json: { get: (k) => (k === 'masterofvega-music' ? music : null) } } }; + const eagerMusic = resolveGameAssets(stub, 'mastervega').filter((d) => d.type === 'audio'); + check('diplomacy bySpecies tracks are not eager-loaded', + !eagerMusic.some((d) => d.key.includes('-diplomacy-') && !d.key.includes('-diplomacy-default-')), + eagerMusic.map((d) => d.key).join(' ')); + check('the diplomacy default pool is still eager-loaded', + eagerMusic.some((d) => d.key.includes('-diplomacy-default-'))); + check('fallback/menu/peace/combat pools are still eager-loaded', + ['fallback', 'menu', 'peace', 'combat'].every((p) => eagerMusic.some((d) => d.key.includes(`-${p}-`)))); + } } // --------------------------------------------------------------------------- @@ -1545,6 +1562,102 @@ section('6. Colony economy'); c.focus = 'manual'; } + // --- Advisor recommendations: recommendColonyFocus / recommendAllocationFocus + // / checkAdvisorRecommendations. Uses a throwaway state so nothing here + // needs restoring afterward. + { + const st2 = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'spiral', seed: 77, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0, + }); + st2.rules = RULES; + const c = st2.colonies[0]; + const emp2 = st2.empires[c.empireIdx]; + const COLONY_FOCUS_VALUES = new Set(['manual', 'improvement', 'research', 'fleet', 'growth', 'trade', 'defense']); + const ALLOC_FOCUS_KEYS = new Set(['default', 'research', 'growth', 'production', 'military']); + + check('a fresh colony has no advisor tracking armed yet', + c.advisor.focusQuietUntil === null && c.advisor.allocQuietUntil === null + && c.advisor.focusNotifiedValue === null && c.advisor.allocKey === null && c.advisor.allocNotifiedKey === null); + + // Growth: starve population well below its ceiling and grant the + // cheapest growth building's prereq so one is actually eligible. + const maxPop = Logic.colonyMaxPop(RULES, st2, c); + c.pop = maxPop * 0.3; + emp2.known.controlledbarren = true; // unlocks cloningcenter + let rec = Logic.recommendColonyFocus(RULES, st2, c); + check('low population recommends Population Growth', rec.value === 'growth', rec.value); + let recA = Logic.recommendAllocationFocus(RULES, st2, c); + check('low population recommends the Population Growth allocation preset', recA.key === 'growth', recA.key); + c.pop = maxPop; + + // Improvement: cap factories out. automatedfactory has no prereq, so + // nothing extra needs granting. + const factoryCap = Logic.colonyFactoryCap(RULES, st2, c); + c.factories = factoryCap; + rec = Logic.recommendColonyFocus(RULES, st2, c); + check('capped factories recommend Colony Improvement', rec.value === 'improvement', rec.value); + // Neither "capped" (colony focus) nor "still building out" (allocation + // focus) — clears both factory rungs so the fleet check below is the + // first thing either ladder actually trips on. + c.factories = Math.round(factoryCap * 0.8); + + // Fleet: a fleetless empire this early is always below the turn-scaled threshold. + rec = Logic.recommendColonyFocus(RULES, st2, c); + check('a fleetless empire this early recommends Fleet Production', rec.value === 'fleet', rec.value); + recA = Logic.recommendAllocationFocus(RULES, st2, c); + check('a fleetless empire this early recommends the Military Buildup allocation preset', + recA.key === 'military', recA.key); + + // A strong fleet moves the recommendation on. + Logic.addFleet(RULES, st2, c.empireIdx, c.starIdx, [{ hullId: 'battleship', mark: 1, count: 10 }]); + rec = Logic.recommendColonyFocus(RULES, st2, c); + check('a strong fleet no longer recommends Fleet Production', rec.value !== 'fleet', rec.value); + check('every recommendColonyFocus value is a real Colony Focus option', COLONY_FOCUS_VALUES.has(rec.value), rec.value); + check('every recommendAllocationFocus key is a real Allocation Focus preset', + ALLOC_FOCUS_KEYS.has(Logic.recommendAllocationFocus(RULES, st2, c).key)); + + // checkAdvisorRecommendations: silent until the player has picked + // something (both Quiet fields still null). + Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx); + check('advisors stay silent until the player has picked something', + !st2.events.some((ev) => ev.type === 'advisorRecommendation')); + + // Arm tracking, force the current setting to diverge from the live + // recommendation, and confirm exactly one event fires. + c.advisor.focusQuietUntil = st2.turn; + c.advisor.allocQuietUntil = st2.turn; + const before = Logic.recommendColonyFocus(RULES, st2, c); + c.focus = before.value === 'research' ? 'trade' : 'research'; + Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx); + let fired = st2.events.filter((ev) => ev.type === 'advisorRecommendation'); + check('a diverging recommendation fires exactly one event', fired.length === 1, `${fired.length}`); + check('the event names this colony', fired[0]?.colonyId === c.id); + + // Calling it again next turn with nothing changed must not repeat. + st2.turn += 1; + Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx); + check('an unchanged still-diverging recommendation does not fire again', + st2.events.filter((ev) => ev.type === 'advisorRecommendation').length === 1); + + // Matching the recommendation clears the dedup marker, so a LATER + // divergence is reported fresh. + c.focus = before.value; + st2.turn += 1; + Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx); + check('matching the recommendation clears the notified marker', c.advisor.focusNotifiedValue === null); + c.focus = c.focus === 'research' ? 'trade' : 'research'; + st2.turn += 1; + Logic.checkAdvisorRecommendations(RULES, st2, c.empireIdx); + fired = st2.events.filter((ev) => ev.type === 'advisorRecommendation'); + check('a fresh divergence after matching fires again', fired.length === 2, `${fired.length}`); + + // The report layer must render this without throwing and name the colony. + const desc = describeEvent(RULES, st2, fired[fired.length - 1]); + check('advisorRecommendation renders a headline naming the colony', + desc.headline.includes(c.name ?? ''), desc.headline); + } + colony.sliders = restore.sliders; colony.locked = restore.locked; colony.queue = restore.queue; @@ -2339,8 +2452,8 @@ section('9. Serialisation'); delete emp.tradeAgreements; delete emp.lastGiftTurn; delete emp.fleetIntrusions; } // Colony Focus follows the same convention: an old save has no `focus` at - // all on any colony. - for (const c of oldSave.colonies) delete c.focus; + // all on any colony. Advisor tracking is the same again, one field newer. + for (const c of oldSave.colonies) { delete c.focus; delete c.advisor; } const backOld = Logic.deserialize(JSON.stringify(oldSave)); check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld); check('tradeAgreements is back-filled to {} on an old save', @@ -2351,6 +2464,9 @@ section('9. Serialisation'); backOld.empires.every((e) => JSON.stringify(e.fleetIntrusions) === '{}')); check('focus is back-filled to manual on an old save', backOld.colonies.every((c) => c.focus === 'manual')); + check('advisor tracking is back-filled to all-null on an old save', + backOld.colonies.every((c) => c.advisor && c.advisor.focusQuietUntil === null + && c.advisor.allocQuietUntil === null && c.advisor.allocKey === null)); backOld.rules = RULES; check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => { Diplo.runGalaxyDiplomacyPass(RULES, backOld);