Unit orders now gather steel and high-tech over the days before training, exactly as buildings do, and clicking a queued order reveals what it is gathering, its reserved budget and the soldiers it will draw. The trade graph cached a producer-less node list under a stamp that already reflected the producers' stores, so every seeded mine stayed invisible to buyers and rare-good prices settled at a huge multiple of base; the storage nodes are now re-read after production. Money gifts draw the giver's central-bank reserves in the denomination first, buy the rest from the issuing bank at the market rate, convert into the receiver's currency, and cannot buy from a bank their nation is at war with. Pruned the finished design briefs (intelligence, stacks, air movement, politics performance, economy balance, training UI and FIXME) and refreshed the index and cross-references.
379 lines
16 KiB
JavaScript
379 lines
16 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;
|
|
// A unit may also be gated behind research, a single technology or several
|
|
// (an ICBM needs both the bomb and the rocket).
|
|
if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
|
|
if (
|
|
Array.isArray(proto.requiresTechnologies) &&
|
|
!proto.requiresTechnologies.every((id) => this.hasTechnology(city.civ, id))
|
|
) {
|
|
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;
|
|
// The unit's steel and high-tech are gathered over the days before its
|
|
// training clock starts, exactly as a building's are: the order reserves a
|
|
// material budget and buys against it a little at a time.
|
|
const materials = this.constructionResourceCost(proto, 0, proto.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, "training");
|
|
const entry = {
|
|
kind: "unit",
|
|
protoIndex,
|
|
capacity,
|
|
phase: "materials",
|
|
budget: reserved,
|
|
budgetCategory: "training",
|
|
prices: this._lockedUnitPrices(),
|
|
needed,
|
|
bought: { steel: 0, hightech: 0 },
|
|
elapsedHours: 0,
|
|
totalHours: (proto.cost / capacity) *
|
|
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 training
|
|
// 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;
|
|
},
|
|
|
|
// 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;
|
|
},
|
|
|
|
// Debug/testing shortcut: grants a technology at once, skipping cost,
|
|
// progress and prerequisites. Reached only through the server's testing gate
|
|
// (a local page or a `--testing` game), never from a normal client.
|
|
unlockTechnology(civ, index) {
|
|
if (civ < 0 || civ >= this.civilisations.length) return false;
|
|
const tech = this.technologies[index];
|
|
if (!tech) return false;
|
|
if (!tech.repeatable && this.isResearched(civ, index)) return false;
|
|
this._discoverTechnology(civ, index);
|
|
this._setTechnologyProgress(civ, index, 0);
|
|
if (!tech.repeatable && this.getFocusedTechnology(civ) === index) this._clearFocus(civ);
|
|
this._clearTileGdpCache();
|
|
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;
|
|
},
|
|
};
|