Files
Battle-for-Tismo/shared/game_state/orders.js
T
adrien c2f131da5c Turned tile improvements into construction sites with their own budgets
Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.

That reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.

Bundles the in-progress chart sizing for the central bank panel.
2026-09-22 18:08:31 +02:00

319 lines
13 KiB
JavaScript

// Player-issued orders that are not movement: training, building, government,
// research and pillaging.
import { key } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { GOVERNMENTS } from "../data/governments.js";
import { PILLAGE, pillageSteps } from "../data/combat.js";
import { transportImprovement } from "../data/improvements.js";
import { buildingBuildCost } from "../rules.js";
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
export const orderMethods = {
// Adds one unit to the city's production queue. The queue is shared with
// buildings and holds at most TRAINING_QUEUE_LIMIT entries; the first entry
// is produced while the rest wait. The unit's price is paid up front from the
// budget, then it takes production capacity to finish.
requestTrain(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city) return false;
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
const proto = this.protoUnits[protoIndex];
// Aircraft and warships need the right installation in the city first.
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
// The local testing shortcut: no cost, no production time, no queue.
if (free) {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
// A free unit (the worker) needs neither money nor production time.
if (proto.cost <= 0) {
this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
if (!this._spendBudget(city.civ, proto.cost, "training")) return false;
const materials = this.constructionResourceCost(proto, 0, proto.cost);
if (!this._payConstructionResources(city.civ, city.coords, materials, "training", proto.name)) {
this._refundBudget(city.civ, proto.cost, "training");
return false;
}
const entry = {
kind: "unit",
protoIndex,
capacity,
elapsedHours: 0,
totalHours: (proto.cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
this._emitChanged();
return true;
},
// Cancels the queued entry at `index` in the city's production queue.
// Nothing is refunded: the money and time already spent on it are lost.
cancelTrain(cityId, index) {
const queue = this.training.get(cityId);
if (!queue) return false;
const at = Number(index);
if (!Number.isInteger(at) || at < 0 || at >= queue.length) return false;
queue.splice(at, 1);
if (queue.length === 0) this.training.delete(cityId);
this._emitChanged();
return true;
},
// Queues one more level of a building in the city's production queue. Like a
// unit, the level is paid for up front from the budget and only rises once
// its production time has elapsed. Repeated orders stack levels: the second
// queued level is priced off the level the first will leave behind.
requestBuild(cityId, protoIndex, free = false) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
// Placement rules: a port needs the sea, a shipyard needs a port.
if (proto.coastal && !this.isCoastalCity(city)) return false;
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
// The local testing shortcut: raise the next level at once, for free.
if (free) {
const level =
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
const level =
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
const cost = buildingBuildCost(proto, level);
// A free improvement is raised at once.
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
}
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
if (!this._spendBudget(city.civ, cost, "construction")) return false;
const materials = this.constructionResourceCost(proto, level, cost);
if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) {
this._refundBudget(city.civ, cost, "construction");
return false;
}
const entry = {
kind: "building",
protoIndex,
level,
capacity,
elapsedHours: 0,
totalHours: (cost / capacity) *
this.constructionApprovalMultiplier(city) *
this.constructionSpeedMultiplier(city.civ),
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
this._emitChanged();
return true;
},
// Lays a road or railway on a land tile the civilisation controls. A tile
// holds at most one transport improvement, so building one replaces whatever
// is already there. The state opens a construction site with a forecast
// budget; the tile's economic node gathers the materials and builds before the
// road or railway appears, and afterwards the nation pays an hourly
// maintenance for every road and railway tile it holds. The `free` shortcut
// used by testing mode raises it at once, without cost.
requestBuildImprovement(civ, coords, id, free = false) {
if (civ < 0 || civ >= this.civilisations.length) return false;
const improvement = transportImprovement(id);
if (!improvement || !coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) 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;
const current = this.railways.has(k) ? "railway" : this.roads.has(k) ? "road" : null;
if (current === improvement.id) return false;
if (this.constructionSiteAt(coords)) return false;
if (free) {
this.roads.delete(k);
this.railways.delete(k);
if (improvement.id === "railway") this.railways.add(k);
else this.roads.add(k);
this._improvementVersion += 1;
this._emitChanged();
return true;
}
const money = this._siteForecastBudget(civ, improvement, improvement.buildCost);
this._reserveSiteBudget(civ, money);
const site = this._openConstructionSite({
civ,
coords,
proto: improvement,
kind: "build",
money,
buildCost: improvement.buildCost,
});
this._kickoffSite(site);
this._emitChanged();
return true;
},
// Removes whatever transport improvement stands on a land tile the
// civilisation controls. Removal is immediate and free, so a mis-built road
// can be cleared without a new one replacing it.
requestRemoveImprovement(civ, coords) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (!coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) 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.constructionSiteAt(coords)) return this.requestCancelSite(civ, coords);
const present = this.railways.has(k) || this.roads.has(k);
if (!present) return false;
this.roads.delete(k);
this.railways.delete(k);
this.improvementCash.delete(k);
this._improvementVersion += 1;
this._emitChanged();
return true;
},
requestDemolish(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const level = this.getCityBuildingLevel(city, protoIndex);
if (level <= 0) return false;
// Downgrading refunds nothing: the money and materials already sunk into
// the level are lost.
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
requestSetGovernment(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.governments.length) return false;
if (this.getGovernment(civ) === index) return false;
const cost = GOVERNMENTS[index].baseCost +
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
if (cost > 0) this._spendBudget(civ, cost, "campaign");
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
this._touchModifiers();
this._emitChanged();
return true;
},
// Focuses a nation's research on one technology. Every point it creates from
// now on flows straight into it; nothing is charged up front and nothing is
// banked while no technology is focused.
requestFocusTechnology(civ, index) {
if (!this.canResearchTechnology(civ, index)) return false;
if (this.getFocusedTechnology(civ) === index) return true;
// Progress is kept per technology, so switching focus never discards work
// already done on either the old focus or the new one.
this.focusTechnology.set(civ, index);
this._emitChanged();
return true;
},
// Whether the units may pillage the tile they stand on: all of them are on
// one hostile tile where no battle is raging.
canPillage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
const coords = units[0].coords;
const owner = this.civAt(coords);
if (owner < 0 || owner === units[0].civ) return false;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
return !this._battleTiles().some(
(battle) => battle.coords.x === coords.x && battle.coords.y === coords.y
);
},
// Pillages the tile the units stand on. Every unit in the stack takes a turn,
// the weakest first (least HP relative to maximum): each removes 10% of the
// remaining population, cuts the tile's production per head by 30% and heals
// one hit point per 1000 people it drives off.
requestPillage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (!this.canPillage(unitIds)) return false;
const coords = units[0].coords;
const owner = this.civAt(coords);
const k = key(coords.x, coords.y);
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
let population = this.tilePopulation.get(k) || 0;
const { steps } = pillageSteps(population, ordered.length);
let penalty = this.tileProductionPenalty.get(k) !== undefined
? this.tileProductionPenalty.get(k)
: 1;
let drivenOff = 0;
for (let i = 0; i < ordered.length; i++) {
penalty *= PILLAGE.productionPenalty;
const unit = ordered[i];
unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal);
drivenOff += steps[i].removed;
}
population = steps.length > 0 ? steps[steps.length - 1].remaining : population;
this.tilePopulation.set(k, population);
this._populationVersion += 1;
this.tileProductionPenalty.set(k, penalty);
this._gdpPerCapitaCache.delete(k);
// The people driven off the tile are civilians lost to its owner in the war
// against the pillaging civilisation.
if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff);
// Pillaging the defenceless is a war crime the world remembers.
this._recordWarCrime(units[0].civ, coords, owner);
this._emitChanged();
return true;
},
// How many levels of `protoIndex` already sit in the city's production queue,
// so the next order is priced off the level the waiting ones will reach.
_pendingBuildingLevels(cityId, protoIndex) {
const queue = this.training.get(cityId);
if (!queue) return 0;
let count = 0;
for (const entry of queue) {
if (entry.kind === "building" && entry.protoIndex === protoIndex) count += 1;
}
return count;
},
_unitsForOrder(unitIds) {
const ids = Array.isArray(unitIds) ? unitIds : [unitIds];
const units = [];
for (const id of ids) {
const unit = this.findUnit(Number(id));
if (unit) units.push(unit);
}
return units;
},
};