Files
Battle-for-Tismo/shared/game_state/combat.js
T
adrien 1a9d48cdc9 Added aircraft sorties, artillery bombardment and local testing mode
- aircraft attack orders fly a sortie to the target, bomb once, return to a
  friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing
  is bounded by fuel rather than the 10-tile combat radius
- artillery and warships open a sustained hourly bombardment on a standing
  target, drawn as a red dashed line and ring while either endpoint is selected
- a game launched from localhost can enable a testing mode whose train and build
  rows offer free, instant orders; the server only honours them for a loopback
  admin in a testing game
- an open city panel now unlocks aircraft as soon as an airport finishes
2026-09-18 21:55:30 +02:00

311 lines
11 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 { 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);
},
_unitAttack(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.attack === "number" ? proto.attack : 0;
},
_unitDefense(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.defense === "number" ? proto.defense : 0;
},
// 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 doubled when it is the
// defending side of the exchange.
_effectiveDefense(unit, isDefender) {
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
return defense;
},
// 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;
enemies.sort((a, 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)
);
damage.set(defender, (damage.get(defender) || 0) + toDefender);
const toAttacker = Math.max(
1,
this._unitAttack(defender) - this._effectiveDefense(attacker, false)
);
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;
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);
}
},
_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) {
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);
}
},
// 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
// GDP per capita each day, and its recovery clock is pushed back to 30 days
// after the last day of fighting.
_tickBattleTiles() {
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.tileBattleGdpDeficit.has(k);
if (perCapita > 0) {
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) +
perCapita * BATTLE.gdpPerCapitaLossPerDay;
this.tileBattleGdpDeficit.set(k, deficit);
this._gdpPerCapitaCache.delete(k);
scarred = true;
}
if (scarred) {
this.tileBattleRecoveryStart.set(
k,
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
);
}
}
},
// Once a battlefield has been quiet for `recoveryDelayDays`, its GDP per
// capita climbs back by `recoveryPerDay` a day until the scar is gone.
_tickGdpRecovery() {
for (const [k, deficit] of Array.from(this.tileBattleGdpDeficit)) {
const start = this.tileBattleRecoveryStart.get(k);
if (start === undefined || this.totalHours < start) continue;
const remaining = deficit - BATTLE.recoveryPerDay;
if (remaining <= 0) {
this.tileBattleGdpDeficit.delete(k);
this.tileBattleRecoveryStart.delete(k);
} else {
this.tileBattleGdpDeficit.set(k, remaining);
}
this._gdpPerCapitaCache.delete(k);
}
},
};