diff --git a/client/assets/icon_airport.svg b/client/assets/icon_airport.svg
new file mode 100644
index 0000000..0cf8b9e
--- /dev/null
+++ b/client/assets/icon_airport.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_bank.svg b/client/assets/icon_bank.svg
new file mode 100644
index 0000000..64cfd8d
--- /dev/null
+++ b/client/assets/icon_bank.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_battleship.svg b/client/assets/icon_battleship.svg
new file mode 100644
index 0000000..0bde57a
--- /dev/null
+++ b/client/assets/icon_battleship.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_bomber.svg b/client/assets/icon_bomber.svg
new file mode 100644
index 0000000..5218b4f
--- /dev/null
+++ b/client/assets/icon_bomber.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_caesar.svg b/client/assets/icon_caesar.svg
new file mode 100644
index 0000000..d4a5d4d
--- /dev/null
+++ b/client/assets/icon_caesar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_cinema_studio.svg b/client/assets/icon_cinema_studio.svg
new file mode 100644
index 0000000..b521468
--- /dev/null
+++ b/client/assets/icon_cinema_studio.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_delivery_drone.svg b/client/assets/icon_delivery_drone.svg
new file mode 100644
index 0000000..be5962e
--- /dev/null
+++ b/client/assets/icon_delivery_drone.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_habitat_dome.svg b/client/assets/icon_habitat_dome.svg
new file mode 100644
index 0000000..17cb86d
--- /dev/null
+++ b/client/assets/icon_habitat_dome.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_jet_fighter.svg b/client/assets/icon_jet_fighter.svg
new file mode 100644
index 0000000..65d121b
--- /dev/null
+++ b/client/assets/icon_jet_fighter.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_market.svg b/client/assets/icon_market.svg
new file mode 100644
index 0000000..4c34415
--- /dev/null
+++ b/client/assets/icon_market.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_museum.svg b/client/assets/icon_museum.svg
new file mode 100644
index 0000000..e1aeeda
--- /dev/null
+++ b/client/assets/icon_museum.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_port.svg b/client/assets/icon_port.svg
new file mode 100644
index 0000000..a13b191
--- /dev/null
+++ b/client/assets/icon_port.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_research_lab.svg b/client/assets/icon_research_lab.svg
new file mode 100644
index 0000000..0939fb1
--- /dev/null
+++ b/client/assets/icon_research_lab.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_shipyard.svg b/client/assets/icon_shipyard.svg
new file mode 100644
index 0000000..cae27b3
--- /dev/null
+++ b/client/assets/icon_shipyard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_throne_king.svg b/client/assets/icon_throne_king.svg
new file mode 100644
index 0000000..8a8bb39
--- /dev/null
+++ b/client/assets/icon_throne_king.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_university.svg b/client/assets/icon_university.svg
new file mode 100644
index 0000000..5e5bc3b
--- /dev/null
+++ b/client/assets/icon_university.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/css/style.css b/client/css/style.css
index ef4225c..99d98f4 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -632,6 +632,13 @@ input:focus, select:focus { border-color: var(--accent); }
border: 1px solid rgba(255, 255, 255, 0.05);
}
.row-card .info { flex: 1; }
+.row-card .building-icon {
+ width: 46px;
+ height: 46px;
+ flex: 0 0 auto;
+ align-self: center;
+ opacity: 0.9;
+}
.row-card .name { font-weight: 600; }
.row-card .desc { color: var(--muted); font-size: 12px; margin: 3px 0; }
.row-card .effects { font-size: 12px; color: #bcd4ff; }
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 1701ecc..94deeb9 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -204,6 +204,24 @@ export class GameScreen {
// foreign territory at peace. `type` is "move", "attack" or "schedule".
async _orderMove(units, coords, type = "move") {
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
+ // Aircraft are refused an order outside their mission radius before it is
+ // ever sent, so the browser and the server never disagree on a route.
+ const outOfRange = units.some((unit) => {
+ const proto = this.protoUnits[unit.proto];
+ if (!proto || !proto.air) return false;
+ const route = routes.get(unit.id);
+ if (!route) return false;
+ // A bombing run is limited by the strike range; a ferry flight by the
+ // aircraft's mission radius.
+ const limit = type === "attack" && proto.strike
+ ? (proto.range || 1)
+ : (proto.missionRange || Infinity);
+ return route.length - 1 > limit;
+ });
+ if (outOfRange) {
+ this.confirmModal.show("Out of range", "That target lies beyond this aircraft's reach.", null);
+ return;
+ }
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
@@ -227,6 +245,13 @@ export class GameScreen {
order.append = true;
}
this.sendOrder(order);
+ // A bombing run is resolved instantly on the server without moving, so
+ // predicting a march there would only make the icon drift.
+ const strike = type === "attack" && units.every((unit) => {
+ const proto = this.protoUnits[unit.proto];
+ return proto && proto.strike;
+ });
+ if (strike) return Promise.resolve();
const pending = [];
for (const unit of units) {
// An append joins the queue behind the leg already under way, so only an
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index a55eacb..ff3ac0d 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -526,16 +526,24 @@ export const panelMethods = {
_cityView(city) {
const stats = this.snapshot.viewerStats || {};
+ const levels = this._cityBuildings.get(city.id) || {};
+ const hasBuilding = (id) => {
+ const index = BUILDINGS.findIndex((b) => b.id === id);
+ return index >= 0 && (levels[index] || 0) > 0;
+ };
+ // Aircraft and warships only appear once the city has the installation
+ // that trains them, so the order can never be sent and rejected.
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
- }).filter(Boolean);
+ }).filter((proto) => proto && (!proto.requiresBuilding || hasBuilding(proto.requiresBuilding)));
return {
budget: stats.budget || 0,
gdp: stats.gdp || 0,
+ coastal: !!city.coastal,
trainable,
buildings: BUILDINGS,
- levels: this._cityBuildings.get(city.id) || {},
+ levels,
queue: this._trainingQueue(city.id),
};
},
diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js
index 7c32d75..fc7981c 100644
--- a/client/js/map_view/motion.js
+++ b/client/js/map_view/motion.js
@@ -386,12 +386,13 @@ export const motionMethods = {
// route never cuts through a third party and provokes a war.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) {
const k = key(coords.x, coords.y);
- if (!this.explored.has(k)) return false;
+ const air = !!(proto && proto.air);
const tile = this.tiles[k];
if (!tile) return false;
+ if (!air && !this.explored.has(k)) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
- if (!isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
+ if (!air && !isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
const hostile = occupants.filter((data) => data.civ !== civ);
if (hostile.length > 0) {
@@ -402,6 +403,8 @@ export const motionMethods = {
}
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== civ) {
+ // Mirrors GameState._canUnitEnter: aircraft never occupy an enemy city.
+ if (air) return false;
if (!isGoal) return false;
if (proto.military !== true) return false;
}
@@ -409,13 +412,16 @@ export const motionMethods = {
},
_stepHours(proto, civ, coords, speed = null) {
+ const effective = Math.max(speed || proto.speed, 0.0001);
+ // Mirrors GameState._tileTravelHours: aircraft cross every tile at a flat
+ // rate, while ground units are shaped by roads and terrain.
+ if (proto.air) return 1 / effective;
const k = key(coords.x, coords.y);
- let effective = Math.max(speed || proto.speed, 0.0001);
- if (this.territory.get(k) === civ) effective *= CONTROLLED_SPEED_MULTIPLIER;
- // Mirrors GameState._tileTravelHours: a road levels the terrain.
+ let pace = effective;
+ if (this.territory.get(k) === civ) pace *= CONTROLLED_SPEED_MULTIPLIER;
const multiplier = this.roads.has(k)
? ROADS.movementCostMultiplier
: this.terrainStats.movementCostMultiplier(coords);
- return multiplier / effective;
+ return multiplier / pace;
},
};
diff --git a/client/js/modals/city.js b/client/js/modals/city.js
index 8f8405a..b43c528 100644
--- a/client/js/modals/city.js
+++ b/client/js/modals/city.js
@@ -31,6 +31,8 @@ export class CityModal {
this.cityId = 0;
this.gdp = 0;
+ this.coastal = false;
+ this._levels = {};
this.protoNames = new Map();
this.buildingNames = new Map(BUILDINGS.map((b, index) => [index, b.name]));
this.buildingRows = [];
@@ -62,6 +64,7 @@ export class CityModal {
show(city, view = {}) {
this.cityId = city.id;
this.gdp = view.gdp || 0;
+ this.coastal = !!view.coastal;
this.$title.text(`${city.name} — City`);
this._renderTrainList(this.gdp, view.trainable || []);
this.renderQueue(view.queue || []);
@@ -160,6 +163,9 @@ export class CityModal {
_makeBuildingRow(proto, index) {
const $card = $("
");
+ const $icon = proto.icon
+ ? $("
").attr({ src: `assets/${proto.icon}`, alt: proto.name })
+ : null;
const $info = $("");
const $name = $("");
const $desc = $("").text(proto.description);
@@ -172,12 +178,28 @@ export class CityModal {
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
+ if ($icon) $card.append($icon);
$card.append($info, $actions);
this.$buildingsList.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
+ // Why a building cannot be raised here, or null when it can. Ports need the
+ // sea and shipyards need a port, mirrored from GameState.requestBuild.
+ _buildRequirement(proto) {
+ if (proto.coastal && !this.coastal) return "Needs a coastal city";
+ if (proto.requiresBuilding) {
+ const index = BUILDINGS.findIndex((b) => b.id === proto.requiresBuilding);
+ if (index < 0 || (this._levels[index] || 0) <= 0) {
+ const name = index >= 0 ? BUILDINGS[index].name : proto.requiresBuilding;
+ return `Needs a ${name}`;
+ }
+ }
+ return null;
+ }
+
refreshBuildings(budget, levels, gdp = 0, queued = {}) {
+ this._levels = levels || {};
this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.buildingRows) {
const level = levels[row.index] || 0;
@@ -193,8 +215,12 @@ export class CityModal {
const cost = buildingBuildCost(row.proto, nextLevel);
const duration = ECONOMY.trainingHours(gdp, cost);
parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`);
+ const requirement = this._buildRequirement(row.proto);
+ if (requirement) parts.push(requirement);
row.$cost.text(parts.join(" · "));
- row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
+ row.$upgrade
+ .text(`Upgrade €${groupDigits(Math.round(cost))}`)
+ .prop("disabled", !!requirement || budget < cost);
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`)
diff --git a/server/game_server.js b/server/game_server.js
index 8fb0bad..ac4bd9a 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -128,7 +128,11 @@ export class GameServer extends EventEmitter {
_handleAttack(civ, order) {
const units = this._ownUnitIds(civ, order);
if (units.length === 0) return false;
- return this.state.requestAttack(units, coordsOf(order.coords));
+ const coords = coordsOf(order.coords);
+ // A group of bombers bombs from afar; anything else charges in.
+ const allStrike = units.every((id) => this.state._isStrikeUnit(this.state.findUnit(id)));
+ if (allStrike) return this.state.requestStrike(units, coords);
+ return this.state.requestAttack(units, coords);
}
_handleSchedule(civ, order) {
diff --git a/shared/data/buildings.js b/shared/data/buildings.js
index b754ce3..8857ae7 100644
--- a/shared/data/buildings.js
+++ b/shared/data/buildings.js
@@ -13,6 +13,7 @@ export const BUILDINGS = [
costAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
+ icon: "icon_market.svg",
},
{
id: "bank",
@@ -22,6 +23,7 @@ export const BUILDINGS = [
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
+ icon: "icon_bank.svg",
},
{
id: "research_lab",
@@ -31,6 +33,7 @@ export const BUILDINGS = [
costAlpha: 1.65,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
+ icon: "icon_research_lab.svg",
},
{
id: "university",
@@ -40,6 +43,7 @@ export const BUILDINGS = [
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
+ icon: "icon_university.svg",
},
{
id: "museum",
@@ -49,6 +53,7 @@ export const BUILDINGS = [
costAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
+ icon: "icon_museum.svg",
},
{
id: "cinema_studio",
@@ -58,6 +63,40 @@ export const BUILDINGS = [
costAlpha: 1.75,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
+ icon: "icon_cinema_studio.svg",
+ },
+ {
+ id: "port",
+ name: "Port",
+ description: "A deep-water harbour. Only a coastal city can raise one. Raises the tax collected from the city's region and is required before a shipyard.",
+ baseCost: 20_000_000,
+ costAlpha: 1.7,
+ refundFraction: 0.5,
+ effects: [effect(EFFECT_BUDGET_INCOME, 0.08, 0.7)],
+ icon: "icon_port.svg",
+ coastal: true,
+ },
+ {
+ id: "airport",
+ name: "Airport",
+ description: "An airfield with a control tower. Lets the city train aircraft and serves as a home airbase for them to land and refuel.",
+ baseCost: 250_000_000,
+ costAlpha: 1.7,
+ refundFraction: 0.5,
+ effects: [effect(EFFECT_GDP, 0.05, 0.8)],
+ icon: "icon_airport.svg",
+ },
+ {
+ id: "shipyard",
+ name: "Shipyard",
+ description: "Naval yards that build warships. Only a coastal city can raise one, and it must already have a port.",
+ baseCost: 150_000_000,
+ costAlpha: 1.65,
+ refundFraction: 0.5,
+ effects: [effect(EFFECT_BUDGET_INCOME, 0.06, 0.7)],
+ icon: "icon_shipyard.svg",
+ coastal: true,
+ requiresBuilding: "port",
},
];
diff --git a/shared/data/units.js b/shared/data/units.js
index f7bd290..340369c 100644
--- a/shared/data/units.js
+++ b/shared/data/units.js
@@ -52,10 +52,76 @@ export const PROTO_UNITS = [
traversableTerrains: ["Land"],
icon: "icon_artillery.svg",
},
+ {
+ id: "jet_fighter",
+ name: "Jet fighter",
+ maxHp: 300,
+ cost: 400_000_000,
+ upkeep: 20_000,
+ moveable: true,
+ speed: 5,
+ military: true,
+ attack: 300,
+ defense: 20,
+ range: 1,
+ traversableTerrains: ["Land", "Sea", "Ice"],
+ // 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.
+ air: true,
+ enduranceHours: 2,
+ missionRange: 5,
+ requiresBuilding: "airport",
+ icon: "icon_jet_fighter.svg",
+ },
+ {
+ id: "bomber",
+ name: "Bomber",
+ maxHp: 350,
+ cost: 500_000_000,
+ upkeep: 25_000,
+ moveable: true,
+ speed: 5,
+ military: true,
+ attack: 450,
+ defense: 10,
+ // A bomber strikes from `range` tiles away and never joins the melee.
+ range: 2,
+ traversableTerrains: ["Land", "Sea", "Ice"],
+ air: true,
+ enduranceHours: 2,
+ missionRange: 5,
+ strike: true,
+ strikeHours: 1,
+ requiresBuilding: "airport",
+ icon: "icon_bomber.svg",
+ },
+ {
+ id: "battleship",
+ name: "Battleship",
+ maxHp: 800,
+ cost: 600_000_000,
+ upkeep: 30_000,
+ moveable: true,
+ speed: 0.5,
+ military: true,
+ attack: 400,
+ defense: 120,
+ range: 2,
+ traversableTerrains: ["Sea"],
+ requiresBuilding: "shipyard",
+ icon: "icon_battleship.svg",
+ },
];
// Only units in this list can be trained from the city screen.
-export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
+export const TRAINABLE_UNIT_IDS = [
+ "modern_infantry",
+ "artillery",
+ "jet_fighter",
+ "bomber",
+ "battleship",
+];
export function protoUnitById(id) {
return PROTO_UNITS.find((u) => u.id === id) || null;
diff --git a/shared/game_state.js b/shared/game_state.js
index d437b72..6d7fe6d 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -39,6 +39,7 @@ import { territoryMethods } from "./game_state/territory.js";
import { economyMethods } from "./game_state/economy.js";
import { movementMethods } from "./game_state/movement.js";
import { combatMethods } from "./game_state/combat.js";
+import { airMethods } from "./game_state/air.js";
import { statusMethods } from "./game_state/statuses.js";
import { diplomacyMethods } from "./game_state/diplomacy.js";
import { orderMethods } from "./game_state/orders.js";
@@ -234,6 +235,7 @@ export class GameState {
tickHour() {
this.totalHours += 1;
this._tickTraining();
+ this._tickAirUnits();
this._tickGarrisonHealing();
if (this._visibilityDirty) {
this._refreshVisibility();
@@ -266,6 +268,7 @@ Object.assign(
economyMethods,
movementMethods,
combatMethods,
+ airMethods,
statusMethods,
diplomacyMethods,
orderMethods,
diff --git a/shared/game_state/air.js b/shared/game_state/air.js
new file mode 100644
index 0000000..5eea7e8
--- /dev/null
+++ b/shared/game_state/air.js
@@ -0,0 +1,236 @@
+// Air power and the placement rules shared by coastal and gated buildings:
+// where a port may be raised, which cities may train aircraft or warships, and
+// the endurance model that keeps aircraft tied to a friendly airport.
+
+import { key } from "../hex.js";
+
+export const airMethods = {
+ // ----------------------------------------------------- building rules --
+
+ buildingIndexById(id) {
+ return this.protoBuildings.findIndex((building) => building.id === id);
+ },
+
+ // Whether the city has raised at least one level of the building with `id`.
+ hasCityBuilding(city, id) {
+ const index = this.buildingIndexById(id);
+ return index >= 0 && this.getCityBuildingLevel(city, index) > 0;
+ },
+
+ // A tile is coastal when at least one of its neighbours is sea.
+ isCoastalCoords(coords) {
+ for (const neighbour of this._neighbours(coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (tile && tile.terrainClass === "Sea") return true;
+ }
+ return false;
+ },
+
+ isCoastalCity(city) {
+ return this.isCoastalCoords(city.coords);
+ },
+
+ // -------------------------------------------------------------- air --
+
+ _isAirProto(proto) {
+ return !!(proto && proto.air);
+ },
+
+ _isAirUnit(unit) {
+ return this._isAirProto(this.unitProto(unit));
+ },
+
+ _isStrikeUnit(unit) {
+ const proto = this.unitProto(unit);
+ return !!(proto && proto.strike);
+ },
+
+ // The friendly airport city on `coords`, or null. An aircraft parked here
+ // refuels and stops burning endurance.
+ _friendlyAirportAt(coords, civ) {
+ const city = this.cityAt(coords);
+ if (!city || city.civ !== civ) return null;
+ return this.hasCityBuilding(city, "airport") ? city : null;
+ },
+
+ // The airport an aircraft flies home to: its own base when still friendly,
+ // otherwise the nearest friendly airport anywhere.
+ _nearestFriendlyAirport(unit) {
+ 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) {
+ if (city.civ !== unit.civ || !this.hasCityBuilding(city, "airport")) continue;
+ const distance = this.topology.tileDistance(unit.coords, city.coords);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = city;
+ }
+ }
+ return best;
+ },
+
+ _pathHours(unit, path) {
+ let total = 0;
+ for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]);
+ return total;
+ },
+
+ // Routes an aircraft back to a friendly airport, unless the trip would run
+ // past its remaining endurance, in which case it stays put and will crash.
+ _orderAirReturn(unit) {
+ const airport = this._nearestFriendlyAirport(unit);
+ if (!airport) return false;
+ const path = this.findPath(unit, unit.coords, airport.coords);
+ if (path.length < 2) return false;
+ const proto = this.unitProto(unit);
+ const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
+ if (this._pathHours(unit, path) > fuel + 1e-9) return false;
+ unit.path = path;
+ unit.pathIndex = 0;
+ unit.progressHours = 0;
+ unit.moveSpeed = null;
+ unit.waypoints = [];
+ unit.autoReturn = true;
+ return true;
+ },
+
+ // Runs once per in-game hour: burns one hour of endurance for every airborne
+ // aircraft, lands and refuels the ones sitting on a friendly airport, crashes
+ // the ones that ran dry, and sends the idle ones home. Aircraft whose fuel is
+ // about to run out turn for home even mid-route, so only losing every airport
+ // can strand a plane.
+ _tickAirUnits() {
+ const crashed = [];
+ for (const unit of this.units) {
+ const proto = this.unitProto(unit);
+ if (!this._isAirProto(proto)) continue;
+ if (this._friendlyAirportAt(unit.coords, unit.civ)) {
+ unit.airHours = proto.enduranceHours;
+ unit.airborne = false;
+ unit.autoReturn = false;
+ continue;
+ }
+ if (unit.airHours === undefined || unit.airHours === null) {
+ unit.airHours = proto.enduranceHours;
+ }
+ unit.airborne = true;
+ unit.airHours -= 1;
+ if (unit.airHours <= 0) crashed.push(unit);
+ }
+ for (const unit of crashed) {
+ this._recordMilitaryCasualties([unit]);
+ this._destroyUnit(unit);
+ }
+ if (crashed.length > 0) this._visibilityDirty = true;
+
+ for (const unit of this.units) {
+ const proto = this.unitProto(unit);
+ if (!this._isAirProto(proto) || !unit.airborne || unit.autoReturn) continue;
+ if (this._hasRoute(unit)) {
+ // Turn for home before the tanks run dry.
+ const airport = this._nearestFriendlyAirport(unit);
+ if (!airport) continue;
+ const home = this.findPath(unit, unit.coords, airport.coords);
+ if (home.length < 2) continue;
+ const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
+ if (this._pathHours(unit, home) >= fuel - 1e-9) this._orderAirReturn(unit);
+ continue;
+ }
+ this._orderAirReturn(unit);
+ }
+ },
+
+ // ----------------------------------------------------------- strike --
+
+ _strikeTargetAt(coords, civ) {
+ const enemies = this.unitsAt(coords).filter(
+ (unit) => unit.civ !== civ && this._isMilitary(unit)
+ );
+ if (enemies.length === 0) return null;
+ enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
+ return enemies[0];
+ },
+
+ // Whether every aircraft in the group may bomb `goal`: all strike-capable,
+ // within strike range, inside their mission radius and still carrying fuel.
+ canStrike(unitIds, goal) {
+ const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
+ if (units.length === 0) return false;
+ const civ = units[0].civ;
+ if (!this._strikeTargetAt(goal, civ)) return false;
+ return units.every((unit) => this._strikeReady(unit, goal));
+ },
+
+ _strikeReady(unit, goal) {
+ const proto = this.unitProto(unit);
+ if (!proto) return false;
+ const distance = this.topology.tileDistance(unit.coords, goal);
+ if (distance > (proto.range || 1)) return false;
+ if (distance > (proto.missionRange || distance)) return false;
+ const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
+ return fuel > 0;
+ },
+
+ // A bombing run: every strike aircraft damages the weakest hostile military
+ // unit on `goal` without moving and without joining the melee. The sortie
+ // burns `strikeHours` of endurance, so bombers must still return to an
+ // airport. Declares war on the target if the two were at peace.
+ requestStrike(unitIds, goal) {
+ const units = this._unitsForOrder(unitIds).filter((unit) => this._isStrikeUnit(unit));
+ if (units.length === 0) return false;
+ const civ = units[0].civ;
+ if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
+ if (!this._strikeTargetAt(goal, civ)) return false;
+ this._declareWar(civ, this._strikeTargetAt(goal, civ).civ);
+ const destroyed = [];
+ for (const unit of units) {
+ const proto = this.unitProto(unit);
+ const target = this._strikeTargetAt(goal, civ);
+ if (target) {
+ const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
+ target.hp -= damage;
+ if (target.hp <= 0) {
+ target.hp = 0;
+ destroyed.push(target);
+ }
+ }
+ unit.airHours = Math.max(0, (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) - (proto.strikeHours || 1));
+ unit.autoReturn = false;
+ }
+ if (destroyed.length > 0) {
+ this._recordMilitaryCasualties(destroyed);
+ for (const unit of destroyed) this._destroyUnit(unit);
+ this._visibilityDirty = true;
+ }
+ this._emitChanged();
+ return true;
+ },
+
+ // ---------------------------------------------------------- training --
+
+ // Places a freshly trained unit: aircraft and land units appear in the city,
+ // while a warship is launched onto the least crowded adjacent sea tile.
+ _spawnTrainedUnit(city, proto) {
+ return this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
+ },
+
+ _spawnCoordsFor(city, proto) {
+ const terrains = proto.traversableTerrains || [];
+ const naval = terrains.length === 1 && terrains[0] === "Sea";
+ if (!naval) return city.coords;
+ let best = null;
+ let bestCount = Infinity;
+ for (const neighbour of this._neighbours(city.coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (!tile || tile.terrainClass !== "Sea") continue;
+ const count = this.unitsAt(neighbour).length;
+ if (count < bestCount) {
+ bestCount = count;
+ best = neighbour;
+ }
+ }
+ return best || city.coords;
+ },
+};
diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js
index 36ac198..f069fc9 100644
--- a/shared/game_state/combat.js
+++ b/shared/game_state/combat.js
@@ -199,7 +199,7 @@ export const combatMethods = {
_resolveCaptures() {
const claimed = new Set();
for (const unit of this.units) {
- if (!this._isMilitary(unit)) continue;
+ 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));
@@ -220,7 +220,7 @@ export const combatMethods = {
_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)) {
+ if (city && city.civ !== unit.civ && this._isMilitary(unit) && !this._isAirUnit(unit)) {
this._captureCity(city, unit.civ);
}
},
diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js
index bed12e0..c56de14 100644
--- a/shared/game_state/economy.js
+++ b/shared/game_state/economy.js
@@ -536,7 +536,7 @@ export const economyMethods = {
if (entry.kind === "building") {
this._completeBuilding(city, entry);
} else {
- this._spawnUnit(city.coords, city.civ, proto);
+ this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
}
queue.shift();
diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js
index 31432da..90734da 100644
--- a/shared/game_state/movement.js
+++ b/shared/game_state/movement.js
@@ -126,24 +126,34 @@ export const movementMethods = {
// joined on arrival; this only decides whether the march declares war.
const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null;
const attacking = !!target && this._isMilitary(unit);
+ const proto = this.unitProto(unit);
+ const air = this._isAirProto(proto);
const path = this.findPath(unit, unit.coords, goal, pathCache);
if (path.length < 2) {
return false;
}
+ // An aircraft may only be ordered inside its mission radius.
+ if (air && path.length - 1 > (proto.missionRange || Infinity)) {
+ return false;
+ }
// Marching a military unit into, or attacking, a country we are at peace
// with declares war on it. The browser confirms the move first, but the
- // server enforces the rule so the two can never disagree.
+ // server enforces the rule so the two can never disagree. Aircraft fly
+ // over foreign land without provoking anyone; only a strike declares war.
if (this._isMilitary(unit)) {
if (attacking) this._declareWar(unit.civ, target.civ);
- for (const coords of path) {
- const owner = this.civAt(coords);
- if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
+ if (!air) {
+ for (const coords of path) {
+ const owner = this.civAt(coords);
+ if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
+ }
}
}
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = speed;
+ unit.autoReturn = false;
return true;
},
@@ -183,7 +193,10 @@ export const movementMethods = {
// Friendly occupants do not block either: the unit simply stacks.
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
const needed = cost - unit.progressHours;
- if (hoursLeft >= needed) {
+ // A hair of slack lets a run of equal steps that sums to exactly the
+ // tick's hours (for example ten 0.2-hour air steps) finish in one tick
+ // instead of stalling on a floating-point remainder.
+ if (hoursLeft + 1e-9 >= needed) {
hoursLeft -= needed;
unit.pathIndex += 1;
unit.progressHours = 0;
@@ -268,6 +281,11 @@ export const movementMethods = {
},
_tileTravelHours(unit, coords) {
+ // Aircraft fly over the ground: their speed is a flat tiles-per-hour figure,
+ // untouched by terrain, roads or whose land lies below.
+ if (this._isAirUnit(unit)) {
+ return 1 / (unit.moveSpeed || this._unitSpeed(unit));
+ }
// A road levels the terrain: every road tile costs the same to cross.
const multiplier = this.roads.has(key(coords.x, coords.y))
? ROADS.movementCostMultiplier
@@ -311,9 +329,17 @@ export const movementMethods = {
// to pass through: pathfinding routes around enemies instead of through them.
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1) {
const proto = this.unitProto(unit);
- if (!this.canEnter(unit.civ, proto, coords)) return false;
+ const air = this._isAirProto(proto);
+ if (air) {
+ // Aircraft fly over fog and foreign lands alike, so only the tile's
+ // existence and its terrain class constrain them.
+ const tile = this.tiles[key(coords.x, coords.y)];
+ if (!tile || !proto.traversableTerrains.includes(tile.terrainClass)) return false;
+ } else if (!this.canEnter(unit.civ, proto, coords)) {
+ return false;
+ }
const owner = this.civAt(coords);
- if (!isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
+ if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
const occupants = this.unitsAt(coords).filter((other) => other !== unit);
if (occupants.length > 0) {
const hostile = occupants.filter((other) => other.civ !== unit.civ);
@@ -325,6 +351,8 @@ export const movementMethods = {
}
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (city && city.civ !== unit.civ) {
+ // Aircraft never occupy an enemy city; they attack from the air instead.
+ if (air) return false;
if (!isGoal) return false;
if (!this._isMilitary(unit)) return false;
}
diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js
index 04e4ce4..7327e46 100644
--- a/shared/game_state/orders.js
+++ b/shared/game_state/orders.js
@@ -18,11 +18,13 @@ export const orderMethods = {
if (!city) return false;
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
const proto = this.protoUnits[protoIndex];
+ // Aircraft and warships need the right installation in the city first.
+ if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
// A free unit (the worker) needs neither money nor production time.
if (proto.cost <= 0) {
- this._spawnUnit(city.coords, city.civ, proto);
+ this._spawnTrainedUnit(city, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
@@ -64,6 +66,9 @@ export const orderMethods = {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
+ // Placement rules: a port needs the sea, a shipyard needs a port.
+ if (proto.coastal && !this.isCoastalCity(city)) return false;
+ if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
const level =
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index 92262ef..cf89f56 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -87,6 +87,9 @@ export const serializationMethods = {
coords: [city.coords.x, city.coords.y],
name: city.name,
isCapital: city.isCapital,
+ // Whether a port may be raised here, so the browser can grey out the
+ // order without regenerating the map.
+ coastal: this.isCoastalCity(city),
population: city.population,
improvements: city.improvements,
buildings: { ...city.buildings },
@@ -116,6 +119,9 @@ export const serializationMethods = {
hp: unit.hp,
maxHp: unit.maxHp,
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
+ // Aircraft carry their endurance so the client can show the fuel left.
+ airHours: unit.airHours,
+ homeCityId: unit.homeCityId,
path,
pathIndex,
progressHours: unit.progressHours,
diff --git a/shared/game_state/statuses.js b/shared/game_state/statuses.js
index 69e3e34..bdfc559 100644
--- a/shared/game_state/statuses.js
+++ b/shared/game_state/statuses.js
@@ -42,7 +42,8 @@ export const statusMethods = {
_isNaval(unit) {
const proto = this.unitProto(unit);
- return !!(proto && proto.traversableTerrains.includes("Sea"));
+ // Aircraft may fly over the sea, but only a hull counts as naval supply.
+ return !!(proto && !proto.air && proto.traversableTerrains.includes("Sea"));
},
// Land connected components for one civ, with enemy-occupied and enemy-held
diff --git a/shared/game_state/world.js b/shared/game_state/world.js
index dd42e4e..3361799 100644
--- a/shared/game_state/world.js
+++ b/shared/game_state/world.js
@@ -108,7 +108,7 @@ export const worldMethods = {
this.roads = buildRoadNetwork(this.cities, this.topology, this.tiles);
},
- _spawnUnit(center, civIndex, proto) {
+ _spawnUnit(center, civIndex, proto, homeCityId = null) {
let index = this.protoUnits.indexOf(proto);
if (index < 0) {
this.protoUnits.push(proto);
@@ -129,6 +129,12 @@ export const worldMethods = {
invader: false,
statuses: [],
};
+ if (proto.air) {
+ unit.homeCityId = homeCityId;
+ unit.airHours = proto.enduranceHours;
+ unit.airborne = false;
+ unit.autoReturn = false;
+ }
this.units.push(unit);
this._unitById.set(this._nextUnitId, unit);
this._nextUnitId += 1;
diff --git a/tests/air_test.js b/tests/air_test.js
new file mode 100644
index 0000000..949d202
--- /dev/null
+++ b/tests/air_test.js
@@ -0,0 +1,231 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState } from "./framework/helpers.js";
+import { parseKey } from "../shared/hex.js";
+
+const BIG_BUDGET = 1.0e15;
+
+function buildingIndex(state, id) {
+ return state.protoBuildings.findIndex((building) => building.id === id);
+}
+
+function protoIndex(state, id) {
+ return state.protoUnits.findIndex((unit) => unit.id === id);
+}
+
+function protoUnit(state, id) {
+ return state.protoUnits.find((unit) => unit.id === id);
+}
+
+function coastalCity(state, civ) {
+ return state.cities.find((city) => city.civ === civ && state.isCoastalCity(city)) || null;
+}
+
+function inlandCity(state, civ) {
+ return state.cities.find((city) => city.civ === civ && !state.isCoastalCity(city)) || null;
+}
+
+function giveBuilding(state, city, id, level = 1) {
+ const index = buildingIndex(state, id);
+ city.buildings[index] = level;
+ state._gdpPerCapitaCache.clear();
+}
+
+function firstLandNeighbour(state, coords) {
+ return state.topology.neighbours(coords.x, coords.y).find((n) => state._isLand(n)) || null;
+}
+
+function freeAdjacentLand(state) {
+ for (const cell of state.landCells) {
+ if (state.cityAt(cell) || state.unitAt(cell)) continue;
+ for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
+ if (!state._isLand(neighbour) || state.cityAt(neighbour) || state.unitAt(neighbour)) continue;
+ return [cell, neighbour];
+ }
+ }
+ return null;
+}
+
+function farTile(state, from, minDistance) {
+ for (const k in state.tiles) {
+ const coords = parseKey(k);
+ if (state.topology.tileDistance(from, coords) <= minDistance) continue;
+ if (state.cityAt(coords) || state.unitAt(coords)) continue;
+ return coords;
+ }
+ return null;
+}
+
+export class AirTest extends TestCase {
+ test_port_can_only_be_raised_in_a_coastal_city() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const port = buildingIndex(state, "port");
+ const inland = inlandCity(state, 0);
+ const coastal = coastalCity(state, 0);
+ this.assertNotNull(inland, "the fixture has an inland city");
+ this.assertNotNull(coastal, "the fixture has a coastal city");
+ this.assertFalse(state.requestBuild(inland.id, port), "an inland city cannot build a port");
+ this.assertTrue(state.requestBuild(coastal.id, port), "a coastal city can build a port");
+ }
+
+ test_shipyard_needs_a_coastal_city_and_a_port() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const shipyard = buildingIndex(state, "shipyard");
+ const city = coastalCity(state, 0);
+ this.assertFalse(state.requestBuild(city.id, shipyard), "no port, no shipyard");
+ giveBuilding(state, city, "port");
+ this.assertTrue(state.requestBuild(city.id, shipyard), "a port unlocks the shipyard");
+
+ // Even with a port, an inland city has no water to launch from.
+ const inland = inlandCity(state, 0);
+ this.assertNotNull(inland, "the fixture has an inland city");
+ giveBuilding(state, inland, "port");
+ this.assertFalse(state.requestBuild(inland.id, shipyard), "a shipyard is coastal-only");
+ }
+
+ test_airport_and_shipyard_gate_training() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const city = coastalCity(state, 0);
+ const jet = protoIndex(state, "jet_fighter");
+ const battleship = protoIndex(state, "battleship");
+ this.assertFalse(state.requestTrain(city.id, jet), "no airport, no aircraft");
+ this.assertFalse(state.requestTrain(city.id, battleship), "no shipyard, no warship");
+ giveBuilding(state, city, "airport");
+ giveBuilding(state, city, "shipyard");
+ this.assertTrue(state.requestTrain(city.id, jet), "the airport unlocks aircraft");
+ this.assertTrue(state.requestTrain(city.id, battleship), "the shipyard unlocks warships");
+ }
+
+ test_trained_aircraft_gets_its_home_airport() {
+ const state = smallState();
+ state.budgets.set(0, BIG_BUDGET);
+ const city = coastalCity(state, 0);
+ giveBuilding(state, city, "airport");
+ const jet = protoIndex(state, "jet_fighter");
+ this.assertTrue(state.requestTrain(city.id, jet), "the airport lets the order through");
+ for (let i = 0; i < 100000 && state.training.size > 0; i++) state.advanceHour();
+ const unit = state.units.find((u) => u.proto === jet && u.civ === 0);
+ this.assertNotNull(unit, "the aircraft was produced");
+ this.assertEqual(unit.homeCityId, city.id, "it is based at the training city");
+ this.assertFalse(unit.airborne, "it is parked on the runway");
+ }
+
+ test_battleships_launch_onto_an_adjacent_sea_tile() {
+ const state = smallState();
+ const city = coastalCity(state, 0);
+ const battleship = protoUnit(state, "battleship");
+ const unit = state._spawnTrainedUnit(city, battleship);
+ const tile = state.tiles[`${unit.coords.x},${unit.coords.y}`];
+ this.assertEqual(tile.terrainClass, "Sea", "the hull is launched on water");
+ this.assertTrue(
+ state.topology.tileDistance(unit.coords, city.coords) === 1,
+ "the launch tile neighbours the city"
+ );
+ }
+
+ test_aircraft_fly_home_and_refuel_after_a_sortie() {
+ const state = smallState();
+ const city = coastalCity(state, 0);
+ giveBuilding(state, city, "airport");
+ const jet = protoUnit(state, "jet_fighter");
+ const unit = state._spawnUnit(city.coords, 0, jet, city.id);
+ const goal = firstLandNeighbour(state, city.coords);
+ this.assertNotNull(goal, "there is somewhere to fly");
+ this.assertTrue(state.requestGroupMove([unit.id], goal), "the sortie is accepted");
+
+ state.advanceHour();
+ this.assertEqual(unit.coords.x, goal.x, "it reached the target");
+ this.assertTrue(unit.airborne, "it is airborne away from the airport");
+ this.assertLess(unit.airHours, jet.enduranceHours, "the sortie burned fuel");
+
+ state.advanceHour();
+ this.assertEqual(unit.coords.x, city.coords.x, "it returned to its airport");
+ this.assertEqual(unit.coords.y, city.coords.y);
+ this.assertFalse(unit.airborne, "it is parked again");
+ this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it refuelled");
+ }
+
+ test_aircraft_crash_when_no_airport_can_reach_them() {
+ const state = smallState();
+ const jet = protoUnit(state, "jet_fighter");
+ const spot = freeAdjacentLand(state);
+ const unit = state._spawnUnit(spot[0], 0, jet, null);
+ this.assertNull(state._nearestFriendlyAirport(unit), "no airport exists yet");
+ state.advanceHour();
+ this.assertNotNull(state.findUnit(unit.id), "still flying after one hour");
+ state.advanceHour();
+ this.assertNull(state.findUnit(unit.id), "out of fuel, it crashed");
+ }
+
+ test_aircraft_cross_every_terrain_at_their_listed_speed() {
+ const state = smallState();
+ const jet = protoUnit(state, "jet_fighter");
+ const unit = state._spawnUnit(state.landCells[0], 0, jet, null);
+ const expected = 1 / jet.speed;
+ let checked = 0;
+ for (const k in state.tiles) {
+ this.assertApprox(
+ state._tileTravelHours(unit, parseKey(k)),
+ expected,
+ 1e-9,
+ `flat flight time over ${state.tiles[k].terrainType}`
+ );
+ checked += 1;
+ if (checked >= 12) break;
+ }
+ this.assertGreater(checked, 0);
+ }
+
+ test_aircraft_reach_the_edge_of_their_radius_and_return() {
+ const state = smallState();
+ const city = coastalCity(state, 0);
+ giveBuilding(state, city, "airport");
+ const jet = protoUnit(state, "jet_fighter");
+ const unit = state._spawnUnit(city.coords, 0, jet, city.id);
+ let goal = null;
+ for (const k in state.tiles) {
+ const coords = parseKey(k);
+ if (state.topology.tileDistance(city.coords, coords) !== jet.missionRange) continue;
+ if (state.cityAt(coords) || state.unitAt(coords)) continue;
+ goal = coords;
+ break;
+ }
+ this.assertNotNull(goal, "a tile sits on the radius edge");
+ this.assertTrue(state.requestGroupMove([unit.id], goal), "the edge is reachable");
+ state.advanceHour();
+ this.assertEqual(unit.coords.x, goal.x, "it reached the edge");
+ this.assertEqual(unit.coords.y, goal.y);
+ state.advanceHour();
+ this.assertEqual(unit.coords.x, city.coords.x, "the round trip closed");
+ this.assertEqual(unit.coords.y, city.coords.y);
+ this.assertApprox(unit.airHours, jet.enduranceHours, 1e-9, "it landed and refuelled");
+ }
+
+ test_aircraft_cannot_be_ordered_beyond_their_range() {
+ const state = smallState();
+ const city = coastalCity(state, 0);
+ giveBuilding(state, city, "airport");
+ const jet = protoUnit(state, "jet_fighter");
+ const unit = state._spawnUnit(city.coords, 0, jet, city.id);
+ const target = farTile(state, city.coords, jet.missionRange);
+ this.assertNotNull(target, "the map has tiles beyond the radius");
+ this.assertFalse(state.requestGroupMove([unit.id], target), "the order is refused");
+ this.assertEmpty(unit.path, "no route was stored");
+ }
+
+ test_bombers_strike_from_afar_without_joining_the_battle() {
+ const state = smallState();
+ const bomber = protoUnit(state, "bomber");
+ const infantry = protoUnit(state, "modern_infantry");
+ const pair = freeAdjacentLand(state);
+ const attacker = state._spawnUnit(pair[0], 0, bomber, null);
+ const defender = state._spawnUnit(pair[1], 1, infantry);
+ this.assertTrue(state.requestStrike([attacker.id], defender.coords), "the strike is accepted");
+ this.assertLess(defender.hp, defender.maxHp, "the bomb damaged the target");
+ this.assertEqual(attacker.coords.x, pair[0].x, "the bomber never moved");
+ this.assertEqual(attacker.coords.y, pair[0].y);
+ this.assertEmpty(state._battleTiles(), "no melee battle was started");
+ }
+}
diff --git a/tests/data_test.js b/tests/data_test.js
index 718af99..90e7dab 100644
--- a/tests/data_test.js
+++ b/tests/data_test.js
@@ -92,6 +92,7 @@ export class DataTest extends TestCase {
for (const building of BUILDINGS) {
this.assertNotEmpty(building.effects, `${building.id} effects`);
this.assertGreater(building.costAlpha, 1, `${building.id} cost growth`);
+ this.assertTrue(building.icon && building.icon.endsWith(".svg"), `${building.id} icon`);
for (const effect of building.effects) {
this.assertTrue(effect.stat.length > 0);
this.assertGreater(effect.baseValue, 0);
@@ -113,6 +114,37 @@ export class DataTest extends TestCase {
}
}
+ test_ports_airports_and_shipyards_declare_their_requirements() {
+ const port = buildingById("port");
+ const airport = buildingById("airport");
+ const shipyard = buildingById("shipyard");
+ this.assertTrue(port.coastal, "a port needs the coast");
+ this.assertNull(port.requiresBuilding, "a port stands on its own");
+ this.assertTrue(shipyard.requiresBuilding === "port", "a shipyard needs a port");
+ this.assertTrue(shipyard.coastal, "a shipyard needs the coast too");
+ this.assertNull(airport.requiresBuilding, "an airport stands on its own");
+ this.assertTrue(port.icon.endsWith(".svg"));
+ this.assertTrue(airport.icon.endsWith(".svg"));
+ this.assertTrue(shipyard.icon.endsWith(".svg"));
+ }
+
+ test_aircraft_and_warships_are_gated_and_fuelled() {
+ for (const id of ["jet_fighter", "bomber"]) {
+ const plane = protoUnitById(id);
+ this.assertTrue(plane.air, `${id} is an aircraft`);
+ this.assertEqual(plane.requiresBuilding, "airport", `${id} needs an airport`);
+ this.assertGreater(plane.speed, 1, `${id} is fast`);
+ this.assertGreater(plane.enduranceHours, 0, `${id} has endurance`);
+ this.assertGreater(plane.missionRange, 0, `${id} has a mission radius`);
+ this.assertTrue(plane.icon.endsWith(".svg"));
+ }
+ this.assertTrue(protoUnitById("bomber").strike, "the bomber strikes from afar");
+ const battleship = protoUnitById("battleship");
+ this.assertEqual(battleship.requiresBuilding, "shipyard");
+ this.assertEqual(battleship.traversableTerrains, ["Sea"]);
+ this.assertTrue(battleship.icon.endsWith(".svg"));
+ }
+
test_effect_value_scales_with_level() {
const effect = { baseValue: 10, exponent: 1 };
this.assertApprox(effectValue(effect, 1), 10);
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 32ba585..af43734 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
-import { CIVILISATIONS } from "../shared/data.js";
+import { BUILDINGS, CIVILISATIONS } from "../shared/data.js";
import { defaultState, adjacentLand } from "./framework/helpers.js";
function stubNetwork() {
@@ -101,7 +101,7 @@ export class GameScreenTest extends TestCase {
screen.map.onCitySelected(capital.id);
env.$("#city-manage").click();
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
- this.assertSize(env.$("#city-buildings-list .row-card"), 6);
+ this.assertSize(env.$("#city-buildings-list .row-card"), BUILDINGS.length);
screen.onState(state.snapshot(0));
this.assertFalse(env.$("#modal-city").hasClass("hidden"));
diff --git a/tests/run_tests.js b/tests/run_tests.js
index 5be0177..8c62049 100644
--- a/tests/run_tests.js
+++ b/tests/run_tests.js
@@ -31,6 +31,7 @@ import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_t
import { WebSocketProtocolTest } from "./websocket_test.js";
import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
+import { AirTest } from "./air_test.js";
import { CityModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
import { GameFeedTest } from "./feed_test.js";
@@ -82,6 +83,7 @@ const SUITES = [
WebSocketProtocolTest,
NetTest,
MapViewTest,
+ AirTest,
CityModalTest,
NationModalTest,
ConfirmModalTest,