276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
// Master of Vega — diplomacy, treaties and the Galactic Council's politics.
|
|
// Headless: no Phaser, so the whole state machine is soak-testable.
|
|
//
|
|
// Attitude runs -100 (hatred) to +100 (devotion) and is the single number every
|
|
// decision reads. Species diplomacy traits bias it permanently — the Lithox sit
|
|
// at -100 and simply cannot be talked to, which is the point of them.
|
|
|
|
import { empireColonies, atWar, rand, randInt } from './VegaLogic.js';
|
|
|
|
export const TREATIES = ['none', 'peace', 'alliance', 'war'];
|
|
|
|
export function attitudeOf(state, a, b) {
|
|
return state.empires[a]?.attitude?.[b] ?? 0;
|
|
}
|
|
|
|
export function moodOf(attitude) {
|
|
if (attitude <= -60) return 'hostile';
|
|
if (attitude <= -20) return 'cold';
|
|
if (attitude < 20) return 'neutral';
|
|
if (attitude < 60) return 'warm';
|
|
return 'devoted';
|
|
}
|
|
|
|
// Collapses moodOf's five attitude tiers to the three video moods the
|
|
// Audience screen actually has clips for (VegaAudience.js).
|
|
export function videoMood(attitude) {
|
|
const m = moodOf(attitude);
|
|
if (m === 'hostile' || m === 'cold') return 'angry';
|
|
if (m === 'warm' || m === 'devoted') return 'happy';
|
|
return 'neutral';
|
|
}
|
|
|
|
function adjust(state, a, b, delta) {
|
|
const emp = state.empires[a];
|
|
emp.attitude[b] = Math.max(-100, Math.min(100, (emp.attitude[b] ?? 0) + delta));
|
|
}
|
|
|
|
// The natural pull a species feels toward or away from everyone else. A species
|
|
// with a diplomacy penalty drifts back to dislike no matter what you do for it.
|
|
function baseline(rules, state, a) {
|
|
return Math.round((rules.species[state.empires[a].speciesId].traits.diplomacy ?? 0) / 2);
|
|
}
|
|
|
|
export function canNegotiate(rules, state, a, b) {
|
|
const sa = rules.species[state.empires[a].speciesId];
|
|
const sb = rules.species[state.empires[b].speciesId];
|
|
// A species with total diplomatic incapacity never comes to the table.
|
|
if ((sa.traits.diplomacy ?? 0) <= -100 || (sb.traits.diplomacy ?? 0) <= -100) return false;
|
|
return !!state.empires[a].contacted[b];
|
|
}
|
|
|
|
export function declareWar(rules, state, a, b) {
|
|
state.empires[a].treaties[b] = 'war';
|
|
state.empires[b].treaties[a] = 'war';
|
|
adjust(state, b, a, -35);
|
|
adjust(state, a, b, -15);
|
|
state.events.push({ type: 'warDeclared', empire: a, other: b, turn: state.turn });
|
|
// Everyone else notices an aggressor.
|
|
for (const o of state.empires) {
|
|
if (!o.alive || o.idx === a || o.idx === b) continue;
|
|
if (o.contacted[a]) adjust(state, o.idx, a, -6);
|
|
}
|
|
}
|
|
|
|
export function makePeace(rules, state, a, b) {
|
|
state.empires[a].treaties[b] = 'peace';
|
|
state.empires[b].treaties[a] = 'peace';
|
|
adjust(state, a, b, 12);
|
|
adjust(state, b, a, 12);
|
|
state.events.push({ type: 'peace', empire: a, other: b, turn: state.turn });
|
|
}
|
|
|
|
export function formAlliance(rules, state, a, b) {
|
|
state.empires[a].treaties[b] = 'alliance';
|
|
state.empires[b].treaties[a] = 'alliance';
|
|
adjust(state, a, b, 20);
|
|
adjust(state, b, a, 20);
|
|
state.events.push({ type: 'alliance', empire: a, other: b, turn: state.turn });
|
|
}
|
|
|
|
// Relative strength, used everywhere an empire has to decide whether it can
|
|
// afford an opinion.
|
|
export function powerOf(rules, state, e) {
|
|
const emp = state.empires[e];
|
|
let power = emp.totalPop;
|
|
for (const c of empireColonies(state, e)) power += c.factories * 0.5 + c.defenseHp * 0.1;
|
|
power += emp.techsKnown * 8;
|
|
return power;
|
|
}
|
|
|
|
// Would `b` accept this proposal from `a`?
|
|
export function wouldAccept(rules, state, a, b, kind) {
|
|
if (!canNegotiate(rules, state, a, b)) return false;
|
|
const att = attitudeOf(state, b, a);
|
|
const ratio = powerOf(rules, state, a) / Math.max(1, powerOf(rules, state, b));
|
|
switch (kind) {
|
|
case 'peace':
|
|
// The losing side sues for peace readily; a winning one has no reason to.
|
|
return att > -70 && (att > -20 || ratio > 1.4);
|
|
case 'alliance':
|
|
return att > 45 && !atWar(state, a, b);
|
|
case 'techTrade':
|
|
return att > 0;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function proposeTreaty(rules, state, a, b, kind) {
|
|
if (!wouldAccept(rules, state, a, b, kind)) {
|
|
adjust(state, b, a, -3);
|
|
return false;
|
|
}
|
|
if (kind === 'peace') makePeace(rules, state, a, b);
|
|
else if (kind === 'alliance') formAlliance(rules, state, a, b);
|
|
return true;
|
|
}
|
|
|
|
// When an AI proposes peace or an alliance TO THE HUMAN, resolving it
|
|
// unilaterally (like AI-vs-AI) would give the Audience screen's Accept/Reject
|
|
// buttons nothing to actually decide. So a proposal aimed at the human is
|
|
// HELD instead — the human answers it via respondToOffer, not wouldAccept.
|
|
// War stays immediate/unilateral either direction: nothing to accept about
|
|
// being attacked.
|
|
export function proposeOrOffer(rules, state, a, b, kind) {
|
|
if (b === state.humanIndex && a !== state.humanIndex) {
|
|
if (state.empires[b].pendingOffers[a]) return 'pending'; // don't clobber an unanswered offer
|
|
state.empires[b].pendingOffers[a] = { kind, turn: state.turn };
|
|
state.events.push({ type: 'offerReceived', empire: a, other: b, kind, turn: state.turn });
|
|
return 'pending';
|
|
}
|
|
return proposeTreaty(rules, state, a, b, kind);
|
|
}
|
|
|
|
// The human's answer to a held offer, from the Audience screen's
|
|
// Accept/Reject buttons. `b` is the human (the offer's target), `a` is the
|
|
// empire that made it.
|
|
export function respondToOffer(rules, state, b, a, accept) {
|
|
const offer = state.empires[b]?.pendingOffers?.[a];
|
|
if (!offer) return false;
|
|
delete state.empires[b].pendingOffers[a];
|
|
if (accept) {
|
|
if (offer.kind === 'peace') makePeace(rules, state, a, b);
|
|
else if (offer.kind === 'alliance') formAlliance(rules, state, a, b);
|
|
} else {
|
|
adjust(state, a, b, -8); // the proposer stings at being refused
|
|
adjust(state, b, a, -2); // refusing is itself a small friction cost
|
|
state.events.push({ type: 'offerRejected', empire: b, other: a, kind: offer.kind, turn: state.turn });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// A contact event auto-opens the full-screen Audience interface only for
|
|
// species capable of diplomacy — Lithox (and anything else the data ever
|
|
// marks incapable) instead falls through unchanged to the ordinary turn
|
|
// report. Marks each claimed event `announced` so the turn report's own
|
|
// "skip already-announced events" loop never shows it twice.
|
|
export function claimAudienceContacts(rules, state, me) {
|
|
const found = [];
|
|
for (const ev of state.events) {
|
|
if (ev.announced || ev.type !== 'contact') continue;
|
|
const otherIdx = ev.empire === me ? ev.other : (ev.other === me ? ev.empire : undefined);
|
|
if (otherIdx === undefined) continue;
|
|
if (!canNegotiate(rules, state, me, otherIdx)) continue;
|
|
ev.announced = true;
|
|
found.push(otherIdx);
|
|
}
|
|
return [...new Set(found)];
|
|
}
|
|
|
|
// Trade a tech each. Both sides gain, which is why an empire with a research
|
|
// bonus should think twice — and why a species locked out of half the tree by
|
|
// its availability roll should think once.
|
|
export function tradeTech(rules, state, a, b, giveId, wantId) {
|
|
const A = state.empires[a];
|
|
const B = state.empires[b];
|
|
if (!A.known[giveId] || !B.known[wantId]) return false;
|
|
if (B.known[giveId] || A.known[wantId]) return false;
|
|
if (!wouldAccept(rules, state, a, b, 'techTrade')) return false;
|
|
// grantTech lives in VegaLogic; importing it here would make the two modules
|
|
// mutually dependent, so the caller applies the grants.
|
|
return true;
|
|
}
|
|
|
|
// Per-turn drift. Attitudes decay toward the species baseline, war grinds them
|
|
// down, and shared borders create friction.
|
|
export function driftAttitudes(rules, state, e) {
|
|
const emp = state.empires[e];
|
|
const base = baseline(rules, state, e);
|
|
for (const other of state.empires) {
|
|
if (!other.alive || other.idx === e) continue;
|
|
if (!emp.contacted[other.idx]) continue;
|
|
const cur = emp.attitude[other.idx] ?? 0;
|
|
const pull = cur < base ? 1 : -1;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// The AI's diplomatic step: pick fights it can win, sue for peace when losing,
|
|
// and ally with whoever it likes enough.
|
|
export function runDiplomacyTurn(rules, state, e) {
|
|
const emp = state.empires[e];
|
|
const diff = rules.difficulties[state.difficultyId];
|
|
driftAttitudes(rules, state, e);
|
|
|
|
// A held offer nobody ever answers shouldn't block this empire from trying
|
|
// again, and shouldn't sit forever either.
|
|
const human = state.empires[state.humanIndex];
|
|
const myOffer = human?.pendingOffers?.[e];
|
|
if (myOffer && state.turn - myOffer.turn > 6) {
|
|
delete human.pendingOffers[e];
|
|
state.events.push({ type: 'offerExpired', empire: e, other: state.humanIndex, kind: myOffer.kind, turn: state.turn });
|
|
}
|
|
|
|
const others = state.empires.filter((o) => o.alive && o.idx !== e && emp.contacted[o.idx]);
|
|
if (!others.length) return;
|
|
|
|
const myPower = powerOf(rules, state, e);
|
|
for (const other of others) {
|
|
const att = attitudeOf(state, e, other.idx);
|
|
const ratio = myPower / Math.max(1, powerOf(rules, state, other.idx));
|
|
|
|
if (atWar(state, e, other.idx)) {
|
|
// Sue for peace when clearly losing, or when the war has gone cold.
|
|
// Only sue for peace when genuinely losing, and not immediately — a war
|
|
// that ends on the turn it starts accomplishes nothing.
|
|
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace');
|
|
continue;
|
|
}
|
|
|
|
if (!canNegotiate(rules, state, e, other.idx)) {
|
|
// Species that cannot negotiate still go to war — more readily, in fact.
|
|
if (ratio > 1.25 && rand(state) < 0.06 * diff.aiAggression) declareWar(rules, state, e, other.idx);
|
|
continue;
|
|
}
|
|
|
|
// Aggression scales with how much stronger you are and how little you like
|
|
// them. An empire that is behind does not start wars, but one that is
|
|
// comfortably ahead does not need a grievance either — requiring BOTH a
|
|
// grudge and a big power lead meant war effectively never happened.
|
|
let appetite = 0;
|
|
if (att < -20) appetite += 0.6;
|
|
if (att < -50) appetite += 0.5;
|
|
if (ratio > 1.2) appetite += 0.5;
|
|
if (ratio > 1.8) appetite += 0.7;
|
|
// Late in a game with no clear leader, someone has to force the issue.
|
|
if (state.turn > 200) appetite += 0.4;
|
|
if (appetite >= 1 && rand(state) < 0.035 * appetite * diff.aiAggression) {
|
|
declareWar(rules, state, e, other.idx);
|
|
continue;
|
|
}
|
|
if (att > 45 && emp.treaties[other.idx] !== 'alliance' && rand(state) < 0.08) {
|
|
proposeOrOffer(rules, state, e, other.idx, 'alliance');
|
|
}
|
|
}
|
|
}
|
|
|
|
// A stalled galaxy is a boring galaxy. If nobody has fought for a long time and
|
|
// the Council keeps failing to elect anyone, nudge the strongest empire into
|
|
// picking on someone. Without this, high-attitude galaxies can idle to the turn
|
|
// cap with five empires politely coexisting and no winner.
|
|
export function breakStalemate(rules, state) {
|
|
const alive = state.empires.filter((e) => e.alive);
|
|
if (alive.length < 2) return false;
|
|
if (alive.some((a) => alive.some((b) => a.idx !== b.idx && atWar(state, a.idx, b.idx)))) return false;
|
|
|
|
const ranked = alive.slice().sort((a, b) => powerOf(rules, state, b.idx) - powerOf(rules, state, a.idx));
|
|
const aggressor = ranked[0];
|
|
const victim = ranked[randInt(state, Math.max(1, ranked.length - 1)) + 1] ?? ranked[1];
|
|
if (!victim || victim.idx === aggressor.idx) return false;
|
|
declareWar(rules, state, aggressor.idx, victim.idx);
|
|
return true;
|
|
}
|