// 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); } }, };