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(); }