From 9690c68cedf75199b4b3b03c618e4bcc67443120 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 21 Sep 2026 23:24:29 +0200 Subject: [PATCH] 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. --- client/js/game_screen/panels.js | 6 ++---- shared/data/units.js | 14 +++++++------ shared/game_state/air.js | 8 +++---- shared/game_state/politics.js | 34 ++++++++++++++++++++---------- tests/air_test.js | 37 ++++++++++++++++++++++++++++----- tests/game_screen_test.js | 30 ++++++++++++++++++++++++++ tests/politics_test.js | 27 ++++++++++++++++++++++-- 7 files changed, 124 insertions(+), 32 deletions(-) diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 57bb80e..c53888c 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -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) { diff --git a/shared/data/units.js b/shared/data/units.js index a3d80ad..7d6cb6f 100644 --- a/shared/data/units.js +++ b/shared/data/units.js @@ -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", diff --git a/shared/game_state/air.js b/shared/game_state/air.js index 94c6513..0b23ddc 100644 --- a/shared/game_state/air.js +++ b/shared/game_state/air.js @@ -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) { diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js index eaedaf2..5cf2951 100644 --- a/shared/game_state/politics.js +++ b/shared/game_state/politics.js @@ -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(); }, diff --git a/tests/air_test.js b/tests/air_test.js index a1e46ac..5ca4d9a 100644 --- a/tests/air_test.js +++ b/tests/air_test.js @@ -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); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 40c81a7..b844d81 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -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 { diff --git a/tests/politics_test.js b/tests/politics_test.js index acf4752..031cfa9 100644 --- a/tests/politics_test.js +++ b/tests/politics_test.js @@ -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);