Files
Battle-for-Tismo/shared/game_state/warfare.js
T
adrien 4600b16399 Stockpiled a month of every material and cached the trade walks
Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.

The delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).

Roadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended.
2026-09-23 15:59:28 +02:00

368 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);
this._reserveSiteBudget(civ, money);
const site = this._openConstructionSite({
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost,
});
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;
},
};