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,