- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep. - Resource-producing tile works built outside cities; every nation opens with the works its economy needs. - Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
414 lines
16 KiB
JavaScript
414 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._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._gdpPerCapitaCache.delete(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._gdpPerCapitaCache.delete(k);
|
|
}
|
|
},
|
|
};
|