From a219cf650f5fd8ecf0872ce33e5e52e7ca83f49f Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Thu, 24 Sep 2026 20:25:08 +0200 Subject: [PATCH] Flew aircraft and missiles straight and let strikes target fogged cities --- AGENTS.md | 18 +++- ROADMAP.md | 2 +- client/css/style.css | 8 ++ client/js/devlog_data.js | 12 +-- client/js/game_screen.js | 21 ++++- client/js/game_screen/panels.js | 60 +++++++++--- client/js/map_view/entities.js | 13 ++- client/js/map_view/motion.js | 142 ++++++++++++++++++++--------- shared/data/combat.js | 9 +- shared/game_state/air.js | 135 ++++++++++++++++++++++----- shared/game_state/movement.js | 122 +++++++++++++++++++++---- shared/game_state/serialization.js | 12 +-- shared/game_state/wmd.js | 16 +++- shared/hex.js | 50 ++++++++++ tests/air_movement_test.js | 126 +++++++++++++++++++++++++ tests/game_screen_test.js | 43 +++++++++ tests/game_server_test.js | 8 +- tests/hex_test.js | 70 +++++++++++++- tests/map_view_input_test.js | 24 +++++ tests/map_view_test.js | 49 ++++++++++ tests/wmd_test.js | 16 +++- 21 files changed, 829 insertions(+), 127 deletions(-) create mode 100644 tests/air_movement_test.js diff --git a/AGENTS.md b/AGENTS.md index 3e321a6..c1d0c45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,8 +132,8 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. `strikeReadyHour` cooldown. The strike is charged up front per aircraft (`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and the ferry is bounded by fuel, not the combat `missionRange`; they never join - melee. A strike may target a garrison inside a hostile city — the plane enters - that tile as a goal only to bomb and turns for home, and captures skip + melee. A strike may target a garrison inside a hostile city — the plane reaches + that tile only to bomb and turns for home, and captures skip aircraft, so it never occupies or takes the city. A strike is only accepted when the whole round trip fits the remaining fuel (out to the target, then home to a friendly airport), and the browser @@ -142,6 +142,20 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. snapshot and `map_view/motion.js` draws the red dashed line and target ring on `#layer-targets` while either endpoint is selected. +- **Aircraft and launched missiles fly straight lines, not hexes** + (`GameState._isFlier`). Their `path` is a list of world-pixel points (the + two-point `flightLine` from `shared/hex.js`, using `nearestCopyPoint` for the + wrapped seam), and `_advanceFlier`/`_completeFlightLeg` in `movement.js` fly + them in `advanceMovement`; `_segmentHours` is the single source for flight + time and fuel, so a plane can never run dry on a route its own check accepted. + `findPath` returns a straight line for a flier and never runs A*. The browser + mirrors all of this in `map_view/motion.js` (`_planMove`, `_flightHours`, + `_motionTile`, `motion.flight`): a mismatch makes the icon fight the server. + A missile (`requestMissileStrike`) now flies to its target and detonates on + arrival in `_detonateMissile`, so air defense can shoot it down first. + Air defense is a **corridor**, not a tile-entry volley: `_airDefenseVolley` + fires once per in-game hour from every hostile battery within + `AIR_DEFENSE.rangeTiles` of the flier, charged per firing battery. - Terrain generation is deterministic from `(seed, mapConfig)`, so `GameState._buildTerrain` and the settlement/road step cache the world shape (`shared/game_state/world.js`) rather than regenerating it for every game with diff --git a/ROADMAP.md b/ROADMAP.md index c0c9cf5..95ebc8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -322,7 +322,7 @@ simplification worth revisiting. Anything unticked is still open. (any unit caught in the blast is destroyed too) - [x] Stacks must behave as single units when it comes to pathfinding - [x] Cannot go through neutral third-party territory, allied and at-war territory can be walked through -- [ ] Airplanes move in straight lines as they are not bound by tiles. +- [x] Airplanes move in straight lines as they are not bound by tiles. * [x] Non-melee units such as artillery, airplanes, have an 80 % chance to be destroyed when an enemy attempts to capture them ### Intelligence diff --git a/client/css/style.css b/client/css/style.css index d54812a..d95841f 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -387,6 +387,14 @@ input:focus, select:focus { border-color: var(--accent); } /* Picking a target takes over the cursor. */ #map-viewport.targeting { cursor: crosshair; } #map-viewport.targeting.dragging { cursor: grabbing; } +/* While picking a target every click belongs to the map. The unit and city + icons must not swallow it: a click on an enemy unit icon would otherwise run + the normal selection path (clear the selection) instead of choosing the + target, so the Attack action appeared to do nothing. */ +#map-viewport.targeting #layer-entities, +#map-viewport.targeting #layer-labels { + pointer-events: none; +} /* The delivery graph, shown only in the economic map modes: a white line per delivery leg, an animated merchandise icon riding it and a dot on every diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 032e562..e49619b 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "36ac669", + "date": "2026-09-24", + "subject": "Let stacks split by shift-click and cross allied or at-war land", + "body": "" + }, { "hash": "cc5c57c", "date": "2026-09-24", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-23", "subject": "Drew the cylindrical map as a repeating world", "body": "" - }, - { - "hash": "4600b16", - "date": "2026-09-23", - "subject": "Stockpiled a month of every material and cached the trade walks", - "body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended." } ]; diff --git a/client/js/game_screen.js b/client/js/game_screen.js index f1fcb88..2941536 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -11,6 +11,7 @@ import { MapView } from "./map_view.js"; import { CityModal, NationModal, TileModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js"; import { GameFeed } from "./feed.js"; import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js"; +import { flightDistanceTiles } from "../../shared/hex.js"; import { bootLog } from "./loading.js"; import { formatCapabilityReport } from "./capabilities.js"; import { panelMethods } from "./game_screen/panels.js"; @@ -456,6 +457,19 @@ export class GameScreen { } } const { targets, speed, routes } = await this._groupMovePlan(units, coords); + // An attack on a neutral nation's unit or city declares war exactly as + // marching into its land does, so the browser asks first. The territory + // scan in `_groupMovePlan` only names owners a ground route crosses; a + // strike on an aircraft's straight line, or on a defenceless city, must be + // named here. + if (type === "attack") { + const defender = this._attackTargetCiv(coords); + if (defender >= 0 && !targets.includes(defender) && + !this._isAtWar(this.localCiv, defender) && + !this._isAllied(this.localCiv, defender)) { + targets.push(defender); + } + } // A strike is refused before it is ever sent when the target lies beyond // the unit's reach, so the browser and the server never disagree. An air // strike must fit the whole round trip, not just the flight out. @@ -478,9 +492,12 @@ export class GameScreen { if (!route) return false; if (!proto.air) return false; // A ferry flight is bounded by fuel, not the combat mission radius: - // endurance hours at the aircraft's speed. + // endurance hours at the aircraft's speed. The flight is a straight line, + // so distance is measured along it rather than by counting path tiles. const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1); - return route.length - 1 > ferryTiles; + const from = { x: unit.coords[0], y: unit.coords[1] }; + return flightDistanceTiles(from, coords, this.map.topology, this.map._maxStepLength) > + ferryTiles; }); if (outOfRange) { this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null); diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 86af7a2..3061484 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -47,6 +47,7 @@ import { defenseFactors, unitDefense, productionFactors, productionMultiplier, p import { formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js"; import { dateString, hoursInNextMonth } from "../../../shared/game_clock.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; +import { flightDistanceTiles } from "../../../shared/hex.js"; import { ethnicityChart } from "../ui/ethnicity.js"; import { isEconomicMode } from "../map_view/economic.js"; import { isWaterTile } from "../map_view/textures.js"; @@ -312,6 +313,9 @@ export const panelMethods = { if (!route || route.length < 2) continue; routes.set(unit.id, route); if (!this._isMilitaryUnit(unit)) continue; + // A flight path is world pixels, not tiles, and an aircraft crosses + // foreign land without declaring war; only ground marches name targets. + if (this._isAirUnit(unit)) continue; for (const point of route) { const owner = this.map.territory.get(`${point.x},${point.y}`); if (owner === undefined || owner < 0 || owner === unit.civ) continue; @@ -616,8 +620,9 @@ export const panelMethods = { }, // A left-click in target-picking mode. A ranged attack fires when the tile - // holds a hostile military unit; a spy operation files its report or opens its - // bombardment when the tile holds the city it wants. + // holds a hostile military unit, or -- for an aircraft -- a hostile city; a + // spy operation files its report or opens its bombardment when the tile holds + // the city it wants. _chooseTarget(coords) { const targeting = this._targeting; if (!targeting) return; @@ -644,7 +649,7 @@ export const panelMethods = { } return; } - if (!this._isEnemyMilitaryAt(coords)) return; + if (!this._isEnemyMilitaryAt(coords) && !this._airStrikeOrder(units, coords)) return; this._orderMove(units, coords, "attack"); }, @@ -671,8 +676,8 @@ export const panelMethods = { return !!(proto && proto.air); }, - // Whether `coords` holds a hostile military unit, the only thing a strike may - // target. + // Whether `coords` holds a hostile military unit, the target of a ranged + // strike. _isEnemyMilitaryAt(coords) { return this.map.unitIdsAt(coords).some((id) => { const data = this._unit(id); @@ -680,6 +685,30 @@ export const panelMethods = { }); }, + // A city that is not ours, at `coords`. A city known to the player but hidden + // under the fog is still a legitimate target, so this reads the snapshot + // rather than the visible unit layer. + _isEnemyCityAt(coords) { + return this._cityCivAt(coords) >= 0; + }, + + // The hostiles a strike on `coords` would fall on: the enemy military unit + // there, else the non-friendly city. -1 when there is nothing to attack. + _attackTargetCiv(coords) { + const hostile = this.map.unitIdsAt(coords) + .map((id) => this._unit(id)) + .find((data) => data && data.civ !== this.localCiv && this._isMilitaryUnit(data)); + if (hostile) return hostile.civ; + return this._cityCivAt(coords); + }, + + _cityCivAt(coords) { + const city = ((this.snapshot && this.snapshot.cities) || []).find( + (c) => c.coords[0] === coords.x && c.coords[1] === coords.y + ); + return city && city.civ !== this.localCiv ? city.civ : -1; + }, + // A ground/naval battery attack: resolved where the battery stands, with a // standing hourly target rather than a march. _rangedAttackOrder(units, coords) { @@ -689,12 +718,13 @@ export const panelMethods = { return this._isEnemyMilitaryAt(coords); }, - // An aircraft attack: the plane flies to the hostile unit and bombs it on - // arrival, then returns home to re-arm. + // An aircraft attack: the plane flies to the hostile unit or city and bombs + // it on arrival, then returns home to re-arm. A city is a valid target even + // when it is under the fog and even when its garrison is not visible. _airStrikeOrder(units, coords) { if (units.length === 0) return false; if (!units.every((unit) => this._isAirUnit(unit))) return false; - return this._isEnemyMilitaryAt(coords); + return this._isEnemyMilitaryAt(coords) || this._isEnemyCityAt(coords); }, // Why an air strike cannot be issued right now, or null when it can. Blocks a @@ -765,14 +795,18 @@ export const panelMethods = { const toTarget = this.map.topology.tileDistance(from, coords); const airport = this._airReturnAirport(unit, coords); if (!airport) return "This aircraft has no friendly airport to return to."; - const toHome = this.map.topology.tileDistance(coords, { - x: airport.coords[0], - y: airport.coords[1], - }); + const home = { x: airport.coords[0], y: airport.coords[1] }; const speed = proto.speed || 1; const fuel = unit.airHours === undefined || unit.airHours === null ? proto.enduranceHours : unit.airHours; + // Fuel is spent in straight-line hours: out to the target, then on to the + // nearest friendly airport. Mirrors GameState._canStrikeAndReturn, so the + // browser refuses exactly what the server would. + const maxStep = this.map._maxStepLength || 1; + const hours = + flightDistanceTiles(from, coords, this.map.topology, maxStep) / speed + + flightDistanceTiles(coords, home, this.map.topology, maxStep) / speed; // The furthest it can strike: half the fuel buys the flight out, the other // half the flight home, capped by the mission radius. const effectiveRange = Math.floor( @@ -780,7 +814,7 @@ export const panelMethods = { ); const beyondMission = toTarget > (proto.missionRange || Infinity); - const beyondFuel = (toTarget + toHome) / speed > fuel + 1e-9; + const beyondFuel = hours > fuel + 1e-9; if (beyondMission || beyondFuel) { return `That target is ${toTarget} tiles away, but this aircraft's effective range is ${effectiveRange} tiles.`; } diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index cac4e7e..12f6b21 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -643,10 +643,11 @@ export const entityMethods = { _currentTile(motion) { if (motion.path.length >= 2) { + // A flight stores world pixels, so its tile comes from where it is on the + // line; a ground path stores tiles and is grouped with the one it heads + // to, so a stack splits the moment one of its members marches away. + if (motion.flight) return this._motionTile(motion); const index = pathProgress(motion).index; - // A unit that has been ordered off a tile no longer belongs to that - // tile's stack: it is grouped with the tile it is heading to, so the - // stack splits the moment one of its members marches away. return motion.path[Math.min(index + 1, motion.path.length - 1)]; } return motion.coords; @@ -687,11 +688,17 @@ export const entityMethods = { // selected stack can be ordered onto this tile (move, stack or attack). // Shift detaches just this unit to the moving group instead. if (event.button !== 0) return; + // While picking a ranged target the click belongs to the map, even when + // it lands on an icon; swallowing it here would clear the attacker's + // selection instead of choosing the target. + if (this._targeting) return; event.stopPropagation(); if (event.shiftKey) this.onUnitToggled(data.id); else this.onUnitSelected(data.id); }); view.addEventListener("dblclick", (event) => { + // A double-click while picking a target is just two target clicks. + if (this._targeting) return; // A unit standing on one of our cities yields to it: double-clicking the // unit opens the city, exactly as double-clicking the city icon would. const current = this._unitData.get(data.id) || data; diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index db3f0be..8a899f9 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -1,7 +1,13 @@ // Dead-reckoning unit motion between snapshots, path drawing and the local // move prediction that mirrors GameState.find_path. -import { mapToLocal, key, parseKey } from "../../../shared/hex.js"; +import { + mapToLocal, + localToMap, + flightLine, + key, + parseKey, +} from "../../../shared/hex.js"; import { HexPathfinder } from "../../../shared/hex_pathfinder.js"; import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js"; import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js"; @@ -51,9 +57,14 @@ export const motionMethods = { const id = data.id; let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] })); let segmentHours = data.segmentHours || []; - const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy); - path = clipped.path; - segmentHours = clipped.segmentHours; + // A flight path is already a straight pixel line with no tile occupants to + // stop it: the truncation walk works in tiles and must not touch it. + const flight = this._isFlierData(data); + if (!flight) { + const clipped = this._truncateAtOccupant(path, segmentHours, data, occupancy); + path = clipped.path; + segmentHours = clipped.segmentHours; + } const existing = this._unitMotion.get(id); if (path.length < 2 || segmentHours.length === 0) { if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) { @@ -64,6 +75,7 @@ export const motionMethods = { segmentHours: [], pathHours: 0, coords: { x: data.coords[0], y: data.coords[1] }, + flight, predicting: false, predictAge: 0, }); @@ -81,6 +93,7 @@ export const motionMethods = { segmentHours, pathHours, coords: { x: data.coords[0], y: data.coords[1] }, + flight, predicting: false, predictAge: 0, }); @@ -185,10 +198,14 @@ export const motionMethods = { } const from = path[index]; const to = path[index + 1] || from; - const a = mapToLocal(from.x, from.y); - const delta = this.topology.pixelDelta(from, to); const step = segmentHours[index] || 1; const t = Math.max(0, Math.min(1, remaining / step)); + // A flight path is already world pixels: interpolate the segment directly. + if (motion.flight) { + return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }; + } + const a = mapToLocal(from.x, from.y); + const delta = this.topology.pixelDelta(from, to); return { x: a.x + delta.x * t, y: a.y + delta.y * t }; }, @@ -204,14 +221,21 @@ export const motionMethods = { if (!this._selectedUnitIds.has(id)) continue; if (motion.path.length < 2) continue; const index = pathProgress(motion).index; - const base = mapToLocal(motion.path[index].x, motion.path[index].y); + const base = motion.flight + ? motion.path[index] + : mapToLocal(motion.path[index].x, motion.path[index].y); const current = this._positionAlongPath(motion); let previous = base; for (let i = index + 1; i < motion.path.length; i++) { - const from = motion.path[i - 1]; const to = motion.path[i]; - const delta = this.topology.pixelDelta(from, to); - const next = { x: previous.x + delta.x, y: previous.y + delta.y }; + // A flight path stores pixel points already; a ground path stores tiles + // and is unwrapped a segment at a time. + const next = motion.flight + ? to + : (() => { + const delta = this.topology.pixelDelta(motion.path[i - 1], to); + return { x: previous.x + delta.x, y: previous.y + delta.y }; + })(); const start = i === index + 1 ? current : previous; fragment.appendChild(makeSegment(start, next, "#ffffff", 4)); previous = next; @@ -236,12 +260,13 @@ export const motionMethods = { return mapToLocal(motion.coords.x, motion.coords.y); }, - // Draws the red dashed barrage arc from every ranged attacker with a standing - // target to the target tile, and a red ring around the target. The shell lobs - // in a parabola rather than along the ground, and the dashes march and a - // glowing shell travels the curve so the direction is unmistakable. They stay - // visible so an ongoing barrage cannot be forgotten, and sit on a layer above - // the terrain but below the unit and city icons. + // Draws the red dashed line from every ranged attacker with a standing target + // to the target tile, and a red ring around the target. A gun's shell lobs in + // a parabola rather than along the ground; an aircraft's strike is no ballistic + // launch, so its line stays straight. The dashes march and a glowing shell + // travels the line so the direction is unmistakable. They stay visible so an + // ongoing barrage cannot be forgotten, and sit on a layer above the terrain but + // below the unit and city icons. // // The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when // its contents change; the per-strike target lookup goes through one index of @@ -296,10 +321,12 @@ export const motionMethods = { const a = fromPoint; const b = this._nearestCopy(toPoint, fromPoint); const distance = Math.hypot(b.x - a.x, b.y - a.y); - // A quadratic Bezier lobs the shell: the control point is lifted above the - // midpoint, its height compensated for the layer's Y foreshortening so the - // arc keeps its shape on screen. - const arc = Math.max(18, distance * 0.4) / CAMERA_TILT; + // A gun lobs its shell: a quadratic Bezier whose control point is lifted + // above the midpoint, its height compensated for the layer's Y + // foreshortening so the arc keeps its shape on screen. An aircraft flies + // straight to the target, so its line is a plain segment. + const straight = !!(this.protoUnits[data.proto] || {}).air; + const arc = straight ? 0 : Math.max(18, distance * 0.4) / CAMERA_TILT; const cx = (a.x + b.x) / 2; const cy = (a.y + b.y) / 2 - arc; let view = views.get(id); @@ -316,15 +343,20 @@ export const motionMethods = { view = { path, shell, ring }; views.set(id, view); } - view.path.setAttribute("d", `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`); + view.path.setAttribute( + "d", + straight + ? `M ${a.x} ${a.y} L ${b.x} ${b.y}` + : `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}` + ); view.path.setAttribute("class", selected ? "target-line target-selected" : "target-line"); // Marching dashes flow from the battery towards the target. view.path.style.strokeDashoffset = `${-((now / 22) % 18)}px`; - // A glowing shell rides the curve to show exactly where it is going. + // A glowing shell rides the line to show exactly where it is going. const t = (now / 1300) % 1; const mt = 1 - t; - view.shell.setAttribute("cx", `${mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); - view.shell.setAttribute("cy", `${mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); + view.shell.setAttribute("cx", `${straight ? a.x + (b.x - a.x) * t : mt * mt * a.x + 2 * mt * t * cx + t * t * b.x}`); + view.shell.setAttribute("cy", `${straight ? a.y + (b.y - a.y) * t : mt * mt * a.y + 2 * mt * t * cy + t * t * b.y}`); view.ring.className = selected ? "target-ring target-selected" : "target-ring"; view.ring.style.left = `${b.x}px`; view.ring.style.top = `${b.y}px`; @@ -528,9 +560,7 @@ export const motionMethods = { const view = this._unitViews.get(id); if (view && view.style.display === "none") continue; const motion = this._unitMotion.get(id); - const tile = motion && motion.path.length >= 2 - ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] - : { x: data.coords[0], y: data.coords[1] }; + const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] }; const k = key(tile.x, tile.y); if (!index.has(k)) index.set(k, id); } @@ -567,8 +597,14 @@ export const motionMethods = { const start = this._unitCoords(unitData.id) || { x: unitData.coords[0], y: unitData.coords[1] }; const segmentHours = []; - for (let i = 1; i < route.length; i++) { - segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed)); + if (proto.air || proto.missile) { + for (let i = 1; i < route.length; i++) { + segmentHours.push(this._flightHours(proto, route[i - 1], route[i], speed)); + } + } else { + for (let i = 1; i < route.length; i++) { + segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed)); + } } this._applyPrediction(unitData, { route, segmentHours, start }); return route; @@ -581,6 +617,7 @@ export const motionMethods = { segmentHours: plan.segmentHours, pathHours: 0, coords: plan.start, + flight: this._isFlierData(unitData), predicting: true, predictAge: 0, offset: existing ? existing.offset : { x: 0, y: 0 }, @@ -600,12 +637,20 @@ export const motionMethods = { ? `${unitData.civ}|${unitData.proto}|${start.x},${start.y}|${goal.x},${goal.y}|${speed}` : null; if (cacheKey && cache.has(cacheKey)) return cache.get(cacheKey); - const path = await this._search(unitData, proto, start, goal, speed); + // A flier's route is a straight pixel line to the goal, exactly as the + // server builds it; there is no territory, terrain or zone to search. + const path = (proto.air || proto.missile) + ? flightLine(mapToLocal(start.x, start.y), goal, this.topology) + : await this._search(unitData, proto, start, goal, speed); let plan = null; if (path && path.length >= 2) { const segmentHours = []; - for (let i = 1; i < path.length; i++) { - segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed)); + if (proto.air || proto.missile) { + segmentHours.push(this._flightHours(proto, path[0], path[1], speed)); + } else { + for (let i = 1; i < path.length; i++) { + segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed)); + } } plan = { route: path, segmentHours, start }; } @@ -613,6 +658,18 @@ export const motionMethods = { return plan; }, + // Whether a snapshot unit flies a straight pixel path (aircraft or missile). + _isFlierData(data) { + const proto = this.protoUnits[data.proto]; + return !!(proto && (proto.air || proto.missile)); + }, + + // Hours to fly a straight pixel segment, mirroring GameState._segmentFlightHours. + _flightHours(proto, a, b, speed = null) { + const effective = Math.max(speed || proto.speed, 0.0001); + return (Math.hypot(b.x - a.x, b.y - a.y) / (this._maxStepLength || 1)) / effective; + }, + // The A* this mirrors recomputes the cheapest movement cost on every node, // and the tile scan behind it dominated the search. Cache it here (the // TerrainStats memoises it too) and index the units once instead of scanning @@ -712,9 +769,7 @@ export const motionMethods = { for (const [id, data] of this._unitData) { if (id === ignoreId) continue; const motion = this._unitMotion.get(id); - const tile = motion && motion.path.length >= 2 - ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] - : { x: data.coords[0], y: data.coords[1] }; + const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] }; if (tile.x === coords.x && tile.y === coords.y) result.push(data); } return result; @@ -728,9 +783,7 @@ export const motionMethods = { for (const [id, data] of this._unitData) { if (id === ignoreId) continue; const motion = this._unitMotion.get(id); - const tile = motion && motion.path.length >= 2 - ? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)] - : { x: data.coords[0], y: data.coords[1] }; + const tile = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] }; const k = key(tile.x, tile.y); const list = index.get(k); if (list) list.push(data); @@ -756,16 +809,23 @@ export const motionMethods = { return denied; }, - _unitCoords(id) { - const motion = this._unitMotion.get(id); - if (!motion) return null; + // The tile a motion is over right now. A flight stores world pixels, so its + // tile is derived from where the line currently is. + _motionTile(motion) { if (motion.path.length >= 2) { + if (motion.flight) return localToMap(this._positionAlongPath(motion), this.topology); const index = pathProgress(motion).index; return motion.path[Math.min(index, motion.path.length - 1)]; } return motion.coords; }, + _unitCoords(id) { + const motion = this._unitMotion.get(id); + if (!motion) return null; + return this._motionTile(motion); + }, + // The civilisation a route is aimed at: the enemy city or unit on the goal, // or failing that whoever owns the goal tile. Crossing that civ's land is // allowed; every other foreign territory is a wall. diff --git a/shared/data/combat.js b/shared/data/combat.js index ed9f436..9b43d7f 100644 --- a/shared/data/combat.js +++ b/shared/data/combat.js @@ -63,10 +63,13 @@ export const AIR_STRIKE = { cooldownHours: 48, }; -// A city's air defense fires one volley at a hostile aircraft the moment it -// enters the city's tile. Damage scales with the building's level. +// A city's air defense watches a corridor of `rangeTiles` around it: any +// hostile aircraft or missile inside that corridor takes continuous fire, dealt +// once per in-game hour, scaled by the building's level. Aircraft no longer +// have to enter the city's own tile to be engaged. export const AIR_DEFENSE = { - damagePerLevel: 150, + damagePerLevelPerHour: 150, + rangeTiles: 2, }; // Taking a non-melee unit -- a gun, an aircraft -- is risky: four times in five diff --git a/shared/game_state/air.js b/shared/game_state/air.js index 730d30c..386437c 100644 --- a/shared/game_state/air.js +++ b/shared/game_state/air.js @@ -2,7 +2,7 @@ // 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"; +import { key, mapToLocal, flightLine, flightDistanceTiles } from "../hex.js"; import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js"; import { BUILDING_MECHANIC } from "../data/buildings.js"; import { RESOURCE_RULES } from "../data/resources.js"; @@ -43,6 +43,17 @@ export const airMethods = { return this._isAirProto(this.unitProto(unit)); }, + // A unit that flies straight over the hex grid instead of walking it: an + // aircraft, or a missile once launched. Their path is a list of world-pixel + // points, not tiles, and the browser predicts the same line. + _isFlierProto(proto) { + return !!(proto && (proto.air || proto.missile)); + }, + + _isFlier(unit) { + return this._isFlierProto(this.unitProto(unit)); + }, + // A unit that attacks from range instead of charging into melee: every // aircraft and anything with a reach beyond a single tile. A melee unit has a // range of one and stays on the ground. @@ -83,16 +94,59 @@ export const airMethods = { _pathHours(unit, path) { let total = 0; - for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]); + for (const hours of this._segmentHours(unit, path)) total += hours; return total; }, + // The per-segment hours of a route. A ground route is a list of tiles and + // each step costs its tile's travel time; a flight is a list of world-pixel + // points and each leg costs its straight-line distance. This is the one place + // the two movement models differ, so time and fuel can never drift apart. + _segmentHours(unit, path) { + const hours = []; + if (!path || path.length < 2) return hours; + if (this._isFlier(unit)) { + for (let i = 1; i < path.length; i++) { + hours.push(this._segmentFlightHours(unit, path[i - 1], path[i])); + } + return hours; + } + for (let i = 1; i < path.length; i++) hours.push(this._tileTravelHours(unit, path[i])); + return hours; + }, + + // Hours to fly the straight pixel segment `a` to `b`. + _segmentFlightHours(unit, a, b) { + const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001); + return (Math.hypot(b.x - a.x, b.y - a.y) / this._maxStepLength) / speed; + }, + + // Hours to fly straight from tile `from` to tile `to`, the measure the fuel + // and strike-reach checks use. + _flightHours(unit, from, to) { + const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001); + return flightDistanceTiles(from, to, this.topology, this._maxStepLength) / speed; + }, + + // Where a flier is right now as a world pixel, whether parked or mid-leg. + _flierPixel(unit) { + if (this._hasRoute(unit)) { + const a = unit.path[unit.pathIndex]; + const b = unit.path[unit.pathIndex + 1]; + const total = this._segmentFlightHours(unit, a, b); + const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + } + return mapToLocal(unit.coords.x, unit.coords.y); + }, + // 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); + const from = this._flierPixel(unit); + const path = flightLine(from, airport.coords, this.topology); if (path.length < 2) return false; const proto = this.unitProto(unit); const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours; @@ -161,6 +215,14 @@ export const airMethods = { this._destroyUnit(unit); } if (crashed.length > 0) this._visibilityDirty = true; + // The anti-air corridor: every hostile battery within reach fires on each + // flier aloft. A parked aircraft is safe on its own runway, but anything + // crossing defended airspace takes fire for as long as it is inside. + for (const unit of this.units.slice()) { + if (!this._isFlier(unit) || !this._hasRoute(unit)) continue; + if (this._isAirUnit(unit) && this._friendlyAirportAt(unit.coords, unit.civ)) continue; + this._airDefenseVolley(unit); + } }, // ----------------------------------------------------------- strike -- @@ -264,18 +326,27 @@ export const airMethods = { return razed; }, - // Fires the city's air defense at a hostile aircraft that has just entered its - // tile: one volley per pass, scaled by the building's level. Returns true when - // the plane is shot down. + // Every hostile anti-air battery within `rangeTiles` of the flier fires one + // in-game hour of continuous fire: damage scales with the building's level, + // and each battery that fires spends its ammunition. These are not tile-entry + // volleys any more -- a plane is under fire whenever it is inside the + // corridor, so skirting the map's tiles no longer offers cover. Returns true + // when the aircraft is shot down. _airDefenseVolley(unit) { - const city = this.cityAt(unit.coords); - if (!city || city.civ === unit.civ) return false; - const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE); - if (level <= 0) return false; - // Air defense is cheap to keep ready and costly to fire: no ammunition, - // no volley. - if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false; - const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level)); + let damage = 0; + for (const city of this.cities) { + if (city.civ === unit.civ) continue; + const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE); + if (level <= 0) continue; + if (this.topology.tileDistance(unit.coords, city.coords) > AIR_DEFENSE.rangeTiles) { + continue; + } + // Air defense is cheap to keep ready and costly to fire: no ammunition, + // no fire. + if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) continue; + damage += Math.max(1, Math.round(AIR_DEFENSE.damagePerLevelPerHour * level)); + } + if (damage <= 0) return false; unit.hp -= damage; if (unit.hp > 0) return false; unit.hp = 0; @@ -285,6 +356,25 @@ export const airMethods = { return true; }, + // Resolves one missile's warhead where it lands and consumes the rocket. The + // missile flies a straight line to its target (requestMissileStrike); this is + // the moment of detonation. + _detonateMissile(unit) { + const goal = unit.missileTarget; + const nuclear = !!unit.nuclearPayload; + unit.missileTarget = null; + unit.nuclearPayload = false; + if (goal) { + if (nuclear) this._applyNuclearBlast(goal, unit.civ); + else this._resolveConventionalMissile(unit, goal); + } + // A nuclear blast at the missile's own tile already destroyed it above. + if (this.findUnit(unit.id)) this._destroyUnit(unit); + this._invalidateUnitCaches(); + this._visibilityDirty = true; + this._emitChanged(); + }, + // Applies one ranged blow to a target unit, with the special fates of exposed // support units, and passes a tenth of the damage on to any structures on the // tile. @@ -316,10 +406,11 @@ export const airMethods = { : (proto.range || 1); }, - // Whether an aircraft has the fuel for the whole sortie: fly to `goal`, bomb, - // then run home to a friendly airport. A strike that could not make it back - // is refused up front, so a sortie never strands a plane. Air routes ignore - // the ground, so tile distance is the number of flight steps. + // Whether an aircraft has the fuel for the whole sortie: fly straight to + // `goal`, bomb, then run home to a friendly airport. A strike that could not + // make it back is refused up front, so a sortie never strands a plane. Both + // legs are measured along the straight line, so the fuel here and the hours + // the mover actually spends always agree. _canStrikeAndReturn(unit, goal, fuel = null) { const proto = this.unitProto(unit); if (!proto) return false; @@ -328,10 +419,10 @@ export const airMethods = { const remaining = fuel === null ? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours) : fuel; - const tiles = - this.topology.tileDistance(unit.coords, goal) + - this.topology.tileDistance(goal, airport.coords); - return tiles / this._unitSpeed(unit) <= remaining + 1e-9; + const hours = + this._flightHours(unit, unit.coords, goal) + + this._flightHours(unit, goal, airport.coords); + return hours <= remaining + 1e-9; }, // Whether a unit may strike `goal` right now: inside its reach and, for an diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js index 00f3661..7241ad9 100644 --- a/shared/game_state/movement.js +++ b/shared/game_state/movement.js @@ -1,7 +1,7 @@ // Movement: translating orders into paths, advancing units along them each // tick, and the travel-cost model the browser mirrors for prediction. -import { key } from "../hex.js"; +import { key, mapToLocal, localToMap, flightLine } from "../hex.js"; import { TRANSPORT_BY_ID } from "../data/improvements.js"; import { MIN_STEP_COST, @@ -188,6 +188,12 @@ export const movementMethods = { for (const unit of this.units.slice()) { if (!this.findUnit(unit.id)) continue; if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue; + // Aircraft and launched missiles fly straight lines in pixel space; the + // tile walker below cannot advance them. + if (this._isFlier(unit)) { + this._advanceFlier(unit, hours); + continue; + } if (engaged.has(unit)) continue; let hoursLeft = hours; while (hoursLeft > 0) { @@ -225,24 +231,7 @@ export const movementMethods = { this._moveUnitTo(unit, next); this._visibilityDirty = true; this._onUnitArrived(unit); - if (this._isAirUnit(unit)) { - // A hostile city's air defense gets one volley as the plane enters - // its tile; a plane shot down here never drops its bombs. - if (this._airDefenseVolley(unit)) break; - // Aircraft never join a melee. Reaching the target tile of a bomb - // run resolves the strike; the next hourly tick sends the plane - // home to re-arm. - if (unit.strikeTarget && - unit.coords.x === unit.strikeTarget.x && - unit.coords.y === unit.strikeTarget.y) { - this._resolveAirStrike(unit); - this._stopUnit(unit); - // Turn for home at once: idling on the target until the next - // hourly tick would burn fuel the round trip never budgeted for. - this._orderAirReturn(unit); - break; - } - } else if (this._hasHostileOn(unit.coords, unit.civ)) { + if (this._hasHostileOn(unit.coords, unit.civ)) { // The fight is joined: hold here and lock every participant. The // unit that charged in is the attacker; the occupants defend. this._stopUnit(unit); @@ -267,6 +256,90 @@ export const movementMethods = { } }, + // Advances a flier along its straight pixel path: aircraft and launched + // missiles. Unlike the tile walker there is no terrain, no occupancy and no + // obstacle to route around, so the leg simply runs its hours and the plane's + // tile is derived from where the pixel line is. + _advanceFlier(unit, hours) { + unit._flightHalted = false; + let hoursLeft = hours; + while (hoursLeft > 1e-9) { + if (!this._hasRoute(unit)) { + if (!unit.waypoints || unit.waypoints.length === 0) break; + if (!this._followNextWaypoint(unit)) break; + continue; + } + const a = unit.path[unit.pathIndex]; + const b = unit.path[unit.pathIndex + 1]; + const total = this._segmentFlightHours(unit, a, b); + if (!(total > 0)) { + // A zero-length leg (already at the destination) completes at once. + this._completeFlightLeg(unit); + if (!this.findUnit(unit.id) || unit._flightHalted) break; + continue; + } + const needed = total - unit.progressHours; + if (hoursLeft + 1e-9 < needed) { + unit.progressHours += hoursLeft; + hoursLeft = 0; + this._syncFlierCoords(unit); + break; + } + hoursLeft -= needed; + unit.progressHours = total; + this._completeFlightLeg(unit); + if (!this.findUnit(unit.id) || unit._flightHalted) break; + } + if (this.findUnit(unit.id) && !this._hasRoute(unit) && + (!unit.waypoints || unit.waypoints.length === 0)) { + this._stopUnit(unit); + } + }, + + // Finishes one flight leg: lands the flier on its destination tile, resolves + // a bomb run or missile warhead, and lets a scheduled move pick up its next + // destination. `_flightHalted` tells the mover to stop consuming hours, for + // example after a plane turns for home. + _completeFlightLeg(unit) { + unit.pathIndex += 1; + unit.progressHours = 0; + unit.coords = localToMap(unit.path[unit.pathIndex], this.topology); + this._visibilityDirty = true; + // A missile's flight is its whole life: the warhead goes off where it lands, + // and it must never capture the tile it flies over (a missile is not an + // aircraft, so the capture rules would otherwise treat it as a ground unit). + if (this._isMissileUnit(unit)) { + this._detonateMissile(unit); + return; + } + this._onUnitArrived(unit); + if (!this.findUnit(unit.id)) return; + // A bomb run that has reached its target bombs once, then turns for home. + if (unit.strikeTarget && + unit.coords.x === unit.strikeTarget.x && unit.coords.y === unit.strikeTarget.y) { + this._resolveAirStrike(unit); + this._stopUnit(unit); + this._orderAirReturn(unit); + unit._flightHalted = true; + } + }, + + // Keeps a flying unit's tile in step with its pixel position, so visibility, + // combat and the snapshot follow the plane between snapshot ticks. + _syncFlierCoords(unit) { + if (!this._hasRoute(unit)) return; + const a = unit.path[unit.pathIndex]; + const b = unit.path[unit.pathIndex + 1]; + const total = this._segmentFlightHours(unit, a, b); + const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0; + const pixel = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + const tile = localToMap(pixel, this.topology); + if (tile.x !== unit.coords.x || tile.y !== unit.coords.y) { + unit.coords = tile; + this._visibilityDirty = true; + } + }, + // A hostile military unit standing on the next tile is an opponent to fight, // not an obstacle to route around. _isBlockingEnemy(unit, occupant) { @@ -499,6 +572,17 @@ export const movementMethods = { ? `${unit.civ}|${unit.proto}|${start.x},${start.y}|${goal.x},${goal.y}` : null; if (cacheKey && pathCache.has(cacheKey)) return pathCache.get(cacheKey); + // A flier ignores the ground: a straight line is its whole route, so the A* + // search -- and with it terrain, zone-of-control and occupancy -- never + // runs. The route is a pair of world-pixel points, the same line the + // browser draws and predicts. + if (this._isFlier(unit)) { + const path = start.x === goal.x && start.y === goal.y + ? [mapToLocal(start.x, start.y)] + : flightLine(mapToLocal(start.x, start.y), goal, this.topology); + if (cacheKey) pathCache.set(cacheKey, path); + return path; + } const occupancy = this._buildOccupancy(unit); const targetCiv = this._targetCivFor(unit, goal, occupancy); const zoc = this._zoneOfControl(unit.civ); diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index b2f5cfc..ec1f586 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -297,14 +297,14 @@ export const serializationMethods = { return this.units.map((unit) => { const rawPath = unit.path || []; const pathIndex = unit.pathIndex; - const path = rawPath.map((coords) => [coords.x, coords.y]); - const segmentHours = []; - for (let i = 1; i < rawPath.length; i++) { - segmentHours.push(this._tileTravelHours(unit, rawPath[i])); - } + // A ground path is a list of tiles; a flight is a list of world-pixel + // points. Both ship as [x, y] pairs and get their per-segment hours from + // the one shared calculator, so the browser need not know which it is. + const path = rawPath.map((point) => [point.x, point.y]); + const segmentHours = this._segmentHours(unit, rawPath); let stepHours = 0; if (pathIndex + 1 < rawPath.length) { - stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]); + stepHours = this._segmentHours(unit, [rawPath[pathIndex], rawPath[pathIndex + 1]])[0] || 0; } return { id: unit.id, diff --git a/shared/game_state/wmd.js b/shared/game_state/wmd.js index 684b928..e48bf34 100644 --- a/shared/game_state/wmd.js +++ b/shared/game_state/wmd.js @@ -35,7 +35,9 @@ export const wmdMethods = { // Fires one or more missiles at `goal`. A conventional warhead strikes the // unit or city there; a nuclear one flattens the whole tile. `nuclear` is // honoured only for a nation that has researched the bomb, and an ICBM is - // always nuclear. Every missile is consumed, so the stack is gone afterwards. + // always nuclear. Every missile is consumed, but only when its straight-line + // flight reaches the target: `_detonateMissile` runs the warhead on arrival, + // so a missile can be shot down by air defense on the way. requestMissileStrike(unitIds, goal, { nuclear = false } = {}) { const units = this._unitsForOrder(unitIds).filter((unit) => this._isMissileUnit(unit)); if (units.length === 0) return false; @@ -55,9 +57,15 @@ export const wmdMethods = { if (units.some((unit) => !this._missileInRange(unit, goal))) return false; this._declareWar(civ, target ? target.civ : city.civ); for (const unit of units) { - if (useNuclear) this._applyNuclearBlast(goal, civ); - else this._resolveConventionalMissile(unit, goal); - this._destroyUnit(unit); + const path = this.findPath(unit, unit.coords, goal); + if (path.length < 2) return false; + unit.path = path; + unit.pathIndex = 0; + unit.progressHours = 0; + unit.moveSpeed = null; + unit.waypoints = []; + unit.missileTarget = { x: goal.x, y: goal.y }; + unit.nuclearPayload = useNuclear; } this._invalidateUnitCaches(); this._visibilityDirty = true; diff --git a/shared/hex.js b/shared/hex.js index 47f1b26..5f2f333 100644 --- a/shared/hex.js +++ b/shared/hex.js @@ -55,6 +55,56 @@ export function mapToLocal(x, y) { return { x: COL_STEP * x, y: HEX_H * (y + 0.5 * parity(x)) }; } +// The tile whose centre is nearest a world pixel point: the inverse of +// `mapToLocal`. Three candidate columns are tested (the point can sit just past +// a column boundary); the winning cell is folded back into the map when the +// world wraps. +export function localToMap(point, topology = null) { + const column = Math.round(point.x / COL_STEP); + let best = null; + let bestDistance = Infinity; + for (let x = column - 1; x <= column + 1; x++) { + const originY = HEX_H * 0.5 * parity(x); + const y = Math.round((point.y - originY) / HEX_H); + const center = mapToLocal(x, y); + const distance = (center.x - point.x) ** 2 + (center.y - point.y) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y }; + } + } + return topology ? topology.wrapCoords(best.x, best.y) : best; +} + +// The copy of a world pixel point nearest `reference`, a whole number of map +// periods away. A flat map returns the point unchanged. Mirrors the browser's +// `_nearestCopy`, so a flight across the seam is drawn where the server flies. +export function nearestCopyPoint(point, reference, topology) { + const period = topology && topology.periodPixels ? topology.periodPixels() : 0; + if (!period) return { x: point.x, y: point.y }; + const k = Math.round((reference.x - point.x) / period); + return { x: point.x + k * period, y: point.y }; +} + +// A straight flight: the world-pixel points from `fromPoint` to the nearest +// wrapped copy of the tile `toCoords`. `fromPoint` is already a pixel, so a +// plane mid-flight can be redirected without snapping back to a tile centre. +export function flightLine(fromPoint, toCoords, topology) { + const to = mapToLocal(toCoords.x, toCoords.y); + return [ + { x: fromPoint.x, y: fromPoint.y }, + nearestCopyPoint(to, fromPoint, topology), + ]; +} + +// Straight-line distance between two tile centres in tile units, taking the +// shortest wrapped route and scaling by the longest hex step. This is the +// aircraft mile: fuel and mission reach are both measured along it. +export function flightDistanceTiles(from, to, topology, maxStepLength) { + const delta = topology.pixelDelta(from, to); + return Math.hypot(delta.x, delta.y) / (maxStepLength || 1); +} + export class MapTopology { constructor(size, cylindrical) { this.width = size.x; diff --git a/tests/air_movement_test.js b/tests/air_movement_test.js new file mode 100644 index 0000000..2bf619a --- /dev/null +++ b/tests/air_movement_test.js @@ -0,0 +1,126 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState } from "./framework/helpers.js"; +import { mapToLocal, flightLine, flightDistanceTiles, key } from "../shared/hex.js"; +import { AIR_DEFENSE } from "../shared/data/combat.js"; + +const BIG_BUDGET = 1.0e15; + +function buildingIndex(state, id) { + return state.protoBuildings.findIndex((building) => building.id === id); +} + +function protoOf(state, id) { + return state.protoUnits.find((unit) => unit.id === id); +} + +function giveBuilding(state, city, id, level = 1) { + city.buildings[buildingIndex(state, id)] = level; + state._gdpPerCapitaCache.clear(); +} + +// Any tile exactly `distance` hex steps from `center`. +function tileAtDistance(state, center, distance) { + const seen = new Set([key(center.x, center.y)]); + let frontier = [{ x: center.x, y: center.y }]; + for (let step = 0; step < distance; step++) { + const next = []; + for (const cell of frontier) { + for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { + const k = key(neighbour.x, neighbour.y); + if (seen.has(k) || !state.tiles[k]) continue; + seen.add(k); + next.push(neighbour); + } + } + frontier = next; + } + return frontier[0] || null; +} + +export class AirMovementTest extends TestCase { + test_a_flight_is_a_straight_two_point_line() { + const state = smallState(); + const airport = buildingIndex(state, "airport"); + const cities = state.cities.filter((city) => city.civ === 0); + for (const city of cities) city.buildings[airport] = 1; + const [home, away] = cities; + const jet = protoOf(state, "jet_fighter"); + const unit = state._spawnUnit(home.coords, 0, jet, home.id); + + const path = state.findPath(unit, home.coords, away.coords); + this.assertEqual(path.length, 2, "a flight is two world-pixel points"); + const expected = flightLine( + mapToLocal(home.coords.x, home.coords.y), + away.coords, + state.topology + ); + this.assertApprox(path[0].x, expected[0].x, 1e-9, "the flight starts at the tile centre"); + this.assertApprox(path[1].x, expected[1].x, 1e-9); + this.assertApprox(path[1].y, expected[1].y, 1e-9); + + // The one segment's hours are the straight-line distance over speed, so + // fuel and flight time can never disagree. + const hours = state._pathHours(unit, path); + const expectedHours = + flightDistanceTiles(home.coords, away.coords, state.topology, state._maxStepLength) / + jet.speed; + this.assertApprox(hours, expectedHours, 1e-9, "time follows the straight line"); + const segmentHours = state._segmentHours(unit, path); + this.assertEqual(segmentHours.length, 1, "one leg, one segment"); + this.assertApprox(segmentHours[0], expectedHours, 1e-9); + } + + test_a_ferry_flight_only_needs_a_runway_at_the_destination() { + const state = smallState(); + state.budgets.set(0, BIG_BUDGET); + const airport = buildingIndex(state, "airport"); + const cities = state.cities.filter((city) => city.civ === 0); + for (const city of cities) city.buildings[airport] = 1; + const [home, away] = cities; + const jet = protoOf(state, "jet_fighter"); + const unit = state._spawnUnit(home.coords, 0, jet, home.id); + + this.assertTrue( + state.requestGroupMove([unit.id], away.coords), + "a ferry to a friendly airport is accepted" + ); + this.assertEqual(unit.path.length, 2, "the ferry is a straight line"); + // The plane reaches the far airport and lands, refuelling there. + for (let hour = 0; hour < 40 && unit.coords.x !== away.coords.x; hour++) { + state.advanceHour(); + } + this.assertEqual(unit.coords.x, away.coords.x, "it crossed in a straight line"); + this.assertEqual(unit.coords.y, away.coords.y); + this.assertFalse(unit.airborne, "and landed"); + } + + test_air_defense_fires_across_its_corridor() { + const state = smallState(); + const city = state.cities.find((entry) => entry.civ === 1); + giveBuilding(state, city, "aa_building", 1); + const jet = protoOf(state, "jet_fighter"); + const unit = state._spawnUnit(state.landCells[0], 0, jet); + + // Inside the corridor but not standing on the city tile: the old tile-entry + // volley would have missed it. + const near = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles); + this.assertNotNull(near, "there is a tile inside the corridor"); + unit.coords = { x: near.x, y: near.y }; + this.assertFalse( + unit.coords.x === city.coords.x && unit.coords.y === city.coords.y, + "the plane is not over the city" + ); + const before = unit.hp; + state._airDefenseVolley(unit); + this.assertLess(unit.hp, before, "a plane inside the corridor takes fire"); + + // One step further out is beyond the battery's reach. + const far = tileAtDistance(state, city.coords, AIR_DEFENSE.rangeTiles + 1); + this.assertNotNull(far, "there is a tile outside the corridor"); + unit.hp = unit.maxHp; + unit.coords = { x: far.x, y: far.y }; + const safe = unit.hp; + state._airDefenseVolley(unit); + this.assertEqual(unit.hp, safe, "a plane outside the corridor is untouched"); + } +} diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 76774d8..9ed0e19 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -556,6 +556,49 @@ export class GameScreenTest extends TestCase { } } + async test_air_strike_on_a_fogged_neutral_city_asks_to_declare_war() { + const env = await setupDom(); + try { + const network = stubNetwork(); + const screen = new GameScreen(network, makeConfig()); + screen.enter(); + const state = smallState(); + const home = state.cities.find((c) => c.civ === 0); + grantBuilding(state, home, "airport"); + const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); + const jet = state._spawnUnit(home.coords, 0, jetProto, home.id); + const city = state.cities.find((c) => c.civ === 1); + // Defenceless and under the fog: no garrison is visible, but a known city + // tile is still a legitimate air-strike target. + for (const unit of state.unitsAt(city.coords).slice()) state._destroyUnit(unit); + screen.onState(state.snapshot(0)); + screen._selectUnits([jet.id]); + this.assertTrue( + screen._isEnemyCityAt({ x: city.coords.x, y: city.coords.y }), + "the fogged city is a target" + ); + screen._toggleTargeting(); + this.assertNotNull(screen._targeting, "targeting is armed"); + screen._chooseTarget({ x: city.coords.x, y: city.coords.y }); + for (let i = 0; i < 100 && env.$("#confirm-title").text() !== "Declare war?"; i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + this.assertEqual( + env.$("#confirm-title").text(), + "Declare war?", + "attacking a neutral city asks first, exactly like marching in" + ); + env.$("#confirm-ok").trigger("click"); + const order = network.orders.find((entry) => entry.type === "attack"); + this.assertNotNull(order, "confirming sends the air strike"); + this.assertEqual(order.coords[0], city.coords.x); + this.assertEqual(order.coords[1], city.coords.y); + screen.leave(); + } finally { + teardownDom(env); + } + } + async test_air_strike_beyond_the_round_trip_is_refused() { const env = await setupDom(); try { diff --git a/tests/game_server_test.js b/tests/game_server_test.js index f5d0352..b6f3c45 100644 --- a/tests/game_server_test.js +++ b/tests/game_server_test.js @@ -221,7 +221,13 @@ export class GameServerTest extends TestCase { server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }), "the server accepts the missile order" ); - this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched"); + // The rocket flies a straight line to the target and is consumed when the + // warhead goes off, not the moment it is launched. + for (let hour = 0; hour < 200 && state.findUnit(missile.id); hour++) { + server.advanceMovement(1.0); + server.tickHour(); + } + this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival"); this.assertNull(state.findUnit(enemy.id), "and the target is destroyed"); } diff --git a/tests/hex_test.js b/tests/hex_test.js index 9cab9f2..5926415 100644 --- a/tests/hex_test.js +++ b/tests/hex_test.js @@ -1,5 +1,16 @@ import { TestCase } from "./framework/test_case.js"; -import { MapTopology, mapToLocal, key, parseKey, COL_STEP, HEX_H } from "../shared/hex.js"; +import { + MapTopology, + mapToLocal, + localToMap, + nearestCopyPoint, + flightLine, + flightDistanceTiles, + key, + parseKey, + COL_STEP, + HEX_H, +} from "../shared/hex.js"; export class HexTest extends TestCase { flat() { @@ -68,6 +79,63 @@ export class HexTest extends TestCase { ); } + test_local_to_map_inverts_map_to_local() { + const flat = this.flat(); + for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -7, y: 2 }]) { + this.assertEqual(localToMap(mapToLocal(coords.x, coords.y), flat), coords); + } + // A pixel a few units off a centre still resolves to that tile. + const centre = mapToLocal(2, 3); + this.assertEqual(localToMap({ x: centre.x + 4, y: centre.y - 3 }, flat), { x: 2, y: 3 }); + // On a cylinder the resolved tile folds back into range. + this.assertEqual(localToMap(mapToLocal(5, 0), this.cylinder()), { x: -5, y: 0 }); + } + + test_nearest_copy_point_folds_by_whole_periods() { + const point = mapToLocal(0, 0); + const period = this.cylinder().periodPixels(); + const reference = { x: point.x - period * 1.2, y: 0 }; + const copy = nearestCopyPoint(point, reference, this.cylinder()); + this.assertApprox(copy.x, point.x - period, 1e-9); + this.assertEqual(copy.y, point.y); + // A flat map returns the point untouched. + this.assertEqual(nearestCopyPoint(point, reference, this.flat()), point); + } + + test_flight_line_takes_the_shortest_wrapped_copy() { + const topology = this.cylinder(); + const from = mapToLocal(-5, 0); + // -5 and 4 are neighbours across the seam, one map period apart. + const line = flightLine(from, { x: 4, y: 0 }, topology); + this.assertEqual(line.length, 2); + this.assertEqual(line[0], from); + // Adjacent columns are offset by half a hex, so a neighbour hop is a + // diagonal of hypot(COL_STEP, HEX_H/2), not a bare COL_STEP. + const hop = Math.hypot(COL_STEP, HEX_H / 2); + this.assertApprox( + Math.hypot(line[1].x - line[0].x, line[1].y - line[0].y), + hop, + 1e-6, + "the flight leaves one edge and re-enters the other" + ); + } + + test_flight_distance_measures_tiles_along_the_line() { + const maxStep = HEX_H; + const hop = Math.hypot(COL_STEP, HEX_H / 2); + this.assertApprox( + flightDistanceTiles({ x: 0, y: 0 }, { x: 1, y: 0 }, this.flat(), maxStep), + hop / maxStep, + 1e-9 + ); + // Across the seam on a cylinder it is the short hop, not the long way. + this.assertApprox( + flightDistanceTiles({ x: -5, y: 0 }, { x: 4, y: 0 }, this.cylinder(), maxStep), + hop / maxStep, + 1e-9 + ); + } + test_key_and_parse_key_round_trip_and_odd_column_offset() { for (const coords of [{ x: 0, y: 0 }, { x: 3, y: -4 }, { x: -12, y: 7 }]) { this.assertEqual(parseKey(key(coords.x, coords.y)), coords); diff --git a/tests/map_view_input_test.js b/tests/map_view_input_test.js index 6a8b5e2..34b3947 100644 --- a/tests/map_view_input_test.js +++ b/tests/map_view_input_test.js @@ -387,6 +387,30 @@ export class MapViewInputTest extends MapViewFixture { } } + async test_a_unit_icon_does_not_steal_a_target_click() { + const state = smallState(); + const attacker = state.units.find((u) => u.civ === 0); + const goal = adjacentLand(state, attacker); + const defender = state._spawnUnit(goal, 1, state.protoUnits[0]); + const { env, map } = await this.build(state); + try { + let selected = -1; + let reachedMap = false; + map.onUnitSelected = (id) => { selected = id; }; + map.$viewport.on("mousedown", () => { reachedMap = true; }); + map.beginTargeting({ unitIds: [attacker.id], origin: attacker.coords, range: 3 }); + const view = map._unitViews.get(defender.id); + this.assertNotNull(view, "the defender has a view"); + // Mousedown on the enemy icon while picking a target must fall through to + // the map, not select the enemy (which would clear the attacker). + view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true })); + this.assertEqual(selected, -1, "the target icon does not steal the click"); + this.assertTrue(reachedMap, "the click reaches the map so the target is chosen"); + } finally { + teardownDom(env); + } + } + async test_ctrl_right_click_schedules_a_destination() { const state = smallState(); const unit = state.units.find((u) => u.civ === 0); diff --git a/tests/map_view_test.js b/tests/map_view_test.js index 4da9535..fe7a069 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -203,6 +203,33 @@ export class MapViewRenderTest extends MapViewFixture { } } + async test_an_aircraft_is_predicted_along_the_server_flight_line() { + const state = smallState(); + const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); + const cities = state.cities.filter((c) => c.civ === 0); + for (const city of cities) city.buildings[airport] = 1; + const [home, away] = cities; + const jet = state.protoUnits.find((p) => p.id === "jet_fighter"); + const unit = state._spawnUnit(home.coords, 0, jet, home.id); + const { env, map, snapshot } = await this.build(state); + try { + const data = snapshot.units.find((u) => u.id === unit.id); + const route = await map.planMove(data, away.coords); + this.assertNotNull(route, "a flight was planned"); + this.assertEqual(route.length, 2, "the flight is a straight two-point line"); + // The browser's flight time matches the server's for the same leg. + const clientHours = map._flightHours(jet, route[0], route[1], null); + const serverHours = state._pathHours(unit, state.findPath(unit, home.coords, away.coords)); + this.assertApprox(clientHours, serverHours, 1e-9, "browser and server agree on flight time"); + await map.predictMove(data, away.coords, null, route); + const motion = map._unitMotion.get(unit.id); + this.assertTrue(motion.flight, "the motion is flagged as a flight"); + this.assertEqual(motion.path.length, 2); + } finally { + teardownDom(env); + } + } + async test_plan_move_reuses_a_shared_route() { const state = smallState(); const { env, map, snapshot } = await this.build(state); @@ -250,6 +277,28 @@ export class MapViewRenderTest extends MapViewFixture { } } + async test_an_air_strike_line_is_straight_not_an_arc() { + const state = smallState(); + const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); + const jet = state._spawnUnit(spot, 0, state.protoUnits.find((p) => p.id === "jet_fighter")); + const goal = state.topology + .neighbours(spot.x, spot.y) + .find((n) => state._isLand(n) && !state.unitAt(n)); + jet.strikeTarget = { x: goal.x, y: goal.y }; + const { env, map } = await this.build(state); + try { + map.setSelectedUnit(jet.id); + map.animate(0); + const d = env.$("#layer-targets .target-line").get(0).getAttribute("d"); + this.assertTrue(d.includes(" L "), "the air strike is a straight segment"); + this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft"); + this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell still travels the line"); + this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed"); + } finally { + teardownDom(env); + } + } + async test_draws_a_bombardment_line_and_ring_always_visible() { const state = smallState(); const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c)); diff --git a/tests/wmd_test.js b/tests/wmd_test.js index dfcb21f..dadbde4 100644 --- a/tests/wmd_test.js +++ b/tests/wmd_test.js @@ -59,6 +59,12 @@ function freeLand(state) { return null; } +// A missile is no longer consumed the instant it is fired: it flies a straight +// line to its target and only then detonates. Fly the clock until it is gone. +function flyUntilGone(state, id, maxHours = 200) { + for (let hour = 0; hour < maxHours && state.findUnit(id); hour++) state.advanceHour(); +} + export class WmdTest extends TestCase { test_nuclear_weapons_research_needs_an_enrichment_centre() { const state = smallState(); @@ -89,7 +95,9 @@ export class WmdTest extends TestCase { state.requestMissileStrike([missile.id], targetCoords), "the strike is accepted" ); - this.assertNull(state.findUnit(missile.id), "the rocket is consumed"); + this.assertNotNull(state.findUnit(missile.id), "the rocket is in flight"); + flyUntilGone(state, missile.id); + this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival"); this.assertNull(state.findUnit(enemy.id), "the infantry is destroyed by the blast"); } @@ -112,7 +120,8 @@ export class WmdTest extends TestCase { state.requestMissileStrike([icbm.id], far), "an ICBM reaches any tile" ); - this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed"); + flyUntilGone(state, icbm.id); + this.assertNull(state.findUnit(icbm.id), "the ICBM is consumed on arrival"); this.assertNull(state.findUnit(enemy.id), "the target is destroyed"); } @@ -136,7 +145,8 @@ export class WmdTest extends TestCase { state.requestMissileStrike([missile.id], targetCoords, { nuclear: true }), "with the bomb the strike goes ahead" ); - this.assertNull(state.findUnit(missile.id), "the missile was fired"); + flyUntilGone(state, missile.id); + this.assertNull(state.findUnit(missile.id), "the missile flew and detonated"); this.assertNull(state.findUnit(enemy.id), "the garrison is wiped out"); }