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
This commit is contained in:
Brian Fertig 2026-08-16 08:12:16 -06:00
parent 46a2979555
commit 09a62f12cc
5 changed files with 113 additions and 5 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -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);
}
}
}

View File

@ -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) {

View File

@ -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);