Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree.
377 lines
14 KiB
JavaScript
377 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 { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
|
import { protoUpkeep, tileImprovementResourceCost } 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 --
|
|
|
|
// Queues a military tile improvement on a land tile the nation controls. The
|
|
// work takes the same GDP-scaled time as any other construction and completes
|
|
// on the hourly tile-works tick.
|
|
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)) 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;
|
|
if (!this._spendBudget(civ, proto.buildCost, "tilebuild")) return false;
|
|
const materials = tileImprovementResourceCost(proto, proto.buildCost);
|
|
if (!this._payConstructionResources(civ, coords, materials, "tilebuild", proto.name)) {
|
|
this._refundBudget(civ, proto.buildCost, "tilebuild");
|
|
return false;
|
|
}
|
|
const work = {
|
|
coords: { x: coords.x, y: coords.y },
|
|
id,
|
|
elapsedHours: 0,
|
|
totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
|
|
};
|
|
const queue = this.tileWorks.get(civ);
|
|
if (queue) queue.push(work);
|
|
else this.tileWorks.set(civ, [work]);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Tears down a military improvement (or a trench) on a controlled tile.
|
|
requestRemoveTileImprovement(civ, coords) {
|
|
if (!this._validCiv(civ) || !coords) return false;
|
|
const k = key(coords.x, coords.y);
|
|
if (this.civAt(coords) !== civ) return false;
|
|
let removed = false;
|
|
if (this.trenches.delete(k)) removed = true;
|
|
if (this.tileImprovements.delete(k)) {
|
|
this.tileImprovementOwner.delete(k);
|
|
this.tileImprovementHp.delete(k);
|
|
this._removeBuildingAgent(k);
|
|
removed = true;
|
|
}
|
|
if (!removed) return false;
|
|
this._tileImprovementVersion += 1;
|
|
this._gdpPerCapitaCache.delete(k);
|
|
this._emitChanged();
|
|
return true;
|
|
},
|
|
|
|
// Advances every nation's queued tile works by an hour, completing the head
|
|
// of each queue and carrying the overflow into the next.
|
|
_tickTileWorks() {
|
|
for (const [civ, queue] of this.tileWorks) {
|
|
let hoursLeft = 1;
|
|
while (hoursLeft > 0 && queue.length > 0) {
|
|
const work = queue[0];
|
|
const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
|
|
if (hoursLeft >= remaining) {
|
|
hoursLeft -= remaining;
|
|
this._completeTileWork(civ, work);
|
|
queue.shift();
|
|
} else {
|
|
work.elapsedHours += hoursLeft;
|
|
hoursLeft = 0;
|
|
}
|
|
}
|
|
if (queue.length === 0) this.tileWorks.delete(civ);
|
|
}
|
|
},
|
|
|
|
_completeTileWork(civ, work) {
|
|
const k = key(work.coords.x, work.coords.y);
|
|
// The tile may have been taken or already improved while the work ran.
|
|
if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
|
|
const proto = tileImprovementById(work.id);
|
|
if (!proto) return;
|
|
this.tileImprovements.set(k, work.id);
|
|
this.tileImprovementOwner.set(k, civ);
|
|
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
|
|
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
|
|
this._tileImprovementVersion += 1;
|
|
this._gdpPerCapitaCache.delete(k);
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
},
|
|
|
|
// 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._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 of another nation within `radius` tiles of `coords`.
|
|
_unitsWithin(coords, radius) {
|
|
const result = [];
|
|
const origin = { x: coords.x, y: coords.y };
|
|
for (const unit of this.units) {
|
|
if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit);
|
|
}
|
|
return result;
|
|
},
|
|
};
|