From 09a62f12cce07c02976441b9873d24999c5276ab Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 16 Aug 2026 08:12:16 -0600 Subject: [PATCH] feat(mastervega): add morale bonus system and espionage restraint diplomacy - Add morale bonus from buildings (e.g., Holo Simulator): flat +N% to total colony production, uniformly boosting construction, defense, industry, ecology, and research - Add espionage restraint (offDrift): setting Intelligence to 'off' toward an empire with neutral-or-better opinion drifts their attitude upward by 1/turn, giving a way to improve relations without gifting (gated on not being at war and their opinion of you being >= 0) - Display morale bonus in colony view when applicable - Reduce planetShotDamageMult from 10 to 6 - Add verification tests for both new mechanics --- data/mastervega-rules.json | 7 +-- src/games/mastervega/VegaColonyView.js | 10 +++- src/games/mastervega/VegaDiplomacy.js | 13 +++++ src/games/mastervega/VegaLogic.js | 17 +++++- tools/verifyMasterOfVega.js | 71 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 5 deletions(-) diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 139ab39..10f73f6 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -450,9 +450,10 @@ "aiProposeChance": 0.15 }, "espionage": { - "_readme": "Sabotage magnitude when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does. A successful mission (sabotage or tech theft) carries no attitude penalty — nobody publicly knows who did it, so there's nobody for the victim to be angry at; getting CAUGHT is the one outcome that still costs relations.", + "_readme": "Sabotage magnitude when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does. A successful mission (sabotage or tech theft) carries no attitude penalty — nobody publicly knows who did it, so there's nobody for the victim to be angry at; getting CAUGHT is the one outcome that still costs relations. offDrift (Brian's ask, 2026-08-15) is a separate mechanic: setting Intelligence to 'off' toward an empire whose current opinion of you is already neutral or better reads as a diplomatic gesture (I'm choosing not to spy on you), so their attitude toward you drifts up by this much per turn (VegaDiplomacy.js's driftAttitudes) — gated on not being at war, and deliberately smaller than even a Modest Gift's one-time +4 so restraint alone can't outperform actually gifting people.", "sabotageFactoriesFraction": 0.25, - "sabotageDefenseFraction": 0.25 + "sabotageDefenseFraction": 0.25, + "offDrift": 1 }, "borderTension": { "colonyRange": 6, @@ -513,7 +514,7 @@ "disengageRound": 25, "planetDefenseScale": 0.06, "planetDefensePerShot": 5, - "planetShotDamageMult": 10, + "planetShotDamageMult": 6, "bombardPopKill": 0.22, "bombardFactoryKillMult": 1, "bombardBuildingDestroyMult": 1, diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index 12fa44c..5669c46 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -44,7 +44,7 @@ import { openLeaderDetail } from './VegaLeaderDetail.js'; import { leaderOf } from './VegaLeaders.js'; import { describeBuildingTooltip, describeLeaderTooltip, describeAllocationTooltip } from './VegaTooltips.js'; import { - CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories, + CHANNELS, colonyMaxPop, colonyProduction, colonyMoraleBonus, colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany, dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign, empireColonies, maxSendablePopulation, sendPopulation, etaTo, @@ -412,6 +412,14 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) { text(`Trade ${colonyTrade(rules, state, colony).toFixed(1)} BC`, 17, '#c8dcf0', 2); text(`Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`, 17, '#c8dcf0', 2); + // Only worth a line when a building is actually granting it — same + // "don't clutter the summary with a zero" convention as the mothballed/ + // waste lines just below. + const moraleBonus = colonyMoraleBonus(rules, colony); + if (moraleBonus > 0) { + text(`Morale +${moraleBonus}% to all output (construction/defense/industry/ecology/research)`, + 14, '#9fd8ff', 2); + } // Factories nobody is left to staff are mothballed rather than demolished — // worth saying out loud, because the number simply reads as wrong otherwise. if (colony.factories > effF + 0.5) { diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js index 2bbeb46..5a331e0 100644 --- a/src/games/mastervega/VegaDiplomacy.js +++ b/src/games/mastervega/VegaDiplomacy.js @@ -315,6 +315,19 @@ export function driftAttitudes(rules, state, e) { if (Math.abs(cur - base) > 1) adjust(state, e, other.idx, pull); if (atWar(state, e, other.idx)) adjust(state, e, other.idx, -2); else if (emp.treaties[other.idx] === 'alliance') adjust(state, e, other.idx, 1); + + // Espionage restraint (Brian's ask, 2026-08-15): choosing 'off' toward + // an empire that already thinks reasonably well of us is a diplomatic + // gesture — the one and only thing that used to make attitude drift + // upward on its own was gifts, so an empire that never gifts anyone + // (or runs out of BC to) had no way to counter the baseline's downward + // pull at all. Gated on THEIR current opinion of US (not war, not our + // opinion of them) — this rewards maintaining an already-decent + // relationship, not buying back one that already hates us. + if ((emp.espionageMission[other.idx] ?? 'steal') === 'off' + && !atWar(state, e, other.idx) && (other.attitude[e] ?? 0) >= 0) { + adjust(state, other.idx, e, rules.diplomacy.espionage.offDrift ?? 1); + } } } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index ddb9345..62f93c5 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -190,6 +190,14 @@ export function effectiveFactories(rules, state, colony) { return Math.min(colony.factories, colonyFactoryCap(rules, state, colony)); } +// Total morale bonus points from this colony's buildings (currently only +// the Holo Simulator's moraleBonus: 10) — a raw point value, not yet turned +// into a multiplier. Exported so the colony screen can show it directly, +// same shape as buildingEffect's other consumers. +export function colonyMoraleBonus(rules, colony) { + return buildingEffect(rules, colony, 'moraleBonus'); +} + export function colonyProduction(rules, state, colony) { const emp = state.empires[colony.empireIdx]; const spec = rules.species[emp.speciesId]; @@ -202,7 +210,14 @@ export function colonyProduction(rules, state, colony) { * spec.traits.industryMult * buildingMult(rules, colony, 'industry') * (skills.industryMult ?? 1); - return fromPop + fromFactories; + // Morale (Brian's ask, 2026-08-15 — Holo Simulator's moraleBonus effect + // previously did nothing at all) is a flat percentage applied to this + // colony's TOTAL output, before it gets split across the five sliders in + // processColony — so it lifts construction, defense, industry, ecology, + // AND research uniformly, exactly as a broad "happier citizens work + // better at everything" bonus should, rather than favouring one channel. + const moraleMult = 1 + colonyMoraleBonus(rules, colony) / 100; + return (fromPop + fromFactories) * moraleMult; } export function colonyDefenseCap(rules, state, colony) { diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index da85bf1..491a66b 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -2591,6 +2591,29 @@ section('5g. AI bombard/invade at a contested multi-colony star'); // --------------------------------------------------------------------------- section('6. Colony economy'); // --------------------------------------------------------------------------- +// --- morale (Brian's ask, 2026-08-15): the Holo Simulator's moraleBonus +// effect previously did nothing at all — a flat +N% now applies uniformly +// to a colony's TOTAL production, before the five sliders split it, so +// construction/defense/industry/ecology/research all benefit equally. +{ + const st = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'spiral', seed: 31, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: -1, + }); + st.rules = RULES; + const colony = st.colonies[0]; + check('a colony with no Holo Simulator has zero morale bonus', + Logic.colonyMoraleBonus(RULES, colony) === 0); + const prodBefore = Logic.colonyProduction(RULES, st, colony); + colony.buildings.push('holosimulator'); + check("the Holo Simulator's moraleBonus (10) is now readable", + Logic.colonyMoraleBonus(RULES, colony) === 10); + const prodAfter = Logic.colonyProduction(RULES, st, colony); + check('building it lifts total colony production by exactly the morale percentage', + Math.abs(prodAfter - prodBefore * 1.1) < 1e-9, `${prodBefore} -> ${prodAfter}`); + colony.buildings.pop(); +} + { const st = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 31, difficultyId: 'normal', @@ -3398,6 +3421,54 @@ section('7. Diplomacy and the Galactic Council'); Diplo.makePeace(RULES, st, 0, 1); check('peace is mutual', !Logic.atWar(st, 0, 1) && !Logic.atWar(st, 1, 0)); + // Espionage restraint drift (Brian's ask, 2026-08-15): setting Intelligence + // to 'off' toward an empire that already thinks reasonably well of us + // should nudge their opinion of us up a little every turn — the one + // mechanic besides gifts that can push attitude UP rather than just decay + // toward baseline. Isolated by diffing WITH vs WITHOUT the 'off' setting + // from the identical starting attitude, so the pre-existing baseline-pull + // noise (which applies either way) cancels out and only offDrift remains. + { + const st2 = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + st2.rules = RULES; + st2.empires[0].contacted[1] = true; + st2.empires[1].contacted[0] = true; + + st2.empires[1].attitude[0] = 10; // kkrix's opinion of the human: neutral-or-better + Diplo.driftAttitudes(RULES, st2, 0); + const withoutOff = st2.empires[1].attitude[0]; + + st2.empires[1].attitude[0] = 10; // reset to the same starting point + st2.empires[0].espionageMission[1] = 'off'; + Diplo.driftAttitudes(RULES, st2, 0); + const withOff = st2.empires[1].attitude[0]; + + check("'off' toward an empire that already likes us at least neutrally drifts their opinion up by offDrift", + withOff - withoutOff === (RULES.diplomacy.espionage.offDrift ?? 1), `${withoutOff} -> ${withOff}`); + + // Control: no drift if THEIR opinion of us is already negative — this + // rewards maintaining good relations, not buying back a rival that + // already dislikes us. + st2.empires[1].attitude[0] = -10; + Diplo.driftAttitudes(RULES, st2, 0); + check("'off' grants no drift when their opinion of us is already negative", + st2.empires[1].attitude[0] === -10, `${st2.empires[1].attitude[0]}`); + + // Control: no drift while at war, even if attitude somehow still reads + // neutral-or-better this same turn. driftAttitudes(e=0) never touches + // empires[1].attitude[0] except through the 'off' block (every other + // line in it writes to empires[0]'s own attitude array instead), so a + // truly-gated 'off' bonus should leave it perfectly unchanged. + Diplo.declareWar(RULES, st2, 0, 1); + st2.empires[1].attitude[0] = 10; // declareWar itself dings attitude — reset AFTER, to isolate the war gate + Diplo.driftAttitudes(RULES, st2, 0); + check("'off' grants no drift while at war", + st2.empires[1].attitude[0] === 10, `${st2.empires[1].attitude[0]}`); + } + check('attitudes stay in range', (() => { for (let i = 0; i < 400; i += 1) { for (const e of st.empires) Diplo.driftAttitudes(RULES, st, e.idx);