Files
Battle-for-Tismo/shared/game_state/treaties.js
T
adrien 49613f2a1c Made training gather materials, fixed the trade graph, and banked money gifts
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.
2026-09-24 21:20:47 +02:00

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