diff --git a/game/ato/flight.py b/game/ato/flight.py
index f860bce7..76cb7a8e 100644
--- a/game/ato/flight.py
+++ b/game/ato/flight.py
@@ -98,6 +98,10 @@ class Flight:
state["id"] = uuid.uuid4()
self.__dict__.update(state)
+ @property
+ def blue(self) -> bool:
+ return self.squadron.player
+
@property
def departure(self) -> ControlPoint:
return self.squadron.location
diff --git a/game/ato/flightwaypoint.py b/game/ato/flightwaypoint.py
index cf0895a3..152b0510 100644
--- a/game/ato/flightwaypoint.py
+++ b/game/ato/flightwaypoint.py
@@ -1,20 +1,115 @@
from __future__ import annotations
+from collections.abc import Sequence
+from dataclasses import field
from datetime import timedelta
-from typing import Optional, Sequence, TYPE_CHECKING, Union
+from typing import Optional, TYPE_CHECKING
from dcs import Point
-from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit
+from pydantic.dataclasses import dataclass
-from game.utils import Distance, meters
from game.ato.flightwaypointtype import FlightWaypointType
+from game.theater import LatLon
+from game.utils import Distance, meters
if TYPE_CHECKING:
- from game.theater import ControlPoint, MissionTarget
+ from game.theater import ConflictTheater, ControlPoint, MissionTarget
-class FlightWaypoint:
+@dataclass
+class BaseFlightWaypoint:
+ name: str
+ waypoint_type: FlightWaypointType
+ x: float
+ y: float
+ alt: Distance
+ alt_type: str
+
+ is_movable: bool = field(init=False)
+ should_mark: bool = field(init=False)
+ include_in_path: bool = field(init=False)
+
+ # Do not use unless you're sure it's up to date. Pydantic doesn't have support for
+ # serializing lazy properties so this needs to be stored in the class, but because
+ # updating it requires a reference to the ConflictTheater it may not always be set,
+ # or up to date. Call update_latlng to refresh.
+ latlng: LatLon | None = None
+
+ def __post_init__(self) -> None:
+ # Target *points* are the exact location of a unit, whereas the target area is
+ # only the center of the objective. Allow moving the latter since its exact
+ # location isn't very important.
+ #
+ # Landing, and divert should be changed in the flight settings UI, takeoff
+ # cannot be changed because that's where the plane is.
+ #
+ # Moving the bullseye reference only makes it wrong.
+ self.is_movable = self.waypoint_type not in {
+ FlightWaypointType.BULLSEYE,
+ FlightWaypointType.DIVERT,
+ FlightWaypointType.LANDING_POINT,
+ FlightWaypointType.TAKEOFF,
+ FlightWaypointType.TARGET_POINT,
+ }
+
+ # We don't need a marker for the departure waypoint (and it's likely
+ # coincident with the landing waypoint, so hard to see). We do want to draw
+ # the path from it though.
+ #
+ # We also don't need the landing waypoint since we'll be drawing that path
+ # as well, and it's clear what it is, and only obscured the CP icon.
+ #
+ # The divert waypoint also obscures the CP. We don't draw the path to it,
+ # but it can be seen in the flight settings page, so it's not really a
+ # problem to exclude it.
+ #
+ # Bullseye ought to be (but currently isn't) drawn *once* rather than as a
+ # flight waypoint.
+ self.should_mark = self.waypoint_type not in {
+ FlightWaypointType.BULLSEYE,
+ FlightWaypointType.DIVERT,
+ FlightWaypointType.LANDING_POINT,
+ FlightWaypointType.TAKEOFF,
+ }
+
+ self.include_in_path = self.waypoint_type not in {
+ FlightWaypointType.BULLSEYE,
+ FlightWaypointType.DIVERT,
+ }
+
+ @property
+ def position(self) -> Point:
+ return Point(self.x, self.y)
+
+ def update_latlng(self, theater: ConflictTheater) -> None:
+ self.latlng = theater.point_to_ll(self.position)
+
+
+class FlightWaypoint(BaseFlightWaypoint):
+ control_point: ControlPoint | None = None
+
+ # TODO: Merge with pretty_name.
+ # Only used in the waypoint list in the flight edit page. No sense
+ # having three names. A short and long form is enough.
+ description: str = ""
+
+ targets: Sequence[MissionTarget | Unit] = []
+ obj_name: str = ""
+ pretty_name: str = ""
+ only_for_player: bool = False
+ flyover: bool = False
+
+ # The minimum amount of fuel remaining at this waypoint in pounds.
+ min_fuel: float | None = None
+
+ # These are set very late by the air conflict generator (part of mission
+ # generation). We do it late so that we don't need to propagate changes
+ # to waypoint times whenever the player alters the package TOT or the
+ # flight's offset in the UI.
+ tot: timedelta | None = None
+ departure_time: timedelta | None = None
+
def __init__(
self,
waypoint_type: FlightWaypointType,
@@ -34,32 +129,10 @@ class FlightWaypoint:
control_point: The control point to associate with this waypoint. Needed for
landing points.
"""
- self.waypoint_type = waypoint_type
- self.x = x
- self.y = y
- self.alt = alt
+ super().__init__(
+ name="", waypoint_type=waypoint_type, x=x, y=y, alt=alt, alt_type="BARO"
+ )
self.control_point = control_point
- self.alt_type = "BARO"
- self.name = ""
- # TODO: Merge with pretty_name.
- # Only used in the waypoint list in the flight edit page. No sense
- # having three names. A short and long form is enough.
- self.description = ""
- self.targets: Sequence[Union[MissionTarget, Unit]] = []
- self.obj_name = ""
- self.pretty_name = ""
- self.only_for_player = False
- self.flyover = False
- # The minimum amount of fuel remaining at this waypoint in pounds.
- self.min_fuel: Optional[float] = None
- # These are set very late by the air conflict generator (part of mission
- # generation). We do it late so that we don't need to propagate changes
- # to waypoint times whenever the player alters the package TOT or the
- # flight's offset in the UI.
- self.tot: Optional[timedelta] = None
- self.departure_time: Optional[timedelta] = None
-
- @property
- def position(self) -> Point:
- return Point(self.x, self.y)
+ def __hash__(self) -> int:
+ return hash(id(self))
diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py
index fb2bd58c..03199389 100644
--- a/game/ato/flightwaypointtype.py
+++ b/game/ato/flightwaypointtype.py
@@ -1,7 +1,8 @@
-from enum import Enum
+from enum import IntEnum, unique
-class FlightWaypointType(Enum):
+@unique
+class FlightWaypointType(IntEnum):
"""Enumeration of waypoint types.
The value of the enum has no meaning but should remain stable to prevent breaking
diff --git a/game/missiongenerator/kneeboard.py b/game/missiongenerator/kneeboard.py
index 41afc403..133d3c47 100644
--- a/game/missiongenerator/kneeboard.py
+++ b/game/missiongenerator/kneeboard.py
@@ -187,9 +187,9 @@ class KneeboardPage:
@staticmethod
def format_ll(ll: LatLon) -> str:
- ns = "N" if ll.latitude >= 0 else "S"
- ew = "E" if ll.longitude >= 0 else "W"
- return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}"
+ ns = "N" if ll.lat >= 0 else "S"
+ ew = "E" if ll.lng >= 0 else "W"
+ return f"{ll.lat:.4}°{ns} {ll.lng:.4}°{ew}"
@dataclass(frozen=True)
diff --git a/game/server/app.py b/game/server/app.py
index 2b02f3be..fe7c8fa1 100644
--- a/game/server/app.py
+++ b/game/server/app.py
@@ -1,8 +1,10 @@
from fastapi import Depends, FastAPI
-from . import debuggeometries, eventstream
+from . import debuggeometries, eventstream, flights, waypoints
from .security import ApiKeyManager
app = FastAPI(dependencies=[Depends(ApiKeyManager.verify)])
app.include_router(debuggeometries.router)
app.include_router(eventstream.router)
+app.include_router(flights.router)
+app.include_router(waypoints.router)
diff --git a/game/server/dependencies.py b/game/server/dependencies.py
index eb69e0cb..e93eb7ee 100644
--- a/game/server/dependencies.py
+++ b/game/server/dependencies.py
@@ -1,15 +1,20 @@
from game import Game
+from qt_ui.models import GameModel
class GameContext:
- _game: Game | None
+ _game_model: GameModel
@classmethod
- def set(cls, game: Game | None) -> None:
- cls._game = game
+ def set_model(cls, game_model: GameModel) -> None:
+ cls._game_model = game_model
@classmethod
def get(cls) -> Game:
- if cls._game is None:
+ if cls._game_model.game is None:
raise RuntimeError("GameContext has no Game set")
- return cls._game
+ return cls._game_model.game
+
+ @classmethod
+ def get_model(cls) -> GameModel:
+ return cls._game_model
diff --git a/game/server/flights/__init__.py b/game/server/flights/__init__.py
new file mode 100644
index 00000000..3a27ef1c
--- /dev/null
+++ b/game/server/flights/__init__.py
@@ -0,0 +1 @@
+from .routes import router
diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py
new file mode 100644
index 00000000..c6601457
--- /dev/null
+++ b/game/server/flights/routes.py
@@ -0,0 +1,34 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends
+from shapely.geometry import LineString, Point as ShapelyPoint
+
+from game import Game
+from game.server import GameContext
+from game.server.leaflet import LeafletPoly, ShapelyUtil
+from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan
+
+router: APIRouter = APIRouter(prefix="/flights")
+
+
+@router.get("/{flight_id}/commit-boundary")
+def commit_boundary(
+ flight_id: UUID, game: Game = Depends(GameContext.get)
+) -> LeafletPoly:
+ flight = game.db.flights.get(flight_id)
+ if not isinstance(flight.flight_plan, PatrollingFlightPlan):
+ return []
+ start = flight.flight_plan.patrol_start
+ end = flight.flight_plan.patrol_end
+ if isinstance(flight.flight_plan, CasFlightPlan):
+ center = flight.flight_plan.target.position
+ commit_center = ShapelyPoint(center.x, center.y)
+ else:
+ commit_center = LineString(
+ [
+ ShapelyPoint(start.x, start.y),
+ ShapelyPoint(end.x, end.y),
+ ]
+ )
+ bubble = commit_center.buffer(flight.flight_plan.engagement_distance.meters)
+ return ShapelyUtil.poly_to_leaflet(bubble, game.theater)
diff --git a/game/server/waypoints/__init__.py b/game/server/waypoints/__init__.py
new file mode 100644
index 00000000..3a27ef1c
--- /dev/null
+++ b/game/server/waypoints/__init__.py
@@ -0,0 +1 @@
+from .routes import router
diff --git a/game/server/waypoints/routes.py b/game/server/waypoints/routes.py
new file mode 100644
index 00000000..5400c5a2
--- /dev/null
+++ b/game/server/waypoints/routes.py
@@ -0,0 +1,81 @@
+from datetime import timedelta
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+
+from game import Game
+from game.ato.flightwaypoint import BaseFlightWaypoint, FlightWaypoint
+from game.ato.flightwaypointtype import FlightWaypointType
+from game.server import GameContext
+from game.theater import LatLon
+from game.utils import meters
+
+router: APIRouter = APIRouter(prefix="/waypoints")
+
+
+@router.get("/{flight_id}", response_model=list[BaseFlightWaypoint])
+def all_waypoints_for_flight(
+ flight_id: UUID, game: Game = Depends(GameContext.get)
+) -> list[FlightWaypoint]:
+ flight = game.db.flights.get(flight_id)
+ departure = FlightWaypoint(
+ FlightWaypointType.TAKEOFF,
+ flight.departure.position.x,
+ flight.departure.position.y,
+ meters(0),
+ )
+ departure.alt_type = "RADIO"
+ points = [departure] + flight.flight_plan.waypoints
+ for point in points:
+ point.update_latlng(game.theater)
+ return points
+
+
+@router.post("/{flight_id}/{waypoint_idx}/position")
+def set_position(
+ flight_id: UUID,
+ waypoint_idx: int,
+ position: LatLon,
+ game: Game = Depends(GameContext.get),
+) -> None:
+ flight = game.db.flights.get(flight_id)
+ if waypoint_idx == 0:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+
+ waypoint = flight.flight_plan.waypoints[waypoint_idx - 1]
+ if not waypoint.is_movable:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+
+ point = game.theater.ll_to_point(position)
+ waypoint.x = point.x
+ waypoint.y = point.y
+ package_model = (
+ GameContext.get_model()
+ .ato_model_for(flight.blue)
+ .find_matching_package_model(flight.package)
+ )
+ if package_model is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Could not find PackageModel owning {flight}",
+ )
+ package_model.update_tot()
+
+
+@router.get("/{flight_id}/{waypoint_idx}/timing")
+def waypoint_timing(
+ flight_id: UUID, waypoint_idx: int, game: Game = Depends(GameContext.get)
+) -> str | None:
+ flight = game.db.flights.get(flight_id)
+ if waypoint_idx == 0:
+ return f"Depart T+{flight.flight_plan.takeoff_time()}"
+
+ waypoint = flight.flight_plan.waypoints[waypoint_idx - 1]
+ prefix = "TOT"
+ time = flight.flight_plan.tot_for_waypoint(waypoint)
+ if time is None:
+ prefix = "Depart"
+ time = flight.flight_plan.depart_time_for_waypoint(waypoint)
+ if time is None:
+ return ""
+ return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}"
diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py
index 271782ca..bdaa1007 100644
--- a/game/theater/conflicttheater.py
+++ b/game/theater/conflicttheater.py
@@ -4,17 +4,17 @@ import datetime
import math
from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
+from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple
from dcs.mapping import Point
from dcs.terrain import (
caucasus,
+ marianaislands,
nevada,
normandy,
persiangulf,
syria,
thechannel,
- marianaislands,
)
from dcs.terrain.terrain import Terrain
from pyproj import CRS, Transformer
@@ -262,7 +262,7 @@ class ConflictTheater:
return LatLon(lat, lon)
def ll_to_point(self, ll: LatLon) -> Point:
- x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude)
+ x, y = self.ll_to_point_transformer.transform(ll.lat, ll.lng)
return Point(x, y)
diff --git a/game/theater/latlon.py b/game/theater/latlon.py
index b819e30f..6d440638 100644
--- a/game/theater/latlon.py
+++ b/game/theater/latlon.py
@@ -1,14 +1,16 @@
-from dataclasses import dataclass
from typing import List, Tuple
+from pydantic.dataclasses import dataclass
+
@dataclass(frozen=True)
class LatLon:
- latitude: float
- longitude: float
+ # These names match Leaflet for easier interop.
+ lat: float
+ lng: float
def as_list(self) -> List[float]:
- return [self.latitude, self.longitude]
+ return [self.lat, self.lng]
@staticmethod
def _components(dimension: float) -> Tuple[int, int, float]:
@@ -28,7 +30,7 @@ class LatLon:
precision = 2 if include_decimal_seconds else 0
return " ".join(
[
- self._format_component(self.latitude, ("N", "S"), precision),
- self._format_component(self.longitude, ("E", "W"), precision),
+ self._format_component(self.lat, ("N", "S"), precision),
+ self._format_component(self.lng, ("E", "W"), precision),
]
)
diff --git a/game/utils.py b/game/utils.py
index 479d65e0..aceeff24 100644
--- a/game/utils.py
+++ b/game/utils.py
@@ -1,14 +1,14 @@
from __future__ import annotations
-from abc import ABC, abstractmethod
import itertools
import math
import random
+from abc import ABC, abstractmethod
from collections.abc import Iterable
-from dataclasses import dataclass
from typing import TypeVar, Union
from dcs import Point
+from pydantic.dataclasses import dataclass
from shapely.geometry import Point as ShapelyPoint
METERS_TO_FEET = 3.28084
diff --git a/mypy.ini b/mypy.ini
index b1cafe55..fe6d3348 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -15,6 +15,7 @@ warn_redundant_casts = True
# warn_return_any = True
warn_unreachable = True
warn_unused_ignores = True
+plugins = pydantic.mypy
[mypy-faker.*]
ignore_missing_imports = True
diff --git a/qt_ui/main.py b/qt_ui/main.py
index 0447931e..3eed28e2 100644
--- a/qt_ui/main.py
+++ b/qt_ui/main.py
@@ -18,7 +18,7 @@ from game.data.weapons import Pylon, Weapon, WeaponGroup
from game.dcs.aircrafttype import AircraftType
from game.factions import FACTIONS
from game.profiling import logged_duration
-from game.server import EventStream, GameContext, Server
+from game.server import EventStream, Server
from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
from pydcs_extensions import load_mods
@@ -137,7 +137,6 @@ def run_ui(game: Optional[Game]) -> None:
# Apply CSS (need works)
GameUpdateSignal()
- GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set)
GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain)
# Start window
diff --git a/qt_ui/models.py b/qt_ui/models.py
index ca9ae581..5e2c85c3 100644
--- a/qt_ui/models.py
+++ b/qt_ui/models.py
@@ -529,3 +529,8 @@ class GameModel:
self.game = game
self.ato_model.replace_from_game(player=True)
self.red_ato_model.replace_from_game(player=False)
+
+ def get(self) -> Game:
+ if self.game is None:
+ raise RuntimeError("GameModel has no Game set")
+ return self.game
diff --git a/qt_ui/widgets/map/model/controlpointjs.py b/qt_ui/widgets/map/model/controlpointjs.py
index 115428fc..7f511727 100644
--- a/qt_ui/widgets/map/model/controlpointjs.py
+++ b/qt_ui/widgets/map/model/controlpointjs.py
@@ -62,7 +62,7 @@ class ControlPointJs(QObject):
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.control_point.position)
- return [ll.latitude, ll.longitude]
+ return [ll.lat, ll.lng]
@Property(bool, notify=mobileChanged)
def mobile(self) -> bool:
diff --git a/qt_ui/widgets/map/model/flightjs.py b/qt_ui/widgets/map/model/flightjs.py
index 9767ad4b..b73567c8 100644
--- a/qt_ui/widgets/map/model/flightjs.py
+++ b/qt_ui/widgets/map/model/flightjs.py
@@ -1,41 +1,19 @@
from __future__ import annotations
-from typing import List
-
from PySide2.QtCore import Property, QObject, Signal, Slot
-from shapely.geometry import LineString, Point as ShapelyPoint
-from game.ato import Flight, FlightWaypoint
+from game.ato import Flight
from game.ato.flightstate import InFlight
-from game.ato.flightwaypointtype import FlightWaypointType
-from game.server.leaflet import LeafletLatLon, LeafletPoly, ShapelyUtil
+from game.server.leaflet import LeafletLatLon
from game.theater import ConflictTheater
-from game.utils import meters
-from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan
from qt_ui.models import AtoModel
-from .waypointjs import WaypointJs
class FlightJs(QObject):
idChanged = Signal()
positionChanged = Signal()
- flightPlanChanged = Signal()
blueChanged = Signal()
selectedChanged = Signal()
- commitBoundaryChanged = Signal()
-
- originChanged = Signal()
-
- @Property(list, notify=originChanged)
- def origin(self) -> LeafletLatLon:
- return self._waypoints[0].position
-
- targetChanged = Signal()
-
- @Property(list, notify=targetChanged)
- def target(self) -> LeafletLatLon:
- ll = self.theater.point_to_ll(self.flight.package.target.position)
- return [ll.latitude, ll.longitude]
def __init__(
self,
@@ -49,42 +27,18 @@ class FlightJs(QObject):
self._selected = selected
self.theater = theater
self.ato_model = ato_model
- self._waypoints = self.make_waypoints()
@Property(str, notify=idChanged)
def id(self) -> str:
return str(self.flight.id)
- def update_waypoints(self) -> None:
- for waypoint in self._waypoints:
- waypoint.timingChanged.emit()
-
- def make_waypoints(self) -> List[WaypointJs]:
- departure = FlightWaypoint(
- FlightWaypointType.TAKEOFF,
- self.flight.departure.position.x,
- self.flight.departure.position.y,
- meters(0),
- )
- departure.alt_type = "RADIO"
- waypoints = []
- for point in [departure] + self.flight.points:
- waypoint = WaypointJs(point, self, self.theater, self.ato_model)
- waypoint.positionChanged.connect(self.update_waypoints)
- waypoints.append(waypoint)
- return waypoints
-
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
if isinstance(self.flight.state, InFlight):
ll = self.theater.point_to_ll(self.flight.state.estimate_position())
- return [ll.latitude, ll.longitude]
+ return [ll.lat, ll.lng]
return []
- @Property(list, notify=flightPlanChanged)
- def flightPlan(self) -> List[WaypointJs]:
- return self._waypoints
-
@Property(bool, notify=blueChanged)
def blue(self) -> bool:
return self.flight.departure.captured
@@ -104,24 +58,3 @@ class FlightJs(QObject):
def set_selected(self, value: bool) -> None:
self._selected = value
self.selectedChanged.emit()
-
- @Property(list, notify=commitBoundaryChanged)
- def commitBoundary(self) -> LeafletPoly:
- if not isinstance(self.flight.flight_plan, PatrollingFlightPlan):
- return []
- start = self.flight.flight_plan.patrol_start
- end = self.flight.flight_plan.patrol_end
- if isinstance(self.flight.flight_plan, CasFlightPlan):
- center = self.flight.flight_plan.target.position
- commit_center = ShapelyPoint(center.x, center.y)
- else:
- commit_center = LineString(
- [
- ShapelyPoint(start.x, start.y),
- ShapelyPoint(end.x, end.y),
- ]
- )
- bubble = commit_center.buffer(
- self.flight.flight_plan.engagement_distance.meters
- )
- return ShapelyUtil.poly_to_leaflet(bubble, self.theater)
diff --git a/qt_ui/widgets/map/model/frontlinejs.py b/qt_ui/widgets/map/model/frontlinejs.py
index d45e1754..c448da49 100644
--- a/qt_ui/widgets/map/model/frontlinejs.py
+++ b/qt_ui/widgets/map/model/frontlinejs.py
@@ -30,7 +30,7 @@ class FrontLineJs(QObject):
self.front_line.attack_heading.left.degrees, nautical_miles(2).meters
)
)
- return [[a.latitude, a.longitude], [b.latitude, b.longitude]]
+ return [[a.lat, a.lng], [b.lat, b.lng]]
@Slot()
def showPackageDialog(self) -> None:
diff --git a/qt_ui/widgets/map/model/groundobjectjs.py b/qt_ui/widgets/map/model/groundobjectjs.py
index 493f83ac..36a278b7 100644
--- a/qt_ui/widgets/map/model/groundobjectjs.py
+++ b/qt_ui/widgets/map/model/groundobjectjs.py
@@ -101,7 +101,7 @@ class GroundObjectJs(QObject):
@Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.tgo.position)
- return [ll.latitude, ll.longitude]
+ return [ll.lat, ll.lng]
@Property(bool, notify=deadChanged)
def dead(self) -> bool:
diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py
index 52b0d0be..f6020f25 100644
--- a/qt_ui/widgets/map/model/mapmodel.py
+++ b/qt_ui/widgets/map/model/mapmodel.py
@@ -160,7 +160,7 @@ class MapModel(QObject):
@staticmethod
def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon:
ll = theater.point_to_ll(point)
- return [ll.latitude, ll.longitude]
+ return [ll.lat, ll.lng]
def reset(self) -> None:
if self.game_model.game is None:
@@ -183,7 +183,7 @@ class MapModel(QObject):
def reset_map_center(self, theater: ConflictTheater) -> None:
ll = theater.point_to_ll(theater.terrain.map_view_default.position)
- self._map_center = [ll.latitude, ll.longitude]
+ self._map_center = [ll.lat, ll.lng]
self.mapCenterChanged.emit(self._map_center)
@Property(str, notify=apiKeyChanged)
diff --git a/qt_ui/widgets/map/model/unculledzonejs.py b/qt_ui/widgets/map/model/unculledzonejs.py
index baede90c..bd2c5bd6 100644
--- a/qt_ui/widgets/map/model/unculledzonejs.py
+++ b/qt_ui/widgets/map/model/unculledzonejs.py
@@ -30,5 +30,5 @@ class UnculledZone(QObject):
for zone in game.get_culling_zones():
ll = game.theater.point_to_ll(zone)
yield UnculledZone(
- [ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000
+ [ll.lat, ll.lng], game.settings.perf_culling_distance * 1000
)
diff --git a/qt_ui/widgets/map/model/waypointjs.py b/qt_ui/widgets/map/model/waypointjs.py
deleted file mode 100644
index 7fdb26ec..00000000
--- a/qt_ui/widgets/map/model/waypointjs.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from __future__ import annotations
-
-from datetime import timedelta
-from typing import TYPE_CHECKING
-
-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
-
-if TYPE_CHECKING:
- from .flightjs import FlightJs
-
-
-class WaypointJs(QObject):
- positionChanged = Signal()
- altitudeFtChanged = Signal()
- altitudeReferenceChanged = Signal()
- nameChanged = Signal()
- timingChanged = Signal()
- isTargetPointChanged = Signal()
- isTakeoffChanged = Signal()
- isLandingChanged = Signal()
- isDivertChanged = Signal()
- isBullseyeChanged = Signal()
-
- def __init__(
- self,
- waypoint: FlightWaypoint,
- flight_model: FlightJs,
- theater: ConflictTheater,
- ato_model: AtoModel,
- ) -> None:
- super().__init__()
- self.waypoint = waypoint
- self.flight_model = flight_model
- self.theater = theater
- self.ato_model = ato_model
-
- @property
- def flight(self) -> Flight:
- return self.flight_model.flight
-
- @property
- def flight_plan(self) -> FlightPlan:
- return self.flight.flight_plan
-
- @Property(list, notify=positionChanged)
- def position(self) -> LeafletLatLon:
- ll = self.theater.point_to_ll(self.waypoint.position)
- return [ll.latitude, ll.longitude]
-
- @Property(int, notify=altitudeFtChanged)
- def altitudeFt(self) -> int:
- return int(self.waypoint.alt.feet)
-
- @Property(str, notify=altitudeReferenceChanged)
- def altitudeReference(self) -> str:
- return "AGL" if self.waypoint.alt_type == "RADIO" else "MSL"
-
- @Property(str, notify=nameChanged)
- def name(self) -> str:
- return self.waypoint.name
-
- @Property(str, notify=timingChanged)
- def timing(self) -> str:
- prefix = "TOT"
- time = self.flight_plan.tot_for_waypoint(self.waypoint)
- if time is None:
- prefix = "Depart"
- time = self.flight_plan.depart_time_for_waypoint(self.waypoint)
- if time is None:
- return ""
- return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}"
-
- @Property(bool, notify=isTargetPointChanged)
- def isTargetPoint(self) -> bool:
- return self.waypoint.waypoint_type is FlightWaypointType.TARGET_POINT
-
- @Property(bool, notify=isTakeoffChanged)
- def isTakeoff(self) -> bool:
- return self.waypoint.waypoint_type is FlightWaypointType.TAKEOFF
-
- @Property(bool, notify=isLandingChanged)
- def isLanding(self) -> bool:
- return self.waypoint.waypoint_type is FlightWaypointType.LANDING_POINT
-
- @Property(bool, notify=isDivertChanged)
- def isDivert(self) -> bool:
- return self.waypoint.waypoint_type is FlightWaypointType.DIVERT
-
- @Property(bool, notify=isBullseyeChanged)
- def isBullseye(self) -> bool:
- return self.waypoint.waypoint_type is FlightWaypointType.BULLSEYE
-
- @Slot(list, result=str)
- def setPosition(self, position: LeafletLatLon) -> str:
- point = self.theater.ll_to_point(LatLon(*position))
- self.waypoint.x = point.x
- self.waypoint.y = point.y
- package = self.ato_model.find_matching_package_model(self.flight.package)
- if package is None:
- return "Could not find package model containing modified flight"
- package.update_tot()
- self.positionChanged.emit()
- self.flight_model.commitBoundaryChanged.emit()
- return ""
diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py
index 1cc645d3..dd53510e 100644
--- a/qt_ui/windows/QLiberationWindow.py
+++ b/qt_ui/windows/QLiberationWindow.py
@@ -20,7 +20,7 @@ from PySide2.QtWidgets import (
import qt_ui.uiconstants as CONST
from game import Game, VERSION, persistency
from game.debriefing import Debriefing
-from game.server import EventStream
+from game.server import EventStream, GameContext
from game.server.security import ApiKeyManager
from qt_ui import liberation_install
from qt_ui.dialogs import Dialog
@@ -54,6 +54,7 @@ class QLiberationWindow(QMainWindow):
self.sim_controller = SimController(self.game)
self.sim_controller.sim_update.connect(EventStream.put_nowait)
self.game_model = GameModel(game, self.sim_controller)
+ GameContext.set_model(self.game_model)
Dialog.set_game(self.game_model)
self.ato_panel = QAirTaskingOrderPanel(self.game_model)
self.info_panel = QInfoPanel(self.game)
diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js
index ab981ad9..b23540a0 100644
--- a/resources/ui/map/map.js
+++ b/resources/ui/map/map.js
@@ -3,6 +3,8 @@ const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
const HTTP_BACKEND = "http://[::1]:5000";
const WS_BACKEND = "ws://[::1]:5000/eventstream";
+METERS_TO_FEET = 3.28084;
+
// Uniquely generated at startup and passed to use by the QWebChannel.
var API_KEY = null;
@@ -14,6 +16,17 @@ function getJson(endpoint) {
}).then((response) => response.json());
}
+function postJson(endpoint, data) {
+ return fetch(`${HTTP_BACKEND}${endpoint}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-API-Key": API_KEY,
+ },
+ body: JSON.stringify(data),
+ }).then((response) => response.json());
+}
+
const Colors = Object.freeze({
Blue: "#0084ff",
Red: "#c85050",
@@ -752,79 +765,50 @@ class Waypoint {
this.number = number;
this.flight = flight;
this.marker = this.makeMarker();
- this.waypoint.positionChanged.connect(() => this.relocate());
- this.waypoint.timingChanged.connect(() => this.updateDescription());
}
position() {
- return this.waypoint.position;
+ return this.waypoint.latlng;
}
shouldMark() {
- // We don't need a marker for the departure waypoint (and it's likely
- // coincident with the landing waypoint, so hard to see). We do want to draw
- // the path from it though.
- //
- // We also don't need the landing waypoint since we'll be drawing that path
- // as well and it's clear what it is, and only obscured the CP icon.
- //
- // The divert waypoint also obscures the CP. We don't draw the path to it,
- // but it can be seen in the flight settings page so it's not really a
- // problem to exclude it.
- //
- // Bullseye ought to be (but currently isn't) drawn *once* rather than as a
- // flight waypoint.
- return !(
- this.waypoint.isTakeoff ||
- this.waypoint.isLanding ||
- this.waypoint.isDivert ||
- this.waypoint.isBullseye
- );
+ return this.waypoint.should_mark;
}
- draggable() {
- // Target *points* are the exact location of a unit, whereas the target area
- // is only the center of the objective. Allow moving the latter since its
- // exact location isn't very important.
- //
- // Landing, and divert should be changed in the flight settings UI, takeoff
- // cannot be changed because that's where the plane is.
- //
- // Moving the bullseye reference only makes it wrong.
- return !(
- this.waypoint.isTargetPoint ||
- this.waypoint.isTakeoff ||
- this.waypoint.isLanding ||
- this.waypoint.isDivert ||
- this.waypoint.isBullseye
- );
+ async timing(dragging) {
+ if (dragging) {
+ return "Waiting to recompute TOT...";
+ }
+ return await getJson(`/waypoints/${this.flight.id}/${this.number}/timing`);
}
- description(dragging) {
- const timing = dragging
- ? "Waiting to recompute TOT..."
- : this.waypoint.timing;
+ async description(dragging) {
+ const alt = Math.floor(
+ this.waypoint.alt.distance_in_meters * METERS_TO_FEET
+ );
+ const altRef = this.waypoint.alt_type == "BARO" ? "MSL" : "AGL";
return (
`${this.number} ${this.waypoint.name}
` +
- `${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}
` +
- `${timing}`
+ `${alt} ft ${altRef}
` +
+ `${await this.timing(dragging)}`
);
}
relocate() {
- this.marker.setLatLng(this.waypoint.position);
+ this.marker.setLatLng(this.position());
}
updateDescription(dragging) {
- this.marker.setTooltipContent(this.description(dragging));
+ this.description(dragging).then((description) => {
+ this.marker.setTooltipContent(description);
+ });
}
makeMarker() {
const zoom = map.getZoom();
- return L.marker(this.waypoint.position, { draggable: this.draggable() })
- .bindTooltip(this.description(), {
- permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM,
- })
+ const marker = L.marker(this.position(), {
+ draggable: this.waypoint.is_movable,
+ })
.on("dragstart", (e) => {
this.updateDescription(true);
})
@@ -836,19 +820,35 @@ class Waypoint {
.on("dragend", (e) => {
const marker = e.target;
const destination = marker.getLatLng();
- this.waypoint
- .setPosition([destination.lat, destination.lng])
- .then((err) => {
+ postJson(
+ `/waypoints/${this.flight.id}/${this.number}/position`,
+ destination
+ )
+ .then(() => {
+ this.waypoint.position = destination;
+ this.updateDescription(false);
+ this.flight.drawCommitBoundary();
+ })
+ .catch((err) => {
if (err) {
+ this.relocate();
console.log(err);
- marker.bindPopup(err);
+ marker.bindPopup(`${err}`).openPopup();
}
});
});
+
+ this.description(false).then((description) =>
+ marker.bindTooltip(description, {
+ permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM,
+ })
+ );
+
+ return marker;
}
includeInPath() {
- return !this.waypoint.isDivert && !this.waypoint.isBullseye;
+ return this.waypoint.include_in_path;
}
}
@@ -858,16 +858,11 @@ class Flight {
constructor(flight) {
this.flight = flight;
this.id = flight.id;
- this.flightPlan = this.flight.flightPlan.map(
- (p, idx) => new Waypoint(p, idx, this)
- );
this.aircraft = null;
this.path = null;
this.markers = [];
this.commitBoundary = null;
this.flight.selectedChanged.connect(() => this.draw());
- this.flight.flightPlanChanged.connect(() => this.drawFlightPlan());
- this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary());
Flight.registerFlight(this);
}
@@ -948,16 +943,18 @@ class Flight {
.removeFrom(allFlightPlansLayer);
}
if (this.flight.selected) {
- if (this.flight.commitBoundary) {
- this.commitBoundary = L.polyline(this.flight.commitBoundary, {
- color: Colors.Highlight,
- weight: 1,
- interactive: false,
- })
- .addTo(selectedFlightPlansLayer)
- .addTo(this.flightPlanLayer())
- .addTo(allFlightPlansLayer);
- }
+ getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => {
+ if (boundary) {
+ this.commitBoundary = L.polyline(boundary, {
+ color: Colors.Highlight,
+ weight: 1,
+ interactive: false,
+ })
+ .addTo(selectedFlightPlansLayer)
+ .addTo(this.flightPlanLayer())
+ .addTo(allFlightPlansLayer);
+ }
+ });
}
}
@@ -993,21 +990,24 @@ class Flight {
// ATO before drawing.
return;
}
- const path = [];
- this.flightPlan.forEach((waypoint) => {
- if (waypoint.includeInPath()) {
- path.push(waypoint.position());
- }
- if (this.shouldMark(waypoint)) {
- waypoint.marker
- .addTo(selectedFlightPlansLayer)
- .addTo(this.flightPlanLayer())
- .addTo(allFlightPlansLayer);
- this.markers.push(waypoint.marker);
- }
- });
- this.drawPath(path);
+ getJson(`/waypoints/${this.flight.id}`).then((waypoints) => {
+ const path = [];
+ waypoints.map((raw, idx) => {
+ const waypoint = new Waypoint(raw, idx, this);
+ if (waypoint.includeInPath()) {
+ path.push(waypoint.position());
+ }
+ if (this.shouldMark(waypoint)) {
+ waypoint.marker
+ .addTo(selectedFlightPlansLayer)
+ .addTo(this.flightPlanLayer())
+ .addTo(allFlightPlansLayer);
+ this.markers.push(waypoint.marker);
+ }
+ });
+ this.drawPath(path);
+ });
});
}
}