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:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user