Files
Battle-for-Tismo/shared/game_state/warfare.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
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.
2026-09-22 14:34:14 +02:00

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