- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too. - Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative. - Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense. - The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it). - The shipped month report carries its label again, so the budget header and pager name the month.
373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
// War-time tile works: trenches dug by infantry, the military improvements a
|
|
// nation raises on its land (fortifications, radar towers, coastal cannons),
|
|
// and the damage, capture and upkeep that come with them.
|
|
|
|
import { key, parseKey } from "../hex.js";
|
|
import { ECONOMY } from "../data/economy.js";
|
|
import { tileImprovementById } from "../data/improvements.js";
|
|
import { TRENCH } from "../data/combat.js";
|
|
import { COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
|
import { protoUpkeep } from "../resources.js";
|
|
|
|
export const warfareMethods = {
|
|
// ----------------------------------------------------------- trenches --
|
|
|
|
// Infantry dig in on a land tile their nation controls. A trench shelters
|
|
// the garrison from melee and artillery at the cost of local production.
|
|
requestDigTrench(unitIds) {
|
|
const units = this._unitsForOrder(unitIds);
|
|
if (units.length === 0) return false;
|
|
const coords = units[0].coords;
|
|
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
|
|
return false;
|
|
}
|
|
const civ = units[0].civ;
|
|
if (!units.every((unit) => unit.civ === civ)) return false;
|
|
// Only infantry can dig, and only into their own land.
|
|
if (!units.every((unit) => {
|
|
const proto = this.unitProto(unit);
|
|
return proto && proto.infantry;
|
|
})) return false;
|
|
if (this.civAt(coords) !== civ) return false;
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
if (!tile || tile.terrainClass !== "Land") return false;
|
|
const k = key(coords.x, coords.y);
|
|
if (this.trenches.has(k)) return false;
|
|
this.trenches.add(k);
|
|
this._gdpPerCapitaCache.delete(k);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// ------------------------------------------------- tile improvements --
|
|
|
|
// Opens a construction site for a military or resource tile improvement on a
|
|
// land tile the nation controls. The state hands the site a forecast budget
|
|
// and the tile's economic node gathers materials before building; nothing is
|
|
// raised until the site completes.
|
|
requestBuildTileImprovement(civ, coords, id) {
|
|
if (!this._validCiv(civ) || !coords) return false;
|
|
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
|
|
const proto = tileImprovementById(id);
|
|
if (!proto) return false;
|
|
const k = key(coords.x, coords.y);
|
|
const tile = this.tiles[k];
|
|
if (!tile || tile.terrainClass !== "Land") return false;
|
|
if (this.civAt(coords) !== civ) return false;
|
|
if (this.tileImprovements.has(k) || this.constructionSiteAt(coords)) return false;
|
|
if (proto.coastal && !this.isCoastalCoords(coords)) return false;
|
|
// Resource producers feed the map, not a city: they stand on open land.
|
|
if (proto.resource && this.cityAt(coords)) return false;
|
|
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
|
const gdp = this.getPlayerGdp(civ);
|
|
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
|
|
const money = this._siteForecastBudget(civ, proto, proto.buildCost);
|
|
// A resource producer is a private business: it funds its construction from
|
|
// its own agent, borrowing from the central bank as it buys, so the state
|
|
// pays nothing. Military works are public and come out of the treasury.
|
|
const isPrivate = this._isProductionAgent(proto);
|
|
if (isPrivate) this._ensureBuildingAgent(k);
|
|
else this._reserveSiteBudget(civ, money);
|
|
const site = this._openConstructionSite({
|
|
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost, isPrivate,
|
|
});
|
|
this._kickoffSite(site);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Tears down a military improvement (or a trench) on a controlled tile. A
|
|
// work still under construction is cancelled instead, with no refund.
|
|
requestRemoveTileImprovement(civ, coords) {
|
|
if (!this._validCiv(civ) || !coords) return false;
|
|
const k = key(coords.x, coords.y);
|
|
if (this.civAt(coords) !== civ) return false;
|
|
if (this.constructionSites && this.constructionSites.has(k)) {
|
|
return this.requestCancelSite(civ, coords);
|
|
}
|
|
let removed = false;
|
|
if (this.trenches.delete(k)) removed = true;
|
|
if (this.tileImprovements.delete(k)) {
|
|
this.tileImprovementOwner.delete(k);
|
|
this.tileImprovementHp.delete(k);
|
|
this.improvementCash.delete(k);
|
|
this._removeBuildingAgent(k);
|
|
removed = true;
|
|
}
|
|
if (!removed) return false;
|
|
this._tileImprovementVersion += 1;
|
|
this._gdpPerCapitaCache.delete(k);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// The extra defense a tile's fortifications and trenches give a defender,
|
|
// against a melee or artillery attacker.
|
|
_tileFortificationMultiplier(coords, attackerProto) {
|
|
const k = key(coords.x, coords.y);
|
|
const artillery = this._isArtilleryAttack(attackerProto);
|
|
let multiplier = 1;
|
|
const id = this.tileImprovements.get(k);
|
|
if (id) {
|
|
const proto = tileImprovementById(id);
|
|
if (proto && proto.defense) {
|
|
multiplier *= 1 + (artillery ? proto.defense.artillery : proto.defense.melee);
|
|
}
|
|
}
|
|
if (this.trenches.has(k)) {
|
|
multiplier *= artillery
|
|
? TRENCH.artilleryDefenseMultiplier
|
|
: TRENCH.meleeDefenseMultiplier;
|
|
}
|
|
return multiplier;
|
|
},
|
|
|
|
// Whether an attacker's blow is artillery (or aircraft) fire rather than a
|
|
// melee charge, so trench and fortification bonuses can tell them apart.
|
|
_isArtilleryAttack(proto) {
|
|
if (!proto) return false;
|
|
if (proto.artillery || proto.air) return true;
|
|
return (proto.range || 1) > 1;
|
|
},
|
|
|
|
// Damage a tile improvement from ranged fire. Fortifications and coastal
|
|
// cannons are knocked down when their hit points run out; a radar tower is
|
|
// captured by infantry rather than destroyed.
|
|
_damageTileImprovement(coords, amount) {
|
|
const k = key(coords.x, coords.y);
|
|
const id = this.tileImprovements.get(k);
|
|
if (!id) return false;
|
|
const proto = tileImprovementById(id);
|
|
if (!proto || !(proto.takesRangedDamage || proto.takesDamage || proto.takesCombatDamage)) {
|
|
return false;
|
|
}
|
|
const hp = (this.tileImprovementHp.get(k) || 0) - amount;
|
|
if (hp <= 0) {
|
|
this.tileImprovements.delete(k);
|
|
this.tileImprovementOwner.delete(k);
|
|
this.tileImprovementHp.delete(k);
|
|
this.improvementCash.delete(k);
|
|
this._removeBuildingAgent(k);
|
|
this._tileImprovementVersion += 1;
|
|
this._emitChanged();
|
|
return true;
|
|
}
|
|
this.tileImprovementHp.set(k, hp);
|
|
// The damage leaves a steel debt: the work heals only once it is paid off.
|
|
this._recordRepairDebt(coords, amount);
|
|
return true;
|
|
},
|
|
|
|
// An enemy walking onto a capturable improvement takes it for their nation.
|
|
_captureTileImprovement(coords, civ) {
|
|
const k = key(coords.x, coords.y);
|
|
const id = this.tileImprovements.get(k);
|
|
if (!id) return false;
|
|
const proto = tileImprovementById(id);
|
|
if (!proto || !proto.capturable) return false;
|
|
const owner = this.tileImprovementOwner.get(k);
|
|
if (owner === civ) return false;
|
|
this.tileImprovementOwner.set(k, civ);
|
|
this._tileImprovementVersion += 1;
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// The daily steel every military and resource improvement a nation holds
|
|
// wears out. Fuel and operating energy are charged through the grid instead.
|
|
getTileImprovementUpkeep(civ) {
|
|
let total = 0;
|
|
for (const [k, id] of this.tileImprovements) {
|
|
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
|
const proto = tileImprovementById(id);
|
|
if (proto) total += protoUpkeep(proto, proto.buildCost || 0).steel;
|
|
}
|
|
return total;
|
|
},
|
|
|
|
_tileImprovementUpkeepSources(civ) {
|
|
const byType = new Map();
|
|
for (const [k, id] of this.tileImprovements) {
|
|
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
|
const proto = tileImprovementById(id);
|
|
if (!proto) continue;
|
|
const resources = protoUpkeep(proto, proto.buildCost || 0);
|
|
const amount = this._upkeepCost(resources);
|
|
if (amount <= 0) continue;
|
|
const entry = byType.get(id) || {
|
|
label: proto.name,
|
|
amount: 0,
|
|
resources: { steel: 0, energy: 0, hightech: 0 },
|
|
count: 0,
|
|
};
|
|
entry.amount += amount;
|
|
entry.resources.steel += resources.steel;
|
|
entry.resources.energy += resources.energy;
|
|
entry.resources.hightech += resources.hightech;
|
|
entry.count += 1;
|
|
byType.set(id, entry);
|
|
}
|
|
return Array.from(byType.values());
|
|
},
|
|
|
|
// ------------------------------------------------------ transports --
|
|
|
|
// Infantry board a friendly launch. They must stand on the shore beside it;
|
|
// the boarding takes EMBARK_HOURS in-game hours to complete.
|
|
requestEmbark(infantryIds, launchId) {
|
|
const launch = this.findUnit(Number(launchId));
|
|
if (!launch) return false;
|
|
const proto = this.unitProto(launch);
|
|
if (!proto || !(proto.transportCapacity > 0)) return false;
|
|
if (launch.embarking) return false;
|
|
const cargo = launch.cargo || [];
|
|
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
|
|
const unitProto = this.unitProto(unit);
|
|
return unitProto && unitProto.infantry && unit.civ === launch.civ;
|
|
});
|
|
if (infantry.length === 0) return false;
|
|
if (cargo.length + infantry.length > proto.transportCapacity) return false;
|
|
// The troops must be on the shore next to the launch.
|
|
if (!infantry.every((unit) =>
|
|
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
|
|
return false;
|
|
}
|
|
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Finishes any boarding: at zero hours the infantry leave the map and become
|
|
// the launch's cargo, and are carried until put ashore.
|
|
_tickEmbark() {
|
|
// Iterate a copy: boarding destroys the infantry, and splicing the live
|
|
// array would skip whatever unit follows them.
|
|
for (const unit of this.units.slice()) {
|
|
if (!unit.embarking) continue;
|
|
unit.embarking.hoursLeft -= 1;
|
|
if (unit.embarking.hoursLeft > 0) continue;
|
|
const proto = this.unitProto(unit);
|
|
const capacity = proto ? proto.transportCapacity || 0 : 0;
|
|
if (!unit.cargo) unit.cargo = [];
|
|
for (const id of unit.embarking.ids) {
|
|
if (unit.cargo.length >= capacity) break;
|
|
const infantry = this.findUnit(id);
|
|
if (!infantry || infantry.civ !== unit.civ) continue;
|
|
unit.cargo.push({ proto: infantry.proto, hp: infantry.hp });
|
|
this._destroyUnit(infantry);
|
|
this._visibilityDirty = true;
|
|
}
|
|
unit.embarking = null;
|
|
this._emitChanged();
|
|
}
|
|
},
|
|
|
|
// Puts a whole stack of launches' cargo ashore on an adjacent coastal tile.
|
|
requestDisembark(launchIds, coords) {
|
|
if (!coords) return false;
|
|
const launches = this._unitsForOrder(launchIds).filter((unit) => {
|
|
const proto = this.unitProto(unit);
|
|
return proto && proto.transportCapacity > 0;
|
|
});
|
|
if (launches.length === 0) return false;
|
|
const civ = launches[0].civ;
|
|
if (!launches.every((unit) => unit.civ === civ)) return false;
|
|
const k = key(coords.x, coords.y);
|
|
const tile = this.tiles[k];
|
|
if (!tile || tile.terrainClass !== "Land") return false;
|
|
if (!launches.every((unit) => this.topology.tileDistance(unit.coords, coords) === 1)) {
|
|
return false;
|
|
}
|
|
let landed = false;
|
|
for (const launch of launches) {
|
|
const cargo = launch.cargo || [];
|
|
for (const passenger of cargo) {
|
|
const proto = this.protoUnits[passenger.proto];
|
|
if (!proto) continue;
|
|
const unit = this._spawnUnit(coords, civ, proto);
|
|
if (passenger.hp !== undefined) unit.hp = Math.min(proto.maxHp, passenger.hp);
|
|
landed = true;
|
|
}
|
|
launch.cargo = [];
|
|
}
|
|
if (landed) {
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
}
|
|
return landed;
|
|
},
|
|
|
|
// -------------------------------------------------- coastal cannons --
|
|
|
|
// Coastal cannons fire at hostile warships within range every hour.
|
|
_tickCoastalCannons() {
|
|
const damaged = new Map();
|
|
for (const [k, id] of this.tileImprovements) {
|
|
const proto = tileImprovementById(id);
|
|
if (!proto || !proto.automatic) continue;
|
|
const owner = this.tileImprovementOwner.get(k);
|
|
const coords = parseKey(k);
|
|
for (const target of this._unitsWithin(coords, proto.range || 1)) {
|
|
const targetProto = this.unitProto(target);
|
|
if (!targetProto || !targetProto.naval) continue;
|
|
if (target.civ === owner) continue;
|
|
const amount = COASTAL_CANNON.damage;
|
|
damaged.set(target, (damaged.get(target) || 0) + amount);
|
|
}
|
|
}
|
|
if (damaged.size === 0) return;
|
|
const destroyed = [];
|
|
for (const [unit, amount] of damaged) {
|
|
unit.hp -= amount;
|
|
if (unit.hp <= 0) {
|
|
unit.hp = 0;
|
|
destroyed.push(unit);
|
|
}
|
|
}
|
|
this._recordMilitaryCasualties(destroyed);
|
|
for (const unit of destroyed) this._destroyUnit(unit);
|
|
if (destroyed.length > 0) this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
},
|
|
|
|
// Every unit within `radius` tiles of `coords`. Walks outward over the tile
|
|
// index rather than scanning the whole army, so a map-wide battery does not
|
|
// pay for every unit twice a day.
|
|
_unitsWithin(coords, radius) {
|
|
const result = [];
|
|
if (!(radius >= 0)) return result;
|
|
if (!this._unitsByTile) {
|
|
for (const unit of this.units) {
|
|
if (this.topology.tileDistance(coords, unit.coords) <= radius) result.push(unit);
|
|
}
|
|
return result;
|
|
}
|
|
const seen = new Set([key(coords.x, coords.y)]);
|
|
let frontier = [coords];
|
|
let depth = 0;
|
|
while (frontier.length > 0 && depth <= radius) {
|
|
for (const c of frontier) {
|
|
const bucket = this._unitsByTile.get(key(c.x, c.y));
|
|
if (bucket) {
|
|
for (const unit of bucket) {
|
|
if (this.topology.tileDistance(coords, unit.coords) <= radius) result.push(unit);
|
|
}
|
|
}
|
|
}
|
|
if (depth === radius) break;
|
|
const next = [];
|
|
for (const c of frontier) {
|
|
for (const neighbour of this._neighbours(c)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (seen.has(nk)) continue;
|
|
seen.add(nk);
|
|
next.push(neighbour);
|
|
}
|
|
}
|
|
frontier = next;
|
|
depth += 1;
|
|
}
|
|
return result;
|
|
},
|
|
};
|