fertig-classic-games/src/games/mastervega/VegaDiplomacy.js

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