Fixed air-strike range and made war-crime popularity a one-off

- An aircraft now returns to the nearest friendly airport instead of always its original home, so a plane rebased to a forward airfield can strike targets beside it; the browser mirrors the rule.
- A pillage's popularity cost is an instant shift that drifts back, not a generation-long grudge. Popularity and approval read the base opinion, while the grudge stays as the diplomatic memory.
- Doubled aircraft range: the fighter's effective reach is now 25 tiles and the bomber's 50, with missionRange raised to match.
This commit is contained in:
2026-09-21 23:24:29 +02:00
parent f3e94a3d53
commit 9690c68ced
7 changed files with 124 additions and 32 deletions
+2 -4
View File
@@ -417,15 +417,13 @@ export const panelMethods = {
},
// The friendly airport an aircraft would fly home to from `coords`, mirroring
// GameState._returnAirport: its own base while that is still a friendly
// airport, otherwise the nearest friendly airport anywhere.
// GameState._returnAirport: the nearest one to where it is heading, so a
// rebased plane is not sent back across the map to its original base.
_airReturnAirport(unit, coords) {
const cities = (this.snapshot && this.snapshot.cities) || [];
const index = BUILDINGS.findIndex((b) => b.id === "airport");
const hasAirport = (city) =>
index >= 0 && ((this._cityBuildings.get(city.id) || {})[index] || 0) > 0;
const home = cities.find((city) => city.id === unit.homeCityId);
if (home && home.civ === this.localCiv && hasAirport(home)) return home;
let best = null;
let bestDistance = Infinity;
for (const city of cities) {
+8 -6
View File
@@ -73,11 +73,11 @@ export const PROTO_UNITS = [
// Aircraft only operate near a friendly airport: `enduranceHours` is how
// long they may stay aloft and `missionRange` the furthest they may be
// ordered from home. Both are per-type so a future plane can differ. The
// fighter's 10-tile reach needs 4 hours aloft for the round trip, leaving
// an hour of reserve.
// fighter's 25-tile strike reach needs ten hours aloft for the round trip;
// `missionRange` is the looser ceiling.
air: true,
enduranceHours: 5,
missionRange: 40,
enduranceHours: 10,
missionRange: 80,
requiresBuilding: "airport",
icon: "icon_jet_fighter.svg",
},
@@ -95,9 +95,11 @@ export const PROTO_UNITS = [
// A bomber strikes from `range` tiles away and never joins the melee.
range: 0,
traversableTerrains: ["Land", "Sea", "Ice"],
// The bomber's 50-tile strike reach needs twenty hours aloft for the round
// trip; `missionRange` is the looser ceiling.
air: true,
enduranceHours: 10,
missionRange: 50,
enduranceHours: 20,
missionRange: 100,
strike: true,
requiresBuilding: "airport",
icon: "icon_bomber.svg",
+4 -4
View File
@@ -58,11 +58,11 @@ export const airMethods = {
return this.hasCityBuilding(city, "airport") ? city : null;
},
// The airport an aircraft flies home to from `from`: its own base when still
// friendly, otherwise the nearest friendly airport anywhere.
// The friendly airport an aircraft would fly home to from `from`: the nearest
// one to where it is heading, so a plane rebased to a forward airfield does
// not have to cross the map back to its original base. Its home base is only
// special until another friendly airport is closer.
_returnAirport(unit, from = unit.coords) {
const home = this.findCity(unit.homeCityId);
if (home && home.civ === unit.civ && this.hasCityBuilding(home, "airport")) return home;
let best = null;
let bestDistance = Infinity;
for (const city of this.cities) {
+23 -11
View File
@@ -252,13 +252,23 @@ export const politicsMethods = {
return value === undefined ? OPINION.baseline : value;
},
// A government's standing with one population: its base opinion plus any war
// crime the government has not been forgiven for.
getGovOpinion(observer, targetCiv) {
// A population's standing with a government, from the opinion shifts applied
// to it, with no war-crime memory. Popularity and approval read this: a war
// crime moves them when it happens, then they drift back like any other shift.
getBaseGovOpinion(observer, targetCiv) {
if (observer < 0 || targetCiv < 0) return OPINION.baseline;
const inner = this.govOpinions.get(observer);
const base = inner && inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline;
return clampOpinion(base + this._grudgeTotal(observer, targetCiv));
return inner && inner.has(targetCiv) ? inner.get(targetCiv) : OPINION.baseline;
},
// A government's standing with one population for diplomacy: its base opinion
// plus any war crime the government has not been forgiven for. Grudges are a
// diplomatic memory, so they are deliberately kept out of approval.
getGovOpinion(observer, targetCiv) {
if (observer < 0 || targetCiv < 0) return OPINION.baseline;
return clampOpinion(
this.getBaseGovOpinion(observer, targetCiv) + this._grudgeTotal(observer, targetCiv)
);
},
_grudgeTotal(observer, targetCiv) {
@@ -393,7 +403,9 @@ export const politicsMethods = {
const population = this.tilePopulation.get(k) || 0;
for (const [eth, share] of this.getTileEthnicFractions(coords)) {
const w = population * share;
const opinion = this.getGovOpinion(eth, government);
// Popularity is the base opinion, not the diplomatic memory: a war crime
// is a one-off hit, not a generation-long tax on approval.
const opinion = this.getBaseGovOpinion(eth, government);
total += w * opinion;
points += w * popularityPoints(opinion);
weight += w;
@@ -895,17 +907,17 @@ export const politicsMethods = {
},
// The world turns against an aggressor that pillages a tile, with the peoples
// directly affected feeling it twice as sharply. It is a grudge, so it fades
// on the same clock as the rest of the war-crime memory.
// directly affected feeling it twice as sharply. The popularity cost is an
// instant, one-off shift per pillage (it drifts back like any other opinion),
// not a grudge; only the long diplomatic memory is a grudge.
_applyPillagePopularity(aggressor, coords) {
if (!this._validCiv(aggressor)) return;
const until = this.totalHours + WAR_CRIME.memoryYears * HOURS_PER_YEAR;
const amount = -POPULARITY.pillage * POPULARITY.opinionPerPoint;
for (let observer = 0; observer < this.civilisations.length; observer++) {
this._addGrudge(observer, aggressor, amount, until);
this._shiftGovOpinion(observer, aggressor, amount);
}
// The tile's dominant people carry the double debuff.
this._addGrudge(this._dominantEthnicity(coords), aggressor, amount, until);
this._shiftGovOpinion(this._dominantEthnicity(coords), aggressor, amount);
this._touchPolitics();
},
+32 -5
View File
@@ -268,19 +268,46 @@ export class AirTest extends TestCase {
const [home, away] = cities;
const bomber = protoUnit(state, "bomber");
const unit = state._spawnUnit(home.coords, 0, bomber, home.id);
// Pin the fuel to exactly the one-way trip: the city can be ferried to but
// not struck and left, because the round trip needs twice as long.
// Pin the fuel to exactly the one-way trip to the far city. A target just
// beside that city still has to be left and returned from, so it is beyond
// the tanks even though the city itself can be ferried to.
unit.airHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords));
const beyond = state.topology
.neighbours(away.coords.x, away.coords.y)
.filter((n) => state._isLand(n) && !state.cityAt(n))
// The far side of the city, so reaching it and returning to the city
// costs more than the one-way ferry.
.sort((a, b) =>
state.topology.tileDistance(home.coords, b) - state.topology.tileDistance(home.coords, a)
)[0];
this.assertNotNull(beyond, "a land tile beyond the far city");
this.assertFalse(
state._strikeReady(unit, away.coords),
"the far city is beyond the bomber's strike reach"
state._strikeReady(unit, beyond),
"a target beside the far city is beyond the bomber's strike reach"
);
this.assertTrue(
state.requestGroupMove([unit.id], away.coords),
"but it can still ferry there to rebase"
"but the city itself can still be ferried to"
);
}
test_an_aircraft_returns_to_the_nearest_friendly_airport_not_its_home() {
const state = smallState();
const airport = buildingIndex(state, "airport");
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) city.buildings[airport] = 1;
const [home, away] = cities;
const jet = protoUnit(state, "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
const beside = state.topology
.neighbours(away.coords.x, away.coords.y)
.find((n) => state._isLand(n));
this.assertNotNull(beside, "a land tile beside the far city");
const airportCity = state._returnAirport(unit, beside);
this.assertNotNull(airportCity, "there is a return airport");
this.assertEqual(airportCity.id, away.id, "the nearest airport, not the home base");
}
test_air_strike_is_limited_to_the_mission_radius() {
const state = smallState();
const city = coastalCity(state, 0);
+30
View File
@@ -896,6 +896,36 @@ export class GameScreenTest extends TestCase {
}
}
async test_the_client_picks_the_nearest_return_airport() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
const cities = state.cities.filter((c) => c.civ === 0);
for (const city of cities) grantBuilding(state, city, "airport");
const [home, away] = cities;
const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
const unit = state._spawnUnit(home.coords, 0, jet, home.id);
screen.onState(state.snapshot(0));
const data = screen.snapshot.units.find((u) => u.id === unit.id);
const beside = state.topology
.neighbours(away.coords.x, away.coords.y)
.filter((n) => state._isLand(n))
.sort((a, b) =>
state.topology.tileDistance(home.coords, b) - state.topology.tileDistance(home.coords, a)
)[0];
this.assertNotNull(beside, "a land tile beyond the far city");
const airport = screen._airReturnAirport(data, beside);
this.assertNotNull(airport, "there is a return airport");
this.assertEqual(airport.id, away.id, "the nearest airport, not the home base");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_a_local_config_offers_the_free_buttons() {
const env = await setupDom();
try {
+25 -2
View File
@@ -291,13 +291,36 @@ export class PoliticsModelTest extends TestCase {
state.totalHours = WAR_CRIME.memoryYears * HOURS_PER_YEAR + 1;
state._expireGrudges();
this.assertApprox(
state.getGovOpinion(victim, 0),
before,
state._grudgeTotal(victim, 0),
0,
1e-9,
"the grudge has finally expired"
);
}
test_a_pillage_costs_popularity_once_then_drifts_back() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const target = state._territoryByCiv.get(1).find(
(coords) =>
!state.cityAt(coords) &&
state.unitsAt(coords).length === 0 &&
(state.tilePopulation.get(`${coords.x},${coords.y}`) || 0) > 0
);
this.assertNotNull(target, "an undefended enemy tile");
unit.coords = { x: target.x, y: target.y };
unit.path = [];
unit.pathIndex = 0;
const before = state.getCivPopularity(0);
this.assertTrue(state.requestPillage([unit.id]), "the pillage happened");
const after = state.getCivPopularity(0);
this.assertLess(after, before, "popularity fell at once");
// It is an instant hit, not a lingering grudge: the hourly drift recovers it.
for (let hour = 0; hour < 12; hour++) state.tickHour();
this.assertApprox(state.getCivPopularity(0), before, 0.5, "popularity drifts back");
}
test_champion_policy_buys_minority_love_at_majority_cost() {
const state = smallState();
const minorityBefore = state.getGovOpinion(1, 0);