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.
415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
// Combat: battle detection, the daily combat round, captures, daily attrition
|
|
// and the lasting scars a battlefield leaves on a tile.
|
|
|
|
import { key } from "../hex.js";
|
|
import { BATTLE } from "../data/combat.js";
|
|
import { POPULARITY } from "../data/politics.js";
|
|
import {
|
|
EFFECT_INFANTRY_ATTACK,
|
|
EFFECT_INFANTRY_DEFENSE,
|
|
EFFECT_ARTILLERY_ATTACK,
|
|
} from "../data/effects.js";
|
|
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
|
|
import { defenseMultiplier } from "../rules.js";
|
|
import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js";
|
|
|
|
export const combatMethods = {
|
|
isMilitary(unit) {
|
|
return this._isMilitary(unit);
|
|
},
|
|
|
|
_isMilitary(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return !!(proto && proto.military);
|
|
},
|
|
|
|
// Base attack, lifted by the nation's infantry-weapons or artillery-shells
|
|
// research. Only the relevant stat for the unit's type applies.
|
|
_unitAttack(unit) {
|
|
const proto = this.unitProto(unit);
|
|
let attack = proto && typeof proto.attack === "number" ? proto.attack : 0;
|
|
if (proto && (proto.infantry || proto.artillery)) {
|
|
const modifiers = this.getCivModifiers(unit.civ);
|
|
const bonus = proto.infantry
|
|
? modifiers[EFFECT_INFANTRY_ATTACK]
|
|
: modifiers[EFFECT_ARTILLERY_ATTACK];
|
|
attack *= 1 + (bonus || 0);
|
|
}
|
|
return attack;
|
|
},
|
|
|
|
// Base defense, lifted by the nation's body-armour research for infantry.
|
|
_unitDefense(unit) {
|
|
const proto = this.unitProto(unit);
|
|
let defense = proto && typeof proto.defense === "number" ? proto.defense : 0;
|
|
if (proto && proto.infantry) {
|
|
const bonus = this.getCivModifiers(unit.civ)[EFFECT_INFANTRY_DEFENSE] || 0;
|
|
defense *= 1 + bonus;
|
|
}
|
|
return defense;
|
|
},
|
|
|
|
// Fraction of extra defense granted by the tile a unit occupies (terrain
|
|
// plus any city), as a multiplier.
|
|
_defenseMultiplierAt(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
return defenseMultiplier(tile, !!this.cityAt(coords));
|
|
},
|
|
|
|
// A unit's base defense, boosted by its tile and any fortifications or
|
|
// trenches there, and doubled when it is the defending side of the exchange.
|
|
// The attacker's type decides whether trench and fort bonuses use their melee
|
|
// or artillery figures.
|
|
_effectiveDefense(unit, isDefender, attackerProto = null) {
|
|
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
|
|
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
|
|
return defense * this._tileFortificationMultiplier(unit.coords, attackerProto);
|
|
},
|
|
|
|
// Unique attacker/defender pairs. A pair is two hostile military units on the
|
|
// same tile; the lower id is treated as the attacker so both sides trade one
|
|
// blow rather than two.
|
|
_combatPairs() {
|
|
const pairs = new Map();
|
|
for (const unit of this.units) {
|
|
const defender = this._combatTarget(unit);
|
|
if (!defender) continue;
|
|
const pairKey = unit.id < defender.id
|
|
? `${unit.id}:${defender.id}`
|
|
: `${defender.id}:${unit.id}`;
|
|
if (!pairs.has(pairKey)) pairs.set(pairKey, { attacker: unit, defender });
|
|
}
|
|
return Array.from(pairs.values());
|
|
},
|
|
|
|
// The enemy military unit on the given unit's own tile it trades blows with,
|
|
// or null. With every combatant on one tile this is simply the weakest
|
|
// hostile military unit there, so a stack focuses its fire.
|
|
_combatTarget(unit) {
|
|
if (!this._isMilitary(unit) || this._isAirUnit(unit)) return null;
|
|
const enemies = this.unitsAt(unit.coords).filter(
|
|
(other) =>
|
|
other.id !== unit.id &&
|
|
other.civ !== unit.civ &&
|
|
this._isMilitary(other) &&
|
|
!this._isAirUnit(other)
|
|
);
|
|
if (enemies.length === 0) return null;
|
|
// Damage lands on infantry first, then the other fighting units, so a stack
|
|
// soaks blows with its footsoldiers before exposing support troops.
|
|
const rank = (unit) => {
|
|
const proto = this.unitProto(unit);
|
|
return proto && proto.infantry ? 0 : 1;
|
|
};
|
|
enemies.sort((a, b) => (rank(a) - rank(b)) || (a.hp - b.hp) || (a.id - b.id));
|
|
return enemies[0];
|
|
},
|
|
|
|
// Combat is fought on a single tile, so every unit sharing a tile with a
|
|
// hostile military unit is locked in place until one side is wiped out.
|
|
_engagedUnits() {
|
|
return this._battleUnitIds();
|
|
},
|
|
|
|
// Every tile holding military units of two or more hostile civilisations is
|
|
// a battlefield: the stacks share the tile and fight there.
|
|
_battleTiles() {
|
|
const byTile = new Map();
|
|
for (const unit of this.units) {
|
|
// Aircraft fly over a battlefield without taking part in the melee.
|
|
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
|
const k = key(unit.coords.x, unit.coords.y);
|
|
let entry = byTile.get(k);
|
|
if (!entry) {
|
|
entry = { coords: { x: unit.coords.x, y: unit.coords.y }, civs: new Set(), units: [] };
|
|
byTile.set(k, entry);
|
|
}
|
|
entry.civs.add(unit.civ);
|
|
entry.units.push(unit);
|
|
}
|
|
const battles = [];
|
|
for (const entry of byTile.values()) {
|
|
if (entry.civs.size >= 2) battles.push(entry);
|
|
}
|
|
return battles;
|
|
},
|
|
|
|
// The ids of every unit currently trading blows in a battle.
|
|
_battleUnitIds() {
|
|
const ids = new Set();
|
|
for (const battle of this._battleTiles()) {
|
|
for (const unit of battle.units) ids.add(unit.id);
|
|
}
|
|
return ids;
|
|
},
|
|
|
|
// The statuses shown for a unit this instant: the supply statuses stored on
|
|
// the unit plus "in battle", which is derived from the live combat state so
|
|
// it appears and clears with the fighting rather than only on the daily tick.
|
|
_effectiveStatuses(unit, inBattle) {
|
|
const statuses = unit.statuses || [];
|
|
if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses;
|
|
return [...statuses, STATUS_BATTLE];
|
|
},
|
|
|
|
// The combined upkeep multiplier of the given statuses.
|
|
_statusUpkeepMultiplier(statuses) {
|
|
let multiplier = 1;
|
|
for (const id of statuses) {
|
|
const def = statusById(id);
|
|
if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier;
|
|
}
|
|
return multiplier;
|
|
},
|
|
|
|
// Applies every active status once per day. The only effect so far is
|
|
// attrition, but the loop is generic so new daily fields slot in here.
|
|
_applyDailyStatusEffects() {
|
|
const battle = this._battleUnitIds();
|
|
const damage = new Map();
|
|
for (const unit of this.units) {
|
|
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
|
|
if (statuses.length === 0) continue;
|
|
const inCity = !!this.cityAt(unit.coords);
|
|
let fraction = 0;
|
|
for (const id of statuses) {
|
|
const def = statusById(id);
|
|
if (def) fraction += this._statusHpLossPerDay(def, inCity);
|
|
}
|
|
if (fraction > 0) damage.set(unit, unit.maxHp * fraction);
|
|
}
|
|
if (damage.size === 0) return;
|
|
const destroyed = [];
|
|
for (const [unit, amount] of damage) {
|
|
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();
|
|
},
|
|
|
|
// One combat round: at most one per day, resolved in `tickHour`. Every pair
|
|
// of hostile military units sharing a tile trades blows and damage lands
|
|
// simultaneously, so a mutual kill destroys both units.
|
|
_resolveCombatRound() {
|
|
const damage = new Map();
|
|
for (const { attacker, defender } of this._combatPairs()) {
|
|
const toDefender = Math.max(
|
|
1,
|
|
this._unitAttack(attacker) -
|
|
this._effectiveDefense(defender, true, this.unitProto(attacker))
|
|
);
|
|
damage.set(defender, (damage.get(defender) || 0) + toDefender);
|
|
const toAttacker = Math.max(
|
|
1,
|
|
this._unitAttack(defender) -
|
|
this._effectiveDefense(attacker, false, this.unitProto(defender))
|
|
);
|
|
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
|
|
}
|
|
if (damage.size > 0) {
|
|
const destroyed = [];
|
|
for (const [unit, amount] of damage) {
|
|
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();
|
|
}
|
|
}
|
|
// A battle can free a besieged city the same tick it ends, so claim it now.
|
|
this._resolveCaptures();
|
|
},
|
|
|
|
// A military unit standing on an enemy city with no defender left captures it.
|
|
_resolveCaptures() {
|
|
const claimed = new Set();
|
|
for (const unit of this.units) {
|
|
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
|
|
const coords = unit.coords;
|
|
// Support units and unaccompanied artillery are taken from underfoot.
|
|
this._captureSupportUnits(unit);
|
|
this._captureTileImprovement(coords, unit.civ);
|
|
if (this._hasHostileOn(coords, unit.civ)) continue;
|
|
const city = this._cityByCoords.get(key(coords.x, coords.y));
|
|
if (!city || city.civ === unit.civ || claimed.has(city.id)) continue;
|
|
claimed.add(city.id);
|
|
this._captureCity(city, unit.civ);
|
|
}
|
|
},
|
|
|
|
// An enemy mobile radar, launch or unaccompanied artillery on the captor's
|
|
// tile changes hands, or is scuttled when the captor is a warship. Artillery
|
|
// escapes capture while any of its own infantry stands beside it.
|
|
_captureSupportUnits(captor) {
|
|
if (!captor || !this._isMilitary(captor) || this._isAirUnit(captor)) return false;
|
|
const captorProto = this.unitProto(captor);
|
|
const enemies = this.unitsAt(captor.coords).filter((unit) => unit.civ !== captor.civ);
|
|
if (enemies.length === 0) return false;
|
|
const enemyInfantry = enemies.some((unit) => {
|
|
const proto = this.unitProto(unit);
|
|
return proto && proto.infantry;
|
|
});
|
|
let changed = false;
|
|
for (const enemy of enemies) {
|
|
const proto = this.unitProto(enemy);
|
|
if (!proto) continue;
|
|
if (proto.capturable) {
|
|
// A warship over a launch stack simply sinks it, unprotected as it is.
|
|
if (proto.naval && captorProto && captorProto.naval) {
|
|
this._destroyUnit(enemy);
|
|
} else {
|
|
enemy.civ = captor.civ;
|
|
enemy.strikeTarget = null;
|
|
}
|
|
changed = true;
|
|
} else if (proto.artillery && captorProto && captorProto.infantry && !enemyInfantry) {
|
|
enemy.civ = captor.civ;
|
|
enemy.strikeTarget = null;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
}
|
|
return changed;
|
|
},
|
|
|
|
// Enemy aircraft caught on a captured city's airfield change allegiance.
|
|
_captureCityAircraft(city, newCiv) {
|
|
for (const unit of this.unitsAt(city.coords)) {
|
|
const proto = this.unitProto(unit);
|
|
if (!proto || !proto.air || unit.civ === newCiv) continue;
|
|
unit.civ = newCiv;
|
|
unit.homeCityId = city.id;
|
|
unit.strikeTarget = null;
|
|
}
|
|
this._visibilityDirty = true;
|
|
},
|
|
|
|
_destroyUnit(unit) {
|
|
const index = this.units.indexOf(unit);
|
|
if (index >= 0) this.units.splice(index, 1);
|
|
this._unindexUnit(unit);
|
|
this._unitById.delete(unit.id);
|
|
},
|
|
|
|
// Called when a unit finishes stepping onto a tile. A military unit entering
|
|
// an enemy city captures it, but only once every defender has fallen.
|
|
_onUnitArrived(unit) {
|
|
// Walking onto a defenceless support unit or unaccompanied artillery takes
|
|
// it, even while other enemies hold the tile.
|
|
this._captureSupportUnits(unit);
|
|
// A military unit also captures a defenceless enemy improvement underfoot,
|
|
// such as a radar tower, whether or not a fight is still going on.
|
|
if (this._isMilitary(unit) && !this._isAirUnit(unit)) {
|
|
this._captureTileImprovement(unit.coords, unit.civ);
|
|
}
|
|
if (this._hasHostileOn(unit.coords, unit.civ)) return;
|
|
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
|
|
if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
|
|
this._captureCity(city, unit.civ);
|
|
}
|
|
},
|
|
|
|
// Tallies the military dead of a battle before the units are removed, while
|
|
// every participant is still on the tile so the enemies can be identified.
|
|
_recordMilitaryCasualties(destroyed) {
|
|
for (const unit of destroyed) {
|
|
if (!this._isMilitary(unit)) continue;
|
|
const enemies = new Set();
|
|
for (const other of this.unitsAt(unit.coords)) {
|
|
if (other.id !== unit.id && other.civ !== unit.civ) enemies.add(other.civ);
|
|
}
|
|
if (enemies.size === 0) {
|
|
for (const neighbour of this._neighbours(unit.coords)) {
|
|
for (const other of this.unitsAt(neighbour)) {
|
|
if (other.civ !== unit.civ) enemies.add(other.civ);
|
|
}
|
|
}
|
|
}
|
|
for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1);
|
|
// Losing a unit costs the owner standing at home; every enemy that was
|
|
// there to see it gains.
|
|
this._shiftOwnPopularity(unit.civ, -POPULARITY.unitLoss);
|
|
for (const enemy of enemies) this._shiftOwnPopularity(enemy, POPULARITY.unitKill);
|
|
}
|
|
},
|
|
|
|
// The civilians killed on a battlefield belong to the tile's owner; when
|
|
// several hostile civilisations share the tile the toll is split between them.
|
|
_recordBattleCivilianCasualties(battle, amount) {
|
|
const owner = this.civAt(battle.coords);
|
|
if (owner < 0) return;
|
|
const opponents = Array.from(battle.civs).filter((civ) => civ !== owner);
|
|
if (opponents.length === 0) return;
|
|
const share = amount / opponents.length;
|
|
for (const enemy of opponents) this._recordCasualties(owner, enemy, "civilians", share);
|
|
},
|
|
|
|
// Every tile where a battle is raging loses a share of its population and its
|
|
// production per head each day, and its recovery clock is pushed back to 30
|
|
// days after the last day of fighting.
|
|
_tickBattleTiles() {
|
|
this._populationVersion += 1;
|
|
for (const battle of this._battleTiles()) {
|
|
const k = key(battle.coords.x, battle.coords.y);
|
|
const population = this.tilePopulation.get(k);
|
|
if (population !== undefined) {
|
|
const removed = population * BATTLE.populationLossPerDay;
|
|
this.tilePopulation.set(k, population - removed);
|
|
this._recordBattleCivilianCasualties(battle, removed);
|
|
}
|
|
const perCapita = this.getTileGdpPerCapita(battle.coords);
|
|
let scarred = this.tileBattleProductionDeficit.has(k);
|
|
if (perCapita > 0) {
|
|
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) +
|
|
perCapita * BATTLE.productionLossPerDay;
|
|
this.tileBattleProductionDeficit.set(k, deficit);
|
|
this._dropTileGdpCache(k);
|
|
scarred = true;
|
|
}
|
|
if (scarred) {
|
|
this.tileBattleRecoveryStart.set(
|
|
k,
|
|
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
|
|
);
|
|
}
|
|
// Fortifications holding a battlefield are battered by the fighting.
|
|
this._damageTileImprovement(battle.coords, BATTLE.structureDamagePerDay);
|
|
}
|
|
},
|
|
|
|
// Once a battlefield has been quiet for `recoveryDelayDays`, its production
|
|
// per head climbs back by `recoveryPerDay` a day until the scar is gone.
|
|
_tickProductionRecovery() {
|
|
for (const [k, deficit] of Array.from(this.tileBattleProductionDeficit)) {
|
|
const start = this.tileBattleRecoveryStart.get(k);
|
|
if (start === undefined || this.totalHours < start) continue;
|
|
// A tile still carrying battle damage it has not paid to repair does not
|
|
// recover: the materials come first.
|
|
if ((this.tileRepairDebt.get(k) || 0) > 0.0001) continue;
|
|
const remaining = deficit - BATTLE.recoveryPerDay;
|
|
if (remaining <= 0) {
|
|
this.tileBattleProductionDeficit.delete(k);
|
|
this.tileBattleRecoveryStart.delete(k);
|
|
} else {
|
|
this.tileBattleProductionDeficit.set(k, remaining);
|
|
}
|
|
this._dropTileGdpCache(k);
|
|
}
|
|
},
|
|
};
|