Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw. The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production. Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with. Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
436 lines
16 KiB
JavaScript
436 lines
16 KiB
JavaScript
// Diplomatic treaties: proposals that one nation makes to another, the active
|
|
// agreements they become when signed, and the history of those that ended. The
|
|
// type catalogue lives in `data/treaties.js`; this module owns the state machine
|
|
// (propose -> sign/reject -> break) and the effects each treaty carries.
|
|
//
|
|
// A signed treaty is a record `{ id, type, a, b, signedHours, signedDate,
|
|
// payload }` in `this.treaties`. A proposal is `{ id, type, from, to, payload,
|
|
// proposedHours, proposedDate }` in `this.treatyProposals`. Ended treaties move
|
|
// to `this.treatyHistory` with the reason and hour they ended. Everything is
|
|
// public, so it all travels in the shared snapshot.
|
|
|
|
import { key } from "../hex.js";
|
|
import {
|
|
TREATY_PEACE,
|
|
TREATY_ALLIANCE,
|
|
TREATY_FREE_TRADE,
|
|
TREATY_GIVE_CITY,
|
|
TREATY_GIVE_MILITARY,
|
|
TREATY_GIVE_RESOURCES,
|
|
TREATY_GIVE_MONEY,
|
|
TREATY_JOINT_RESEARCH,
|
|
treatyById,
|
|
TREATY_PROPOSED,
|
|
TREATY_SIGNED,
|
|
TREATY_REJECTED,
|
|
TREATY_BROKEN,
|
|
} from "../data/treaties.js";
|
|
import { NEWS_TREATY } from "../data/relations.js";
|
|
import { STORABLE_RESOURCE_IDS } from "../data/resources.js";
|
|
import { warKey } from "./helpers.js";
|
|
import { dateString } from "../game_clock.js";
|
|
|
|
// A treaty proposal older than this many hours is dropped, so a stale offer
|
|
// does not sit in the receiver's inbox for ever.
|
|
const PROPOSAL_TTL_HOURS = 24 * 30;
|
|
|
|
export const treatyMethods = {
|
|
// ------------------------------------------------------------ lifecycle --
|
|
|
|
// Offers a treaty from one nation to another. The type must be known, the
|
|
// parties distinct and valid, and any payload (a city, units, a resource
|
|
// basket or money) must belong to the proposer. A duplicate offer for the
|
|
// same type between the same two nations is refused.
|
|
requestProposeTreaty(from, to, type, payload = {}) {
|
|
if (!this._validCiv(from) || !this._validCiv(to) || from === to) return false;
|
|
const proto = treatyById(type);
|
|
if (!proto) return false;
|
|
if (this._pendingTreaty(from, to, type)) return false;
|
|
if (this.hasTreaty(from, to, type)) return false;
|
|
const clean = this._validateTreatyPayload(from, to, type, payload);
|
|
if (clean === null) return false;
|
|
const proposal = {
|
|
id: this._nextTreatyProposalId++,
|
|
type,
|
|
from,
|
|
to,
|
|
payload: clean,
|
|
proposedHours: this.totalHours,
|
|
proposedDate: dateString(this.totalHours),
|
|
};
|
|
this.treatyProposals.push(proposal);
|
|
this._addNews({
|
|
type: NEWS_TREATY, stage: TREATY_PROPOSED, treaty: type, from, to,
|
|
});
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Accepts or rejects a proposal addressed to `civ`. Only the nation the
|
|
// proposal names as its receiver may answer, and only once.
|
|
requestRespondTreaty(civ, proposalId, accept) {
|
|
const index = this.treatyProposals.findIndex((p) => p.id === proposalId);
|
|
if (index < 0) return false;
|
|
const proposal = this.treatyProposals[index];
|
|
if (proposal.to !== civ) return false;
|
|
this.treatyProposals.splice(index, 1);
|
|
if (!accept) {
|
|
this._addNews({
|
|
type: NEWS_TREATY, stage: TREATY_REJECTED, treaty: proposal.type,
|
|
from: proposal.from, to: proposal.to,
|
|
});
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
const payload = this._validateTreatyPayload(proposal.from, proposal.to, proposal.type, proposal.payload);
|
|
if (payload === null) return false;
|
|
const treaty = {
|
|
id: this._nextTreatyId++,
|
|
type: proposal.type,
|
|
a: proposal.from,
|
|
b: proposal.to,
|
|
payload,
|
|
signedHours: this.totalHours,
|
|
signedDate: dateString(this.totalHours),
|
|
};
|
|
// Free trade is a standing exemption, not a one-off effect.
|
|
if (proposal.type !== TREATY_FREE_TRADE) this._applyTreatyEffect(treaty);
|
|
this.treaties.push(treaty);
|
|
this._addNews({
|
|
type: NEWS_TREATY, stage: TREATY_SIGNED, treaty: treaty.type,
|
|
from: treaty.a, to: treaty.b,
|
|
});
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Ends an active treaty, whoever asks. Used when a player breaks an
|
|
// agreement and when a war cancels an alliance.
|
|
_endTreaty(treaty, reason = TREATY_BROKEN) {
|
|
const index = this.treaties.indexOf(treaty);
|
|
if (index < 0) return false;
|
|
this.treaties.splice(index, 1);
|
|
treaty.endedHours = this.totalHours;
|
|
treaty.endedDate = dateString(this.totalHours);
|
|
treaty.reason = reason;
|
|
this.treatyHistory.push(treaty);
|
|
while (this.treatyHistory.length > this._treatyHistoryLimit) this.treatyHistory.shift();
|
|
this._addNews({
|
|
type: NEWS_TREATY, stage: reason, treaty: treaty.type,
|
|
from: treaty.a, to: treaty.b,
|
|
});
|
|
return true;
|
|
},
|
|
|
|
// A player cancels one of their own active treaties.
|
|
requestCancelTreaty(civ, treatyId) {
|
|
const treaty = this.treaties.find((t) => t.id === treatyId);
|
|
if (!treaty || (treaty.a !== civ && treaty.b !== civ)) return false;
|
|
this._endTreaty(treaty, TREATY_BROKEN);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// ------------------------------------------------------------- queries --
|
|
|
|
hasTreaty(a, b, type) {
|
|
return this.getTreaty(a, b, type) !== null;
|
|
},
|
|
|
|
getTreaty(a, b, type) {
|
|
if (a < 0 || b < 0 || a === b) return null;
|
|
for (const treaty of this.treaties) {
|
|
if (treaty.type !== type) continue;
|
|
if ((treaty.a === a && treaty.b === b) || (treaty.a === b && treaty.b === a)) {
|
|
return treaty;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
areAllied(a, b) {
|
|
return this.hasTreaty(a, b, TREATY_ALLIANCE);
|
|
},
|
|
|
|
freeTradePartners(civ) {
|
|
const partners = new Set();
|
|
for (const treaty of this.treaties) {
|
|
if (treaty.type !== TREATY_FREE_TRADE) continue;
|
|
if (treaty.a === civ) partners.add(treaty.b);
|
|
else if (treaty.b === civ) partners.add(treaty.a);
|
|
}
|
|
return partners;
|
|
},
|
|
|
|
// Whether a tariff between a seller and a buyer is lifted by a free-trade
|
|
// treaty. Used by the resource market when it charges the tax.
|
|
_tariffExempt(seller, buyer) {
|
|
if (seller < 0 || buyer < 0) return false;
|
|
return this.freeTradePartners(seller).has(buyer);
|
|
},
|
|
|
|
// Every nation allied with `civ`.
|
|
_alliesOf(civ) {
|
|
const allies = [];
|
|
for (const treaty of this.treaties) {
|
|
if (treaty.type !== TREATY_ALLIANCE) continue;
|
|
if (treaty.a === civ) allies.push(treaty.b);
|
|
else if (treaty.b === civ) allies.push(treaty.a);
|
|
}
|
|
return allies;
|
|
},
|
|
|
|
// Nations that share a joint-research treaty with `civ`.
|
|
jointResearchPartners(civ) {
|
|
const partners = [];
|
|
for (const treaty of this.treaties) {
|
|
if (treaty.type !== TREATY_JOINT_RESEARCH) continue;
|
|
if (treaty.a === civ) partners.push(treaty.b);
|
|
else if (treaty.b === civ) partners.push(treaty.a);
|
|
}
|
|
return partners;
|
|
},
|
|
|
|
_pendingTreaty(from, to, type) {
|
|
return this.treatyProposals.some(
|
|
(p) => p.type === type && p.from === from && p.to === to
|
|
);
|
|
},
|
|
|
|
// Expires old proposals; called from the hourly tick. Returns true when the
|
|
// inbox changed.
|
|
_expireTreatyProposals() {
|
|
if (this.treatyProposals.length === 0) return false;
|
|
const before = this.treatyProposals.length;
|
|
this.treatyProposals = this.treatyProposals.filter(
|
|
(p) => this.totalHours - p.proposedHours < PROPOSAL_TTL_HOURS
|
|
);
|
|
return this.treatyProposals.length !== before;
|
|
},
|
|
|
|
// ------------------------------------------------------------ validation --
|
|
|
|
// Returns the cleaned payload for a treaty, or null when it cannot be
|
|
// honoured. The `from` nation must own whatever it is giving away.
|
|
_validateTreatyPayload(from, to, type, payload) {
|
|
const data = payload || {};
|
|
if (type === TREATY_GIVE_CITY) {
|
|
const city = this.findCity(Number(data.city));
|
|
if (!city || city.civ !== from) return null;
|
|
return { city: city.id };
|
|
}
|
|
if (type === TREATY_GIVE_MILITARY) {
|
|
const ids = Array.isArray(data.units) ? data.units.map(Number) : [];
|
|
const units = [];
|
|
for (const id of ids) {
|
|
const unit = this.findUnit(id);
|
|
if (!unit || unit.civ !== from) return null;
|
|
const proto = this.unitProto(unit);
|
|
if (proto && proto.infantry) return null;
|
|
units.push(unit.id);
|
|
}
|
|
if (units.length === 0) return null;
|
|
return { units };
|
|
}
|
|
if (type === TREATY_GIVE_RESOURCES) {
|
|
const basket = {};
|
|
let any = false;
|
|
for (const id of STORABLE_RESOURCE_IDS) {
|
|
const amount = Number((data.resources || {})[id] || 0);
|
|
if (!(amount > 0)) continue;
|
|
basket[id] = amount;
|
|
any = true;
|
|
}
|
|
return any ? { resources: basket } : null;
|
|
}
|
|
if (type === TREATY_GIVE_MONEY) {
|
|
const amount = Number(data.amount || 0);
|
|
if (!(amount > 0)) return null;
|
|
const payload = { amount };
|
|
// The gift is denominated in any nation's currency; it defaults to the
|
|
// receiver's own. An unknown code is refused.
|
|
if (data.currency) {
|
|
const code = String(data.currency);
|
|
if (this._civByCurrencyCode().get(code) === undefined) return null;
|
|
payload.currency = code;
|
|
}
|
|
return payload;
|
|
}
|
|
// Peace, alliance, free trade and joint research carry no payload.
|
|
return {};
|
|
},
|
|
|
|
// -------------------------------------------------------------- effects --
|
|
|
|
_applyTreatyEffect(treaty) {
|
|
switch (treaty.type) {
|
|
case TREATY_PEACE:
|
|
this._makePeace(treaty.a, treaty.b);
|
|
break;
|
|
case TREATY_GIVE_CITY: {
|
|
const city = this.findCity(treaty.payload.city);
|
|
if (city && city.civ === treaty.a) this._captureCity(city, treaty.b);
|
|
break;
|
|
}
|
|
case TREATY_GIVE_MILITARY:
|
|
this._giftUnits(treaty.a, treaty.b, treaty.payload.units);
|
|
break;
|
|
case TREATY_GIVE_RESOURCES:
|
|
this._giftResources(treaty.a, treaty.b, treaty.payload.resources);
|
|
break;
|
|
case TREATY_GIVE_MONEY: {
|
|
this._payTreatyMoney(treaty.a, treaty.b, treaty.payload.amount, treaty.payload.currency);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
|
|
// Peace ends the war, forgets the conflict record only in so far as combat
|
|
// stops, and sends each side's troops back to their nearest city.
|
|
_makePeace(a, b) {
|
|
const k = warKey(a, b);
|
|
if (this.wars.has(k)) {
|
|
this.wars.delete(k);
|
|
this.conflicts.delete(k);
|
|
this._invalidateSpatialCaches();
|
|
}
|
|
this._sendTroopsHome(a, b);
|
|
this._sendTroopsHome(b, a);
|
|
},
|
|
|
|
// Teleports every unit of `civ` standing in `enemy`'s territory to its
|
|
// nearest own city. An island unit with no reachable home simply appears at
|
|
// the nearest city by tile distance, mirroring the "for now" simplification.
|
|
_sendTroopsHome(civ, enemy) {
|
|
const home = this.cities.filter((city) => city.civ === civ);
|
|
if (home.length === 0) return;
|
|
for (const unit of this.units.slice()) {
|
|
if (unit.civ !== civ) continue;
|
|
const k = key(unit.coords.x, unit.coords.y);
|
|
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
|
|
if (owner !== enemy) continue;
|
|
let best = home[0];
|
|
let bestDistance = Infinity;
|
|
for (const city of home) {
|
|
const distance = this.topology.tileDistance(unit.coords, city.coords);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = city;
|
|
}
|
|
}
|
|
unit.path = [];
|
|
unit.pathIndex = 0;
|
|
unit.strikeTarget = null;
|
|
this._moveUnitTo(unit, { x: best.coords.x, y: best.coords.y });
|
|
}
|
|
this._invalidateUnitCaches();
|
|
},
|
|
|
|
_giftUnits(from, to, ids) {
|
|
let moved = 0;
|
|
for (const id of ids) {
|
|
const unit = this.findUnit(id);
|
|
if (!unit || unit.civ !== from) continue;
|
|
this._unindexUnit(unit);
|
|
unit.civ = to;
|
|
this._indexUnit(unit);
|
|
moved += 1;
|
|
}
|
|
if (moved > 0) {
|
|
this._invalidateUnitCaches();
|
|
this._touchModifiers();
|
|
}
|
|
},
|
|
|
|
// Moves a resource basket from one nation to another, draining the giver's
|
|
// cities in proportion to what each holds and dropping it all on the
|
|
// receiver's capital (or first city). Non-storable goods such as energy are
|
|
// skipped.
|
|
_giftResources(from, to, basket) {
|
|
const givers = this.cities.filter((city) => city.civ === from);
|
|
const takers = this.cities.filter((city) => city.civ === to);
|
|
if (givers.length === 0 || takers.length === 0) return;
|
|
const sink = takers.find((city) => city.isCapital) || takers[0];
|
|
const sinkStore = this.getCityResourceStock(sink);
|
|
for (const [id, wanted] of Object.entries(basket || {})) {
|
|
let remaining = wanted;
|
|
for (const city of givers) {
|
|
if (remaining <= 0) break;
|
|
const store = this.getCityResourceStock(city);
|
|
const take = Math.min(store[id] || 0, remaining);
|
|
if (take <= 0) continue;
|
|
store[id] -= take;
|
|
remaining -= take;
|
|
}
|
|
const moved = wanted - remaining;
|
|
if (moved > 0) sinkStore[id] = (sinkStore[id] || 0) + moved;
|
|
}
|
|
},
|
|
|
|
// Settles a money gift. The amount is denominated in a currency (the
|
|
// receiver's by default): the giver's central bank spends its foreign
|
|
// reserves in that currency first, then buys the rest from the issuing bank,
|
|
// which keeps the giver's currency as a reserve and issues its own against
|
|
// the matching debt, exactly as an import does. A nation cannot buy from a
|
|
// bank it is at war with, so a blocked gift settles only as far as the
|
|
// reserves already reach. The receiver is credited the gift's market value in
|
|
// its own currency, and the giver's treasury carries it at the same value.
|
|
// Returns the amount actually delivered, in the denomination.
|
|
_payTreatyMoney(from, to, amount, code = null) {
|
|
const denom = code || this.currencyOf(to).code;
|
|
const issuer = this._civByCurrencyCode().get(denom);
|
|
const vDenom = this.currencyValueOf(denom);
|
|
const vFrom = this.currencyValue(from);
|
|
const vTo = this.currencyValue(to);
|
|
const reserves = this.centralBankReserves ? this.centralBankReserves.get(from) : null;
|
|
const held = (reserves && reserves.get(denom)) || 0;
|
|
// Buying the denomination needs a reachable issuing bank.
|
|
const ownMoney = issuer === from;
|
|
const reachable = issuer === undefined || ownMoney || !this.isAtWar(from, issuer);
|
|
const sold = ownMoney ? 0 : Math.min(held, amount);
|
|
const bought = reachable ? amount - sold : 0;
|
|
const paid = sold + bought;
|
|
if (!(paid > 0)) return 0;
|
|
if (sold > 0) reserves.set(denom, held - sold);
|
|
if (bought > 0 && issuer !== undefined && !ownMoney) {
|
|
const cost = (bought * vDenom) / vFrom;
|
|
this._addReserve(issuer, this.currencyOf(from).code, cost);
|
|
this._addCentralBankDebt(issuer, denom, bought);
|
|
// Paying with our own money is what the issuing bank holds as a reserve.
|
|
this._recordCurrencyDemand(from, -cost);
|
|
this._recordCurrencyDemand(issuer, bought * vDenom);
|
|
}
|
|
const value = paid * vDenom;
|
|
this.budgets.set(from, this.getBudget(from) - value / vFrom);
|
|
this.budgets.set(to, this.getBudget(to) + value / vTo);
|
|
return paid;
|
|
},
|
|
|
|
// Declaring war tears up any alliance or peace between the two and drags the
|
|
// target's allies into the fight. Called from `_declareWar` before the war
|
|
// records are written, so an alliance never coexists with a war. The broken
|
|
// treaty's news entry is the warning the players get.
|
|
_handleWarAlliances(actor, target) {
|
|
const defenders = new Set([target]);
|
|
for (const ally of this._alliesOf(target)) defenders.add(ally);
|
|
for (const defender of defenders) {
|
|
this._breakTreaty(actor, defender, TREATY_ALLIANCE);
|
|
this._breakTreaty(actor, defender, TREATY_PEACE);
|
|
}
|
|
return Array.from(defenders).filter((civ) => civ !== actor);
|
|
},
|
|
|
|
_breakTreaty(a, b, type) {
|
|
const treaty = this.getTreaty(a, b, type);
|
|
if (!treaty) return false;
|
|
this._endTreaty(treaty, TREATY_BROKEN);
|
|
return true;
|
|
},
|
|
|
|
_breakAlliance(a, b) {
|
|
return this._breakTreaty(a, b, TREATY_ALLIANCE);
|
|
},
|
|
};
|