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:
parent
46a2979555
commit
09a62f12cc
|
|
@ -450,9 +450,10 @@
|
||||||
"aiProposeChance": 0.15
|
"aiProposeChance": 0.15
|
||||||
},
|
},
|
||||||
"espionage": {
|
"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,
|
"sabotageFactoriesFraction": 0.25,
|
||||||
"sabotageDefenseFraction": 0.25
|
"sabotageDefenseFraction": 0.25,
|
||||||
|
"offDrift": 1
|
||||||
},
|
},
|
||||||
"borderTension": {
|
"borderTension": {
|
||||||
"colonyRange": 6,
|
"colonyRange": 6,
|
||||||
|
|
@ -513,7 +514,7 @@
|
||||||
"disengageRound": 25,
|
"disengageRound": 25,
|
||||||
"planetDefenseScale": 0.06,
|
"planetDefenseScale": 0.06,
|
||||||
"planetDefensePerShot": 5,
|
"planetDefensePerShot": 5,
|
||||||
"planetShotDamageMult": 10,
|
"planetShotDamageMult": 6,
|
||||||
"bombardPopKill": 0.22,
|
"bombardPopKill": 0.22,
|
||||||
"bombardFactoryKillMult": 1,
|
"bombardFactoryKillMult": 1,
|
||||||
"bombardBuildingDestroyMult": 1,
|
"bombardBuildingDestroyMult": 1,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { openLeaderDetail } from './VegaLeaderDetail.js';
|
||||||
import { leaderOf } from './VegaLeaders.js';
|
import { leaderOf } from './VegaLeaders.js';
|
||||||
import { describeBuildingTooltip, describeLeaderTooltip, describeAllocationTooltip } from './VegaTooltips.js';
|
import { describeBuildingTooltip, describeLeaderTooltip, describeAllocationTooltip } from './VegaTooltips.js';
|
||||||
import {
|
import {
|
||||||
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
|
CHANNELS, colonyMaxPop, colonyProduction, colonyMoraleBonus, colonyFactoryCap, effectiveFactories,
|
||||||
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
|
||||||
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
|
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
|
||||||
empireColonies, maxSendablePopulation, sendPopulation, etaTo,
|
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(`Trade ${colonyTrade(rules, state, colony).toFixed(1)} BC`, 17, '#c8dcf0', 2);
|
||||||
text(`Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`,
|
text(`Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`,
|
||||||
17, '#c8dcf0', 2);
|
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 —
|
// Factories nobody is left to staff are mothballed rather than demolished —
|
||||||
// worth saying out loud, because the number simply reads as wrong otherwise.
|
// worth saying out loud, because the number simply reads as wrong otherwise.
|
||||||
if (colony.factories > effF + 0.5) {
|
if (colony.factories > effF + 0.5) {
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,19 @@ export function driftAttitudes(rules, state, e) {
|
||||||
if (Math.abs(cur - base) > 1) adjust(state, e, other.idx, pull);
|
if (Math.abs(cur - base) > 1) adjust(state, e, other.idx, pull);
|
||||||
if (atWar(state, e, other.idx)) adjust(state, e, other.idx, -2);
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,14 @@ export function effectiveFactories(rules, state, colony) {
|
||||||
return Math.min(colony.factories, colonyFactoryCap(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) {
|
export function colonyProduction(rules, state, colony) {
|
||||||
const emp = state.empires[colony.empireIdx];
|
const emp = state.empires[colony.empireIdx];
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
|
|
@ -202,7 +210,14 @@ export function colonyProduction(rules, state, colony) {
|
||||||
* spec.traits.industryMult
|
* spec.traits.industryMult
|
||||||
* buildingMult(rules, colony, 'industry')
|
* buildingMult(rules, colony, 'industry')
|
||||||
* (skills.industryMult ?? 1);
|
* (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) {
|
export function colonyDefenseCap(rules, state, colony) {
|
||||||
|
|
|
||||||
|
|
@ -2591,6 +2591,29 @@ section('5g. AI bombard/invade at a contested multi-colony star');
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
section('6. Colony economy');
|
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, {
|
const st = Logic.createGame(RULES, {
|
||||||
sizeId: 'medium', shapeId: 'spiral', seed: 31, difficultyId: 'normal',
|
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);
|
Diplo.makePeace(RULES, st, 0, 1);
|
||||||
check('peace is mutual', !Logic.atWar(st, 0, 1) && !Logic.atWar(st, 1, 0));
|
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', (() => {
|
check('attitudes stay in range', (() => {
|
||||||
for (let i = 0; i < 400; i += 1) {
|
for (let i = 0; i < 400; i += 1) {
|
||||||
for (const e of st.empires) Diplo.driftAttitudes(RULES, st, e.idx);
|
for (const e of st.empires) Diplo.driftAttitudes(RULES, st, e.idx);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue