From 350f08be2f4f5a8034befc2b0ed049e0220a90d9 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sun, 13 Feb 2022 14:22:05 -0800 Subject: [PATCH] Add FastAPI interface between the game and map. A possible explanation for the infrequent CTDs we've been seeing since adding fast forward is that QWebChannel doesn't keep a reference to the python objects that it passes to js, so if the object is GC'd before the front end is done with it, it crashes. We don't really like QWebChannel anyway, so this begins replacing that with FastAPI. --- game/ato/flight.py | 4 + game/server/__init__.py | 2 + game/server/app.py | 6 + game/server/debuggeometries/__init__.py | 1 + game/server/debuggeometries/models.py | 126 ++++++++++ game/server/debuggeometries/routes.py | 38 +++ game/server/dependencies.py | 15 ++ .../shapelyutil.py => game/server/leaflet.py | 6 +- game/server/server.py | 35 +++ game/server/settings.py | 28 +++ mypy.ini | 3 + qt_ui/main.py | 7 +- qt_ui/widgets/map/QLiberationMap.py | 6 + qt_ui/widgets/map/model/aircombatjs.py | 3 +- qt_ui/widgets/map/model/config.py | 3 - qt_ui/widgets/map/model/controlpointjs.py | 2 +- qt_ui/widgets/map/model/flightjs.py | 3 +- qt_ui/widgets/map/model/frontlinejs.py | 2 +- qt_ui/widgets/map/model/groundobjectjs.py | 2 +- qt_ui/widgets/map/model/holdzonesjs.py | 60 ----- qt_ui/widgets/map/model/ipzonesjs.py | 37 --- qt_ui/widgets/map/model/joinzonesjs.py | 58 ----- qt_ui/widgets/map/model/leaflet.py | 4 - qt_ui/widgets/map/model/mapmodel.py | 49 +--- qt_ui/widgets/map/model/mapzonesjs.py | 3 +- qt_ui/widgets/map/model/navmeshjs.py | 2 +- qt_ui/widgets/map/model/navmeshpolyjs.py | 3 +- qt_ui/widgets/map/model/supplyroutejs.py | 2 +- qt_ui/widgets/map/model/threatzonesjs.py | 3 +- qt_ui/widgets/map/model/unculledzonejs.py | 2 +- qt_ui/widgets/map/model/waypointjs.py | 2 +- requirements.txt | 12 + resources/ui/map/map.js | 220 ++++++++++-------- 33 files changed, 419 insertions(+), 330 deletions(-) create mode 100644 game/server/__init__.py create mode 100644 game/server/app.py create mode 100644 game/server/debuggeometries/__init__.py create mode 100644 game/server/debuggeometries/models.py create mode 100644 game/server/debuggeometries/routes.py create mode 100644 game/server/dependencies.py rename qt_ui/widgets/map/model/shapelyutil.py => game/server/leaflet.py (93%) create mode 100644 game/server/server.py create mode 100644 game/server/settings.py delete mode 100644 qt_ui/widgets/map/model/config.py delete mode 100644 qt_ui/widgets/map/model/holdzonesjs.py delete mode 100644 qt_ui/widgets/map/model/ipzonesjs.py delete mode 100644 qt_ui/widgets/map/model/joinzonesjs.py delete mode 100644 qt_ui/widgets/map/model/leaflet.py diff --git a/game/ato/flight.py b/game/ato/flight.py index 823e5cf9..8c352691 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -1,5 +1,6 @@ from __future__ import annotations +import uuid from datetime import datetime, timedelta from typing import Any, List, Optional, TYPE_CHECKING @@ -36,6 +37,7 @@ class Flight: cargo: Optional[TransferOrder] = None, roster: Optional[FlightRoster] = None, ) -> None: + self.id = uuid.uuid4() self.package = package self.country = country self.squadron = squadron @@ -91,6 +93,8 @@ class Flight: state["state"] = Uninitialized(self, state["squadron"].settings) if "props" not in state: state["props"] = {} + if "id" not in state: + state["id"] = uuid.uuid4() self.__dict__.update(state) @property diff --git a/game/server/__init__.py b/game/server/__init__.py new file mode 100644 index 00000000..6c5d5e6b --- /dev/null +++ b/game/server/__init__.py @@ -0,0 +1,2 @@ +from .dependencies import GameContext +from .server import Server diff --git a/game/server/app.py b/game/server/app.py new file mode 100644 index 00000000..be862def --- /dev/null +++ b/game/server/app.py @@ -0,0 +1,6 @@ +from fastapi import FastAPI + +from . import debuggeometries + +app = FastAPI() +app.include_router(debuggeometries.router) diff --git a/game/server/debuggeometries/__init__.py b/game/server/debuggeometries/__init__.py new file mode 100644 index 00000000..3a27ef1c --- /dev/null +++ b/game/server/debuggeometries/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/game/server/debuggeometries/models.py b/game/server/debuggeometries/models.py new file mode 100644 index 00000000..7ee33322 --- /dev/null +++ b/game/server/debuggeometries/models.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + +from game import Game +from game.ato import Flight +from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry +from ..leaflet import LeafletPoly, ShapelyUtil + + +class HoldZonesJs(BaseModel): + home_bubble: LeafletPoly = Field(alias="homeBubble") + target_bubble: LeafletPoly = Field(alias="targetBubble") + join_bubble: LeafletPoly = Field(alias="joinBubble") + excluded_zones: list[LeafletPoly] = Field(alias="excludedZones") + permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones") + preferred_lines: list[LeafletPoly] = Field(alias="preferredLines") + + @classmethod + def empty(cls) -> HoldZonesJs: + return HoldZonesJs( + homeBubble=[], + targetBubble=[], + joinBubble=[], + excludedZones=[], + permissibleZones=[], + preferredLines=[], + ) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs: + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return HoldZonesJs.empty() + ip = flight.package.waypoints.ingress + join = flight.package.waypoints.join + geometry = HoldZoneGeometry( + target.position, home.position, ip, join, game.blue, game.theater + ) + return HoldZonesJs( + homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), + targetBubble=ShapelyUtil.poly_to_leaflet( + geometry.target_bubble, game.theater + ), + joinBubble=ShapelyUtil.poly_to_leaflet(geometry.join_bubble, game.theater), + excludedZones=ShapelyUtil.polys_to_leaflet( + geometry.excluded_zones, game.theater + ), + permissibleZones=ShapelyUtil.polys_to_leaflet( + geometry.permissible_zones, game.theater + ), + preferredLines=ShapelyUtil.lines_to_leaflet( + geometry.preferred_lines, game.theater + ), + ) + + +class IpZonesJs(BaseModel): + home_bubble: LeafletPoly = Field(alias="homeBubble") + ipBubble: LeafletPoly = Field(alias="ipBubble") + permissibleZone: LeafletPoly = Field(alias="permissibleZone") + safeZones: list[LeafletPoly] = Field(alias="safeZones") + + @classmethod + def empty(cls) -> IpZonesJs: + return IpZonesJs(homeBubble=[], ipBubble=[], permissibleZone=[], safeZones=[]) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: + target = flight.package.target + home = flight.departure + geometry = IpZoneGeometry(target.position, home.position, game.blue) + return IpZonesJs( + homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), + ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater), + permissibleZone=ShapelyUtil.poly_to_leaflet( + geometry.permissible_zone, game.theater + ), + safeZones=ShapelyUtil.polys_to_leaflet(geometry.safe_zones, game.theater), + ) + + +class JoinZonesJs(BaseModel): + home_bubble: LeafletPoly = Field(alias="homeBubble") + target_bubble: LeafletPoly = Field(alias="targetBubble") + ip_bubble: LeafletPoly = Field(alias="ipBubble") + excluded_zones: list[LeafletPoly] = Field(alias="excludedZones") + permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones") + preferred_lines: list[LeafletPoly] = Field(alias="preferredLines") + + @classmethod + def empty(cls) -> JoinZonesJs: + return JoinZonesJs( + homeBubble=[], + targetBubble=[], + ipBubble=[], + excludedZones=[], + permissibleZones=[], + preferredLines=[], + ) + + @classmethod + def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: + target = flight.package.target + home = flight.departure + if flight.package.waypoints is None: + return JoinZonesJs.empty() + ip = flight.package.waypoints.ingress + geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue) + return JoinZonesJs( + homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), + targetBubble=ShapelyUtil.poly_to_leaflet( + geometry.target_bubble, game.theater + ), + ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater), + excludedZones=ShapelyUtil.polys_to_leaflet( + geometry.excluded_zones, game.theater + ), + permissibleZones=ShapelyUtil.polys_to_leaflet( + geometry.permissible_zones, game.theater + ), + preferredLines=ShapelyUtil.lines_to_leaflet( + geometry.preferred_lines, game.theater + ), + ) diff --git a/game/server/debuggeometries/routes.py b/game/server/debuggeometries/routes.py new file mode 100644 index 00000000..f6900368 --- /dev/null +++ b/game/server/debuggeometries/routes.py @@ -0,0 +1,38 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends + +from game import Game +from game.ato import Flight +from game.server import GameContext +from .models import HoldZonesJs, IpZonesJs, JoinZonesJs + +router = APIRouter(prefix="/debug/waypoint-geometries") + + +# TODO: Maintain map of UUID -> Flight in Game. +def find_flight(game: Game, flight_id: UUID) -> Flight: + for coalition in game.coalitions: + for package in coalition.ato.packages: + for flight in package.flights: + if flight.id == flight_id: + return flight + raise KeyError(f"No flight found with ID {flight_id}") + + +@router.get("/hold/{flight_id}") +def hold_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> HoldZonesJs: + flight = find_flight(game, flight_id) + return HoldZonesJs.for_flight(flight, game) + + +@router.get("/ip/{flight_id}") +def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> IpZonesJs: + flight = find_flight(game, flight_id) + return IpZonesJs.for_flight(flight, game) + + +@router.get("/join/{flight_id}") +def join_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> JoinZonesJs: + flight = find_flight(game, flight_id) + return JoinZonesJs.for_flight(flight, game) diff --git a/game/server/dependencies.py b/game/server/dependencies.py new file mode 100644 index 00000000..eb69e0cb --- /dev/null +++ b/game/server/dependencies.py @@ -0,0 +1,15 @@ +from game import Game + + +class GameContext: + _game: Game | None + + @classmethod + def set(cls, game: Game | None) -> None: + cls._game = game + + @classmethod + def get(cls) -> Game: + if cls._game is None: + raise RuntimeError("GameContext has no Game set") + return cls._game diff --git a/qt_ui/widgets/map/model/shapelyutil.py b/game/server/leaflet.py similarity index 93% rename from qt_ui/widgets/map/model/shapelyutil.py rename to game/server/leaflet.py index 958e27fd..1915790b 100644 --- a/qt_ui/widgets/map/model/shapelyutil.py +++ b/game/server/leaflet.py @@ -1,10 +1,14 @@ +from __future__ import annotations + from typing import Union from dcs import Point from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon from game.theater import ConflictTheater -from .leaflet import LeafletLatLon, LeafletPoly + +LeafletLatLon = list[float] +LeafletPoly = list[LeafletLatLon] class ShapelyUtil: diff --git a/game/server/server.py b/game/server/server.py new file mode 100644 index 00000000..bfc89483 --- /dev/null +++ b/game/server/server.py @@ -0,0 +1,35 @@ +import time +from collections.abc import Iterator +from contextlib import contextmanager +from threading import Thread + +import uvicorn +from uvicorn import Config + +from game.server.settings import ServerSettings + + +class Server(uvicorn.Server): + def __init__(self) -> None: + super().__init__( + Config( + "game.server.app:app", + host=ServerSettings.get().server_bind_address, + port=ServerSettings.get().server_port, + log_level="info", + ) + ) + + @contextmanager + def run_in_thread(self) -> Iterator[None]: + # This relies on undocumented behavior, but it is what the developer recommends: + # https://github.com/encode/uvicorn/issues/742 + thread = Thread(target=self.run) + thread.start() + try: + while not self.started: + time.sleep(1e-3) + yield + finally: + self.should_exit = True + thread.join() diff --git a/game/server/settings.py b/game/server/settings.py new file mode 100644 index 00000000..93f1277a --- /dev/null +++ b/game/server/settings.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from functools import lru_cache + +from pydantic import BaseSettings + + +class ServerSettings(BaseSettings): + """Settings controlling server behavior. + + The values listed here will be automatically modified based on the environment. e.g. + running with SERVER_BIND_ADDRESS=0.0.0.0 will cause the server to bind to all + interfaces. + + https://fastapi.tiangolo.com/advanced/settings + """ + + # WARNING: Be extremely cautious exposing the server to other machines. As there is + # no client/server workflow yet, security has not been a focus. + server_bind_address: str = "::1" + + # If you for some reason change the port, you'll need to also update map.js. + server_port: int = 5000 + + @classmethod + @lru_cache + def get(cls) -> ServerSettings: + return cls() diff --git a/mypy.ini b/mypy.ini index da81307c..b1cafe55 100644 --- a/mypy.ini +++ b/mypy.ini @@ -21,4 +21,7 @@ ignore_missing_imports = True [mypy-shapely.*] # https://github.com/Toblerity/Shapely/issues/721 +ignore_missing_imports = True + +[mypy-uvicorn.*] ignore_missing_imports = True \ No newline at end of file diff --git a/qt_ui/main.py b/qt_ui/main.py index 9ef9dfc5..e777a974 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -9,7 +9,7 @@ from typing import Optional from PySide2 import QtWidgets from PySide2.QtCore import Qt from PySide2.QtGui import QPixmap -from PySide2.QtWidgets import QApplication, QSplashScreen, QCheckBox +from PySide2.QtWidgets import QApplication, QCheckBox, QSplashScreen from dcs.payloads import PayloadDirectories from game import Game, VERSION, persistency @@ -18,6 +18,7 @@ from game.data.weapons import Pylon, Weapon, WeaponGroup from game.db import FACTIONS from game.dcs.aircrafttype import AircraftType from game.profiling import logged_duration +from game.server import GameContext, Server from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from qt_ui import ( @@ -135,6 +136,7 @@ def run_ui(game: Optional[Game]) -> None: # Apply CSS (need works) GameUpdateSignal() + GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set) # Start window window = QLiberationWindow(game) @@ -333,7 +335,8 @@ def main(): lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft)) return - run_ui(game) + with Server().run_in_thread(): + run_ui(game) if __name__ == "__main__": diff --git a/qt_ui/widgets/map/QLiberationMap.py b/qt_ui/widgets/map/QLiberationMap.py index b7b90faa..b57fb286 100644 --- a/qt_ui/widgets/map/QLiberationMap.py +++ b/qt_ui/widgets/map/QLiberationMap.py @@ -10,6 +10,7 @@ from PySide2.QtCore import QUrl from PySide2.QtWebChannel import QWebChannel from PySide2.QtWebEngineWidgets import ( QWebEnginePage, + QWebEngineSettings, QWebEngineView, ) @@ -48,6 +49,11 @@ class QLiberationMap(QWebEngineView): self.channel.registerObject("game", self.map_model) self.page = LoggingWebPage(self) + # Required to allow "cross-origin" access from file:// scoped canvas.html to the + # localhost HTTP backend. + self.page.settings().setAttribute( + QWebEngineSettings.LocalContentCanAccessRemoteUrls, True + ) self.page.setWebChannel(self.channel) self.page.load( QUrl.fromLocalFile(str(Path("resources/ui/map/canvas.html").resolve())) diff --git a/qt_ui/widgets/map/model/aircombatjs.py b/qt_ui/widgets/map/model/aircombatjs.py index 38c48186..f68a0ac9 100644 --- a/qt_ui/widgets/map/model/aircombatjs.py +++ b/qt_ui/widgets/map/model/aircombatjs.py @@ -1,9 +1,8 @@ from PySide2.QtCore import Property, QObject, Signal +from game.server.leaflet import LeafletPoly, ShapelyUtil from game.sim.combat.aircombat import AirCombat from game.theater import ConflictTheater -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil class AirCombatJs(QObject): diff --git a/qt_ui/widgets/map/model/config.py b/qt_ui/widgets/map/model/config.py deleted file mode 100644 index 80e3d84a..00000000 --- a/qt_ui/widgets/map/model/config.py +++ /dev/null @@ -1,3 +0,0 @@ -# Set to True to enable computing expensive debugging information. At the time of -# writing this only controls computing the waypoint placement zones. -ENABLE_EXPENSIVE_DEBUG_TOOLS = False diff --git a/qt_ui/widgets/map/model/controlpointjs.py b/qt_ui/widgets/map/model/controlpointjs.py index c132d6d8..115428fc 100644 --- a/qt_ui/widgets/map/model/controlpointjs.py +++ b/qt_ui/widgets/map/model/controlpointjs.py @@ -5,12 +5,12 @@ from typing import Optional from PySide2.QtCore import Property, QObject, Signal, Slot from dcs import Point +from game.server.leaflet import LeafletLatLon from game.theater import ConflictTheater, ControlPoint, ControlPointStatus, LatLon from game.utils import meters, nautical_miles from qt_ui.dialogs import Dialog from qt_ui.models import GameModel from qt_ui.windows.basemenu.QBaseMenu2 import QBaseMenu2 -from .leaflet import LeafletLatLon MAX_SHIP_DISTANCE = nautical_miles(80) diff --git a/qt_ui/widgets/map/model/flightjs.py b/qt_ui/widgets/map/model/flightjs.py index a9cb9fc1..6cf87e1a 100644 --- a/qt_ui/widgets/map/model/flightjs.py +++ b/qt_ui/widgets/map/model/flightjs.py @@ -8,12 +8,11 @@ from shapely.geometry import LineString, Point as ShapelyPoint from game.ato import Flight, FlightWaypoint from game.ato.flightstate import InFlight from game.ato.flightwaypointtype import FlightWaypointType +from game.server.leaflet import LeafletLatLon, LeafletPoly, ShapelyUtil from game.theater import ConflictTheater from game.utils import meters from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan from qt_ui.models import AtoModel -from .leaflet import LeafletLatLon, LeafletPoly -from .shapelyutil import ShapelyUtil from .waypointjs import WaypointJs diff --git a/qt_ui/widgets/map/model/frontlinejs.py b/qt_ui/widgets/map/model/frontlinejs.py index bee88d7b..d45e1754 100644 --- a/qt_ui/widgets/map/model/frontlinejs.py +++ b/qt_ui/widgets/map/model/frontlinejs.py @@ -4,10 +4,10 @@ from typing import List from PySide2.QtCore import Property, QObject, Signal, Slot +from game.server.leaflet import LeafletLatLon from game.theater import ConflictTheater, FrontLine from game.utils import nautical_miles from qt_ui.dialogs import Dialog -from .leaflet import LeafletLatLon class FrontLineJs(QObject): diff --git a/qt_ui/widgets/map/model/groundobjectjs.py b/qt_ui/widgets/map/model/groundobjectjs.py index eae0eb55..493f83ac 100644 --- a/qt_ui/widgets/map/model/groundobjectjs.py +++ b/qt_ui/widgets/map/model/groundobjectjs.py @@ -8,9 +8,9 @@ from dcs.vehicles import vehicle_map from game import Game from game.dcs.groundunittype import GroundUnitType +from game.server.leaflet import LeafletLatLon from game.theater import TheaterGroundObject from qt_ui.dialogs import Dialog -from qt_ui.widgets.map.model.leaflet import LeafletLatLon from qt_ui.windows.groundobject.QGroundObjectMenu import QGroundObjectMenu diff --git a/qt_ui/widgets/map/model/holdzonesjs.py b/qt_ui/widgets/map/model/holdzonesjs.py deleted file mode 100644 index 1519b6ca..00000000 --- a/qt_ui/widgets/map/model/holdzonesjs.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, Field - -from game import Game -from game.ato import Flight -from game.flightplan import HoldZoneGeometry -from .config import ENABLE_EXPENSIVE_DEBUG_TOOLS -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil - - -class HoldZonesJs(BaseModel): - home_bubble: LeafletPoly = Field(alias="homeBubble") - target_bubble: LeafletPoly = Field(alias="targetBubble") - join_bubble: LeafletPoly = Field(alias="joinBubble") - excluded_zones: list[LeafletPoly] = Field(alias="excludedZones") - permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones") - preferred_lines: list[LeafletPoly] = Field(alias="preferredLines") - - @classmethod - def empty(cls) -> HoldZonesJs: - return HoldZonesJs( - homeBubble=[], - targetBubble=[], - joinBubble=[], - excludedZones=[], - permissibleZones=[], - preferredLines=[], - ) - - @classmethod - def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs: - if not ENABLE_EXPENSIVE_DEBUG_TOOLS: - return HoldZonesJs.empty() - target = flight.package.target - home = flight.departure - if flight.package.waypoints is None: - return HoldZonesJs.empty() - ip = flight.package.waypoints.ingress - join = flight.package.waypoints.join - geometry = HoldZoneGeometry( - target.position, home.position, ip, join, game.blue, game.theater - ) - return HoldZonesJs( - homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), - targetBubble=ShapelyUtil.poly_to_leaflet( - geometry.target_bubble, game.theater - ), - joinBubble=ShapelyUtil.poly_to_leaflet(geometry.join_bubble, game.theater), - excludedZones=ShapelyUtil.polys_to_leaflet( - geometry.excluded_zones, game.theater - ), - permissibleZones=ShapelyUtil.polys_to_leaflet( - geometry.permissible_zones, game.theater - ), - preferredLines=ShapelyUtil.lines_to_leaflet( - geometry.preferred_lines, game.theater - ), - ) diff --git a/qt_ui/widgets/map/model/ipzonesjs.py b/qt_ui/widgets/map/model/ipzonesjs.py deleted file mode 100644 index b756579b..00000000 --- a/qt_ui/widgets/map/model/ipzonesjs.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, Field - -from game import Game -from game.ato import Flight -from game.flightplan import IpZoneGeometry -from .config import ENABLE_EXPENSIVE_DEBUG_TOOLS -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil - - -class IpZonesJs(BaseModel): - home_bubble: LeafletPoly = Field(alias="homeBubble") - ipBubble: LeafletPoly = Field(alias="ipBubble") - permissibleZone: LeafletPoly = Field(alias="permissibleZone") - safeZones: list[LeafletPoly] = Field(alias="safeZones") - - @classmethod - def empty(cls) -> IpZonesJs: - return IpZonesJs(homeBubble=[], ipBubble=[], permissibleZone=[], safeZones=[]) - - @classmethod - def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: - if not ENABLE_EXPENSIVE_DEBUG_TOOLS: - return IpZonesJs.empty() - target = flight.package.target - home = flight.departure - geometry = IpZoneGeometry(target.position, home.position, game.blue) - return IpZonesJs( - homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), - ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater), - permissibleZone=ShapelyUtil.poly_to_leaflet( - geometry.permissible_zone, game.theater - ), - safeZones=ShapelyUtil.polys_to_leaflet(geometry.safe_zones, game.theater), - ) diff --git a/qt_ui/widgets/map/model/joinzonesjs.py b/qt_ui/widgets/map/model/joinzonesjs.py deleted file mode 100644 index 7a178753..00000000 --- a/qt_ui/widgets/map/model/joinzonesjs.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from pydantic import Field -from pydantic.main import BaseModel - -from game import Game -from game.ato import Flight -from game.flightplan import JoinZoneGeometry -from .config import ENABLE_EXPENSIVE_DEBUG_TOOLS -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil - - -class JoinZonesJs(BaseModel): - home_bubble: LeafletPoly = Field(alias="homeBubble") - target_bubble: LeafletPoly = Field(alias="targetBubble") - ip_bubble: LeafletPoly = Field(alias="ipBubble") - excluded_zones: list[LeafletPoly] = Field(alias="excludedZones") - permissible_zones: list[LeafletPoly] = Field(alias="permissibleZones") - preferred_lines: list[LeafletPoly] = Field(alias="preferredLines") - - @classmethod - def empty(cls) -> JoinZonesJs: - return JoinZonesJs( - homeBubble=[], - targetBubble=[], - ipBubble=[], - excludedZones=[], - permissibleZones=[], - preferredLines=[], - ) - - @classmethod - def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs: - if not ENABLE_EXPENSIVE_DEBUG_TOOLS: - return JoinZonesJs.empty() - target = flight.package.target - home = flight.departure - if flight.package.waypoints is None: - return JoinZonesJs.empty() - ip = flight.package.waypoints.ingress - geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue) - return JoinZonesJs( - homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), - targetBubble=ShapelyUtil.poly_to_leaflet( - geometry.target_bubble, game.theater - ), - ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater), - excludedZones=ShapelyUtil.polys_to_leaflet( - geometry.excluded_zones, game.theater - ), - permissibleZones=ShapelyUtil.polys_to_leaflet( - geometry.permissible_zones, game.theater - ), - preferredLines=ShapelyUtil.lines_to_leaflet( - geometry.preferred_lines, game.theater - ), - ) diff --git a/qt_ui/widgets/map/model/leaflet.py b/qt_ui/widgets/map/model/leaflet.py deleted file mode 100644 index 9623a951..00000000 --- a/qt_ui/widgets/map/model/leaflet.py +++ /dev/null @@ -1,4 +0,0 @@ -from __future__ import annotations - -LeafletLatLon = list[float] -LeafletPoly = list[LeafletLatLon] diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index 5d6a2679..ad93d635 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging from typing import List, Optional, Tuple @@ -10,6 +9,7 @@ from dcs import Point from game import Game from game.ato.airtaaskingorder import AirTaskingOrder from game.profiling import logged_duration +from game.server.leaflet import LeafletLatLon from game.sim.combat import FrozenCombat from game.sim.combat.aircombat import AirCombat from game.sim.combat.atip import AtIp @@ -26,11 +26,7 @@ from .controlpointjs import ControlPointJs from .flightjs import FlightJs from .frontlinejs import FrontLineJs from .groundobjectjs import GroundObjectJs -from .holdzonesjs import HoldZonesJs from .ipcombatjs import IpCombatJs -from .ipzonesjs import IpZonesJs -from .joinzonesjs import JoinZonesJs -from .leaflet import LeafletLatLon from .mapzonesjs import MapZonesJs from .navmeshjs import NavMeshJs from .samcombatjs import SamCombatJs @@ -69,12 +65,10 @@ class MapModel(QObject): navmeshesChanged = Signal() mapZonesChanged = Signal() unculledZonesChanged = Signal() - ipZonesChanged = Signal() - joinZonesChanged = Signal() - holdZonesChanged = Signal() airCombatsChanged = Signal() samCombatsChanged = Signal() ipCombatsChanged = Signal() + selectedFlightChanged = Signal(str) def __init__(self, game_model: GameModel, sim_controller: SimController) -> None: super().__init__() @@ -91,9 +85,6 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs.empty() - self._join_zones = JoinZonesJs.empty() - self._hold_zones = HoldZonesJs.empty() self._selected_flight_index: Optional[Tuple[int, int]] = None self._air_combats = [] self._sam_combats = [] @@ -128,7 +119,6 @@ class MapModel(QObject): self._navmeshes = NavMeshJs([], []) self._map_zones = MapZonesJs([], [], []) self._unculled_zones = [] - self._ip_zones = IpZonesJs.empty() self._air_combats = [] self._sam_combats = [] self._ip_combats = [] @@ -155,7 +145,6 @@ class MapModel(QObject): else: self._selected_flight_index = index, 0 self.select_current_flight() - self.reset_debug_zones() def set_flight_selection(self, index: int) -> None: self.deselect_current_flight() @@ -174,7 +163,6 @@ class MapModel(QObject): self._selected_flight_index = self._selected_flight_index[0], None self._selected_flight_index = self._selected_flight_index[0], index self.select_current_flight() - self.reset_debug_zones() @property def _selected_flight(self) -> Optional[FlightJs]: @@ -193,8 +181,10 @@ class MapModel(QObject): def select_current_flight(self): flight = self._selected_flight if flight is None: + self.selectedFlightChanged.emit(None) return None flight.set_selected(True) + self.selectedFlightChanged.emit(str(flight.flight.id)) @staticmethod def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon: @@ -249,23 +239,6 @@ class MapModel(QObject): self.game.blue.ato, blue=True ) | self._flights_in_ato(self.game.red.ato, blue=False) self.flightsChanged.emit() - self.reset_debug_zones() - - def reset_debug_zones(self) -> None: - selected_flight = None - if self._selected_flight is not None: - selected_flight = self._selected_flight.flight - if selected_flight is None: - self._ip_zones = IpZonesJs.empty() - self._join_zones = JoinZonesJs.empty() - self._hold_zones = HoldZonesJs.empty() - else: - self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game) - self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game) - self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game) - self.ipZonesChanged.emit() - self.joinZonesChanged.emit() - self.holdZonesChanged.emit() @Property(list, notify=flightsChanged) def flights(self) -> list[FlightJs]: @@ -397,20 +370,6 @@ class MapModel(QObject): def unculledZones(self) -> list[UnculledZone]: return self._unculled_zones - @Property(str, notify=ipZonesChanged) - def ipZones(self) -> str: - return json.dumps(self._ip_zones.dict(by_alias=True)) - - @Property(str, notify=joinZonesChanged) - def joinZones(self) -> str: - # Must be dumped as a string and deserialized in js because QWebChannel can't - # handle a dict. Can be cleaned up by switching from QWebChannel to FastAPI. - return json.dumps(self._join_zones.dict(by_alias=True)) - - @Property(str, notify=holdZonesChanged) - def holdZones(self) -> str: - return json.dumps(self._hold_zones.dict(by_alias=True)) - def reset_combats(self) -> None: self._air_combats = [] self._sam_combats = [] diff --git a/qt_ui/widgets/map/model/mapzonesjs.py b/qt_ui/widgets/map/model/mapzonesjs.py index f491f4cc..fe254df3 100644 --- a/qt_ui/widgets/map/model/mapzonesjs.py +++ b/qt_ui/widgets/map/model/mapzonesjs.py @@ -3,8 +3,7 @@ from __future__ import annotations from PySide2.QtCore import Property, QObject, Signal from game import Game -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil +from game.server.leaflet import LeafletPoly, ShapelyUtil class MapZonesJs(QObject): diff --git a/qt_ui/widgets/map/model/navmeshjs.py b/qt_ui/widgets/map/model/navmeshjs.py index d8764656..a2b11d5a 100644 --- a/qt_ui/widgets/map/model/navmeshjs.py +++ b/qt_ui/widgets/map/model/navmeshjs.py @@ -4,8 +4,8 @@ from PySide2.QtCore import Property, QObject, Signal from game import Game from game.navmesh import NavMesh +from game.server.leaflet import LeafletPoly from game.theater import ConflictTheater -from .leaflet import LeafletPoly from .navmeshpolyjs import NavMeshPolyJs diff --git a/qt_ui/widgets/map/model/navmeshpolyjs.py b/qt_ui/widgets/map/model/navmeshpolyjs.py index 03a5b6ba..52d35992 100644 --- a/qt_ui/widgets/map/model/navmeshpolyjs.py +++ b/qt_ui/widgets/map/model/navmeshpolyjs.py @@ -3,9 +3,8 @@ from __future__ import annotations from PySide2.QtCore import Property, QObject, Signal from game.navmesh import NavMeshPoly +from game.server.leaflet import LeafletPoly, ShapelyUtil from game.theater import ConflictTheater -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil class NavMeshPolyJs(QObject): diff --git a/qt_ui/widgets/map/model/supplyroutejs.py b/qt_ui/widgets/map/model/supplyroutejs.py index 9979c235..2d6226ed 100644 --- a/qt_ui/widgets/map/model/supplyroutejs.py +++ b/qt_ui/widgets/map/model/supplyroutejs.py @@ -5,9 +5,9 @@ from typing import List from PySide2.QtCore import Property, QObject, Signal from game import Game +from game.server.leaflet import LeafletLatLon from game.theater import ControlPoint from game.transfers import MultiGroupTransport, TransportMap -from .leaflet import LeafletLatLon class SupplyRouteJs(QObject): diff --git a/qt_ui/widgets/map/model/threatzonesjs.py b/qt_ui/widgets/map/model/threatzonesjs.py index fe2d1e9d..a10565f8 100644 --- a/qt_ui/widgets/map/model/threatzonesjs.py +++ b/qt_ui/widgets/map/model/threatzonesjs.py @@ -2,10 +2,9 @@ from __future__ import annotations from PySide2.QtCore import Property, QObject, Signal +from game.server.leaflet import LeafletPoly, ShapelyUtil from game.theater import ConflictTheater from game.threatzones import ThreatZones -from .leaflet import LeafletPoly -from .shapelyutil import ShapelyUtil class ThreatZonesJs(QObject): diff --git a/qt_ui/widgets/map/model/unculledzonejs.py b/qt_ui/widgets/map/model/unculledzonejs.py index cf374948..baede90c 100644 --- a/qt_ui/widgets/map/model/unculledzonejs.py +++ b/qt_ui/widgets/map/model/unculledzonejs.py @@ -5,7 +5,7 @@ from typing import Iterator from PySide2.QtCore import Property, QObject, Signal from game import Game -from .leaflet import LeafletLatLon +from game.server.leaflet import LeafletLatLon class UnculledZone(QObject): diff --git a/qt_ui/widgets/map/model/waypointjs.py b/qt_ui/widgets/map/model/waypointjs.py index d54ebf9e..4ae06bfe 100644 --- a/qt_ui/widgets/map/model/waypointjs.py +++ b/qt_ui/widgets/map/model/waypointjs.py @@ -7,10 +7,10 @@ from PySide2.QtCore import Property, QObject, Signal, Slot from game.ato import Flight, FlightWaypoint from game.ato.flightwaypointtype import FlightWaypointType +from game.server.leaflet import LeafletLatLon from game.theater import ConflictTheater, LatLon from gen.flights.flightplan import FlightPlan from qt_ui.models import AtoModel -from .leaflet import LeafletLatLon if TYPE_CHECKING: from .flightjs import FlightJs diff --git a/requirements.txt b/requirements.txt index cdb3d7ba..4b3f86ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ altgraph==0.17.2 +anyio==3.5.0 +asgiref==3.5.0 atomicwrites==1.4.0 attrs==21.4.0 black==22.1.0 @@ -8,9 +10,13 @@ click==8.0.3 colorama==0.4.4 distlib==0.3.4 Faker==12.3.0 +fastapi==0.73.0 filelock==3.4.2 future==0.18.2 +h11==0.13.0 +httptools==0.3.0 identify==2.4.9 +idna==3.3 iniconfig==1.1.1 Jinja2==3.0.3 MarkupSafe==2.0.1 @@ -34,11 +40,14 @@ pyproj==3.3.0 PySide2==5.15.2.1 pytest==7.0.1 python-dateutil==2.8.2 +python-dotenv==0.19.2 pywin32-ctypes==0.2.0 PyYAML==6.0 ./wheels/Shapely-1.8.0-cp310-cp310-win_amd64.whl shiboken2==5.15.2.1 six==1.16.0 +sniffio==1.2.0 +starlette==0.17.1 tabulate==0.8.9 text-unidecode==1.3 toml==0.10.2 @@ -49,4 +58,7 @@ types-Pillow==9.0.6 types-PyYAML==6.0.4 types-tabulate==0.8.5 typing_extensions==4.0.1 +uvicorn==0.17.4 virtualenv==20.13.1 +watchgod==0.7 +websockets==10.1 diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index 5a1e0cd0..c587742b 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -1,6 +1,12 @@ -// Won't actually enable anything unless the same property is set in -// mapmodel.py. const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; +// Must be kept in sync with game.server.settings.ServerSettings. +const HTTP_BACKEND = "http://[::1]:5000"; + +function getJson(endpoint) { + return fetch(`${HTTP_BACKEND}${endpoint}`).then((response) => + response.json() + ); +} const Colors = Object.freeze({ Blue: "#0084ff", @@ -350,18 +356,29 @@ new QWebChannel(qt.webChannelTransport, function (channel) { game.navmeshesChanged.connect(drawNavmeshes); game.mapZonesChanged.connect(drawMapZones); game.unculledZonesChanged.connect(drawUnculledZones); - game.ipZonesChanged.connect(drawIpZones); - game.joinZonesChanged.connect(drawJoinZones); - game.holdZonesChanged.connect(drawHoldZones); game.airCombatsChanged.connect(drawCombat); game.samCombatsChanged.connect(drawCombat); game.ipCombatsChanged.connect(drawCombat); + game.selectedFlightChanged.connect(updateSelectedFlight); }); function recenterMap(center) { map.setView(center, 8, { animate: true, duration: 1 }); } +function updateSelectedFlight(id) { + if (id == null) { + holdZones.clearLayers(); + ipZones.clearLayers(); + joinZones.clearLayers(); + return; + } + + drawHoldZones(id); + drawIpZones(id); + drawJoinZones(id); +} + class ControlPoint { constructor(cp) { this.cp = cp; @@ -1099,142 +1116,142 @@ function drawUnculledZones() { } } -function drawIpZones() { +function drawIpZones(id) { ipZones.clearLayers(); if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { return; } - const iz = JSON.parse(game.ipZones); - - L.polygon(iz.homeBubble, { - color: Colors.Highlight, - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); - - L.polygon(iz.ipBubble, { - color: "#bb89ff", - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); - - L.polygon(iz.permissibleZone, { - color: "#ffffff", - fillOpacity: 0.1, - interactive: false, - }).addTo(ipZones); - - for (const zone of iz.safeZones) { - L.polygon(zone, { - color: Colors.Green, + getJson(`/debug/waypoint-geometries/ip/${id}`).then((iz) => { + L.polygon(iz.homeBubble, { + color: Colors.Highlight, fillOpacity: 0.1, interactive: false, }).addTo(ipZones); - } + + L.polygon(iz.ipBubble, { + color: "#bb89ff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + + L.polygon(iz.permissibleZone, { + color: "#ffffff", + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + + for (const zone of iz.safeZones) { + L.polygon(zone, { + color: Colors.Green, + fillOpacity: 0.1, + interactive: false, + }).addTo(ipZones); + } + }); } -function drawJoinZones() { +function drawJoinZones(id) { joinZones.clearLayers(); if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { return; } - const jz = JSON.parse(game.joinZones); - - L.polygon(jz.homeBubble, { - color: Colors.Highlight, - fillOpacity: 0.1, - interactive: false, - }).addTo(joinZones); - - L.polygon(jz.targetBubble, { - color: "#bb89ff", - fillOpacity: 0.1, - interactive: false, - }).addTo(joinZones); - - L.polygon(jz.ipBubble, { - color: "#ffffff", - fillOpacity: 0.1, - interactive: false, - }).addTo(joinZones); - - for (const zone of jz.excludedZones) { - L.polygon(zone, { - color: "#ffa500", - fillOpacity: 0.2, - stroke: false, + getJson(`/debug/waypoint-geometries/join/${id}`).then((jz) => { + L.polygon(jz.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, }).addTo(joinZones); - } - for (const zone of jz.permissibleZones) { - L.polygon(zone, { - color: Colors.Green, + L.polygon(jz.targetBubble, { + color: "#bb89ff", + fillOpacity: 0.1, interactive: false, }).addTo(joinZones); - } - for (const line of jz.preferredLines) { - L.polyline(line, { - color: Colors.Green, + L.polygon(jz.ipBubble, { + color: "#ffffff", + fillOpacity: 0.1, interactive: false, }).addTo(joinZones); - } + + for (const zone of jz.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(joinZones); + } + + for (const zone of jz.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } + + for (const line of jz.preferredLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(joinZones); + } + }); } -function drawHoldZones() { +function drawHoldZones(id) { holdZones.clearLayers(); if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) { return; } - const hz = JSON.parse(game.holdZones); - - L.polygon(hz.homeBubble, { - color: Colors.Highlight, - fillOpacity: 0.1, - interactive: false, - }).addTo(holdZones); - - L.polygon(hz.targetBubble, { - color: Colors.Highlight, - fillOpacity: 0.1, - interactive: false, - }).addTo(holdZones); - - L.polygon(hz.joinBubble, { - color: Colors.Highlight, - fillOpacity: 0.1, - interactive: false, - }).addTo(holdZones); - - for (const zone of hz.excludedZones) { - L.polygon(zone, { - color: "#ffa500", - fillOpacity: 0.2, - stroke: false, + getJson(`/debug/waypoint-geometries/hold/${id}`).then((hz) => { + L.polygon(hz.homeBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, }).addTo(holdZones); - } - for (const zone of hz.permissibleZones) { - L.polygon(zone, { - color: Colors.Green, + L.polygon(hz.targetBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, }).addTo(holdZones); - } - for (const line of hz.preferredLines) { - L.polyline(line, { - color: Colors.Green, + L.polygon(hz.joinBubble, { + color: Colors.Highlight, + fillOpacity: 0.1, interactive: false, }).addTo(holdZones); - } + + for (const zone of hz.excludedZones) { + L.polygon(zone, { + color: "#ffa500", + fillOpacity: 0.2, + stroke: false, + interactive: false, + }).addTo(holdZones); + } + + for (const zone of hz.permissibleZones) { + L.polygon(zone, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } + + for (const line of hz.preferredLines) { + L.polyline(line, { + color: Colors.Green, + interactive: false, + }).addTo(holdZones); + } + }); } function drawCombat() { @@ -1276,9 +1293,6 @@ function drawInitialMap() { drawNavmeshes(); drawMapZones(); drawUnculledZones(); - drawIpZones(); - drawJoinZones(); - drawHoldZones(); drawCombat(); }