The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms. The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit. Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
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._dropTileGdpCache(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._dropTileGdpCache(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;
|
|
},
|
|
};
|