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.
This commit is contained in:
Dan Albert
2022-02-13 14:22:05 -08:00
parent 1df31b2496
commit 350f08be2f
33 changed files with 419 additions and 330 deletions

View File

@@ -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__":

View File

@@ -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()))

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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
),
)

View File

@@ -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),
)

View File

@@ -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
),
)

View File

@@ -1,4 +0,0 @@
from __future__ import annotations
LeafletLatLon = list[float]
LeafletPoly = list[LeafletLatLon]

View File

@@ -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 = []

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -1,43 +0,0 @@
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
class ShapelyUtil:
@staticmethod
def poly_to_leaflet(poly: Polygon, theater: ConflictTheater) -> LeafletPoly:
if poly.is_empty:
return []
return [
theater.point_to_ll(Point(x, y)).as_list() for x, y in poly.exterior.coords
]
@classmethod
def polys_to_leaflet(
cls, poly: Union[Polygon, MultiPolygon], theater: ConflictTheater
) -> list[LeafletPoly]:
if isinstance(poly, MultiPolygon):
polys = poly.geoms
else:
polys = [poly]
return [cls.poly_to_leaflet(poly, theater) for poly in polys]
@staticmethod
def line_to_leaflet(
line: LineString, theater: ConflictTheater
) -> list[LeafletLatLon]:
return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords]
@classmethod
def lines_to_leaflet(
cls, line_string: MultiLineString | LineString, theater: ConflictTheater
) -> list[list[LeafletLatLon]]:
if isinstance(line_string, MultiLineString):
lines = line_string.geoms
else:
lines = [line_string]
return [cls.line_to_leaflet(line, theater) for line in lines]

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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