From 6a24ccd13755e4438a290add0e50f95fec25638b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 8 Aug 2026 22:07:51 -0600 Subject: [PATCH] feat(mastervega): add advisor recommendation system with lazy diplomacy music loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an advisor recommendation system that suggests colony focus (improvement, research, fleet, growth, trade, defense) and allocation slider presets (growth, production, military, research, default) for each colony. Recommendations are computed by pure heuristics that consider population levels, factory utilization, empire-wide fleet strength, war status, and building availability. UI changes: - Colony Focus and Allocation Focus flyouts display "YOUR ADVISORS RECOMMEND" banners with explanatory text and pulsing arrow indicators pointing at the recommended row. - Selecting a recommendation arms quiet-period tracking (15 turns) so advisors don't nag immediately after the player makes a choice. - `checkAdvisorRecommendations` fires once-a-turn `advisorRecommendation` events when current settings diverge from live recommendations. Events use dedup markers (`focusNotifiedValue`, `allocNotifiedKey`) that clear on match, ensuring a persisting mismatch doesn't repeat every turn but a later divergence is reported fresh. - Turn report screen gains an "Advisor Recommendation" event type with a "View Colony" shortcut button. Performance change: - Stop eager-loading `diplomacy.bySpecies` music tracks in `assetManifest.js`. A playthrough may contact only a handful of the nine species, so pre-fetching all nine at room entry wastes bandwidth. `VegaMusic`'s own `new Audio()` at `setDiplomacy()` time is the actual fetch — this manifest was only ever a race to get there first. Logic changes: - Extract shared building-ID lists and a `firstEligibleBuildingId` helper to eliminate duplication between auto-queue pickers and the recommendation heuristics. - Add `advisor` tracking object to every colony (with migration in `deserialize`). --- src/data/assetManifest.js | 14 +- src/games/mastervega/MasterOfVegaGame.js | 4 + src/games/mastervega/VegaColonyView.js | 61 ++++- src/games/mastervega/VegaLogic.js | 266 +++++++++++++++++-- src/games/mastervega/VegaTurnReport.js | 29 +- src/games/mastervega/VegaTurnReportScreen.js | 22 +- tools/verifyMasterOfVega.js | 120 ++++++++- 7 files changed, 484 insertions(+), 32 deletions(-) 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);