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.
337 lines
14 KiB
JavaScript
337 lines
14 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, HOURS_PER_DAY } 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. The
|
|
// level's money cost is paid up front, but its steel and high-tech are
|
|
// gathered over the days that follow -- exactly as a tile improvement does --
|
|
// and only then does the construction time begin. 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._clearTileGdpCache();
|
|
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._clearTileGdpCache();
|
|
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;
|
|
// The level's material bill is not bought up front any more: like a tile
|
|
// improvement, the order reserves a budget and gathers the steel and
|
|
// high-tech over the days before construction can begin.
|
|
const materials = this.constructionResourceCost(proto, level, cost);
|
|
const needed = {
|
|
steel: Math.max(0, materials.steel || 0),
|
|
hightech: Math.max(0, materials.hightech || 0),
|
|
};
|
|
const reserved = needed.steel * this.getResourcePrice("steel") +
|
|
needed.hightech * this.getResourcePrice("hightech");
|
|
this._reserveSiteBudget(city.civ, reserved, "construction");
|
|
const entry = {
|
|
kind: "building",
|
|
protoIndex,
|
|
level,
|
|
capacity,
|
|
phase: "materials",
|
|
budget: reserved,
|
|
prices: this._lockedUnitPrices(),
|
|
needed,
|
|
bought: { steel: 0, hightech: 0 },
|
|
elapsedHours: 0,
|
|
totalHours: ECONOMY.buildingHours(this.getPlayerGdp(city.civ), cost) *
|
|
this.constructionApprovalMultiplier(city) *
|
|
this.constructionSpeedMultiplier(city.civ),
|
|
lastGatherDay: -1,
|
|
};
|
|
if (queue) queue.push(entry);
|
|
else this.training.set(cityId, [entry]);
|
|
// Make the first purchase at once, so a well-supplied city starts building
|
|
// right away instead of waiting for the first day boundary.
|
|
entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY);
|
|
this._gatherBuildEntry(city, 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._clearTileGdpCache();
|
|
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._clearTileGdpCache();
|
|
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._dropTileGdpCache(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;
|
|
},
|
|
};
|