Move waypoints and commit boundaries to FastAPI.

This commit is contained in:
Dan Albert 2022-02-19 18:25:45 -08:00
parent b533633494
commit c16ca40894
25 changed files with 360 additions and 328 deletions

View File

@ -98,6 +98,10 @@ class Flight:
state["id"] = uuid.uuid4() state["id"] = uuid.uuid4()
self.__dict__.update(state) self.__dict__.update(state)
@property
def blue(self) -> bool:
return self.squadron.player
@property @property
def departure(self) -> ControlPoint: def departure(self) -> ControlPoint:
return self.squadron.location return self.squadron.location

View File

@ -1,20 +1,115 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from dataclasses import field
from datetime import timedelta from datetime import timedelta
from typing import Optional, Sequence, TYPE_CHECKING, Union from typing import Optional, TYPE_CHECKING
from dcs import Point from dcs import Point
from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit from dcs.unit import Unit
from pydantic.dataclasses import dataclass
from game.utils import Distance, meters
from game.ato.flightwaypointtype import FlightWaypointType from game.ato.flightwaypointtype import FlightWaypointType
from game.theater import LatLon
from game.utils import Distance, meters
if TYPE_CHECKING: 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__( def __init__(
self, self,
waypoint_type: FlightWaypointType, waypoint_type: FlightWaypointType,
@ -34,32 +129,10 @@ class FlightWaypoint:
control_point: The control point to associate with this waypoint. Needed for control_point: The control point to associate with this waypoint. Needed for
landing points. landing points.
""" """
self.waypoint_type = waypoint_type super().__init__(
self.x = x name="", waypoint_type=waypoint_type, x=x, y=y, alt=alt, alt_type="BARO"
self.y = y )
self.alt = alt
self.control_point = control_point 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 def __hash__(self) -> int:
# generation). We do it late so that we don't need to propagate changes return hash(id(self))
# 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)

View File

@ -1,7 +1,8 @@
from enum import Enum from enum import IntEnum, unique
class FlightWaypointType(Enum): @unique
class FlightWaypointType(IntEnum):
"""Enumeration of waypoint types. """Enumeration of waypoint types.
The value of the enum has no meaning but should remain stable to prevent breaking The value of the enum has no meaning but should remain stable to prevent breaking

View File

@ -187,9 +187,9 @@ class KneeboardPage:
@staticmethod @staticmethod
def format_ll(ll: LatLon) -> str: def format_ll(ll: LatLon) -> str:
ns = "N" if ll.latitude >= 0 else "S" ns = "N" if ll.lat >= 0 else "S"
ew = "E" if ll.longitude >= 0 else "W" ew = "E" if ll.lng >= 0 else "W"
return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}" return f"{ll.lat:.4}°{ns} {ll.lng:.4}°{ew}"
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -1,8 +1,10 @@
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from . import debuggeometries, eventstream from . import debuggeometries, eventstream, flights, waypoints
from .security import ApiKeyManager from .security import ApiKeyManager
app = FastAPI(dependencies=[Depends(ApiKeyManager.verify)]) app = FastAPI(dependencies=[Depends(ApiKeyManager.verify)])
app.include_router(debuggeometries.router) app.include_router(debuggeometries.router)
app.include_router(eventstream.router) app.include_router(eventstream.router)
app.include_router(flights.router)
app.include_router(waypoints.router)

View File

@ -1,15 +1,20 @@
from game import Game from game import Game
from qt_ui.models import GameModel
class GameContext: class GameContext:
_game: Game | None _game_model: GameModel
@classmethod @classmethod
def set(cls, game: Game | None) -> None: def set_model(cls, game_model: GameModel) -> None:
cls._game = game cls._game_model = game_model
@classmethod @classmethod
def get(cls) -> Game: def get(cls) -> Game:
if cls._game is None: if cls._game_model.game is None:
raise RuntimeError("GameContext has no Game set") 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

View File

@ -0,0 +1 @@
from .routes import router

View File

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

View File

@ -0,0 +1 @@
from .routes import router

View File

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

View File

@ -4,17 +4,17 @@ import datetime
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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.mapping import Point
from dcs.terrain import ( from dcs.terrain import (
caucasus, caucasus,
marianaislands,
nevada, nevada,
normandy, normandy,
persiangulf, persiangulf,
syria, syria,
thechannel, thechannel,
marianaislands,
) )
from dcs.terrain.terrain import Terrain from dcs.terrain.terrain import Terrain
from pyproj import CRS, Transformer from pyproj import CRS, Transformer
@ -262,7 +262,7 @@ class ConflictTheater:
return LatLon(lat, lon) return LatLon(lat, lon)
def ll_to_point(self, ll: LatLon) -> Point: 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) return Point(x, y)

View File

@ -1,14 +1,16 @@
from dataclasses import dataclass
from typing import List, Tuple from typing import List, Tuple
from pydantic.dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class LatLon: class LatLon:
latitude: float # These names match Leaflet for easier interop.
longitude: float lat: float
lng: float
def as_list(self) -> List[float]: def as_list(self) -> List[float]:
return [self.latitude, self.longitude] return [self.lat, self.lng]
@staticmethod @staticmethod
def _components(dimension: float) -> Tuple[int, int, float]: def _components(dimension: float) -> Tuple[int, int, float]:
@ -28,7 +30,7 @@ class LatLon:
precision = 2 if include_decimal_seconds else 0 precision = 2 if include_decimal_seconds else 0
return " ".join( return " ".join(
[ [
self._format_component(self.latitude, ("N", "S"), precision), self._format_component(self.lat, ("N", "S"), precision),
self._format_component(self.longitude, ("E", "W"), precision), self._format_component(self.lng, ("E", "W"), precision),
] ]
) )

View File

@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
import itertools import itertools
import math import math
import random import random
from abc import ABC, abstractmethod
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass
from typing import TypeVar, Union from typing import TypeVar, Union
from dcs import Point from dcs import Point
from pydantic.dataclasses import dataclass
from shapely.geometry import Point as ShapelyPoint from shapely.geometry import Point as ShapelyPoint
METERS_TO_FEET = 3.28084 METERS_TO_FEET = 3.28084

View File

@ -15,6 +15,7 @@ warn_redundant_casts = True
# warn_return_any = True # warn_return_any = True
warn_unreachable = True warn_unreachable = True
warn_unused_ignores = True warn_unused_ignores = True
plugins = pydantic.mypy
[mypy-faker.*] [mypy-faker.*]
ignore_missing_imports = True ignore_missing_imports = True

View File

@ -18,7 +18,7 @@ from game.data.weapons import Pylon, Weapon, WeaponGroup
from game.dcs.aircrafttype import AircraftType from game.dcs.aircrafttype import AircraftType
from game.factions import FACTIONS from game.factions import FACTIONS
from game.profiling import logged_duration 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.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
from pydcs_extensions import load_mods from pydcs_extensions import load_mods
@ -137,7 +137,6 @@ def run_ui(game: Optional[Game]) -> None:
# Apply CSS (need works) # Apply CSS (need works)
GameUpdateSignal() GameUpdateSignal()
GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set)
GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain) GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain)
# Start window # Start window

View File

@ -529,3 +529,8 @@ class GameModel:
self.game = game self.game = game
self.ato_model.replace_from_game(player=True) self.ato_model.replace_from_game(player=True)
self.red_ato_model.replace_from_game(player=False) 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

View File

@ -62,7 +62,7 @@ class ControlPointJs(QObject):
@Property(list, notify=positionChanged) @Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon: def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.control_point.position) ll = self.theater.point_to_ll(self.control_point.position)
return [ll.latitude, ll.longitude] return [ll.lat, ll.lng]
@Property(bool, notify=mobileChanged) @Property(bool, notify=mobileChanged)
def mobile(self) -> bool: def mobile(self) -> bool:

View File

@ -1,41 +1,19 @@
from __future__ import annotations from __future__ import annotations
from typing import List
from PySide2.QtCore import Property, QObject, Signal, Slot 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.flightstate import InFlight
from game.ato.flightwaypointtype import FlightWaypointType from game.server.leaflet import LeafletLatLon
from game.server.leaflet import LeafletLatLon, LeafletPoly, ShapelyUtil
from game.theater import ConflictTheater from game.theater import ConflictTheater
from game.utils import meters
from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan
from qt_ui.models import AtoModel from qt_ui.models import AtoModel
from .waypointjs import WaypointJs
class FlightJs(QObject): class FlightJs(QObject):
idChanged = Signal() idChanged = Signal()
positionChanged = Signal() positionChanged = Signal()
flightPlanChanged = Signal()
blueChanged = Signal() blueChanged = Signal()
selectedChanged = 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__( def __init__(
self, self,
@ -49,42 +27,18 @@ class FlightJs(QObject):
self._selected = selected self._selected = selected
self.theater = theater self.theater = theater
self.ato_model = ato_model self.ato_model = ato_model
self._waypoints = self.make_waypoints()
@Property(str, notify=idChanged) @Property(str, notify=idChanged)
def id(self) -> str: def id(self) -> str:
return str(self.flight.id) 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) @Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon: def position(self) -> LeafletLatLon:
if isinstance(self.flight.state, InFlight): if isinstance(self.flight.state, InFlight):
ll = self.theater.point_to_ll(self.flight.state.estimate_position()) ll = self.theater.point_to_ll(self.flight.state.estimate_position())
return [ll.latitude, ll.longitude] return [ll.lat, ll.lng]
return [] return []
@Property(list, notify=flightPlanChanged)
def flightPlan(self) -> List[WaypointJs]:
return self._waypoints
@Property(bool, notify=blueChanged) @Property(bool, notify=blueChanged)
def blue(self) -> bool: def blue(self) -> bool:
return self.flight.departure.captured return self.flight.departure.captured
@ -104,24 +58,3 @@ class FlightJs(QObject):
def set_selected(self, value: bool) -> None: def set_selected(self, value: bool) -> None:
self._selected = value self._selected = value
self.selectedChanged.emit() 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)

View File

@ -30,7 +30,7 @@ class FrontLineJs(QObject):
self.front_line.attack_heading.left.degrees, nautical_miles(2).meters 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() @Slot()
def showPackageDialog(self) -> None: def showPackageDialog(self) -> None:

View File

@ -101,7 +101,7 @@ class GroundObjectJs(QObject):
@Property(list, notify=positionChanged) @Property(list, notify=positionChanged)
def position(self) -> LeafletLatLon: def position(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.tgo.position) ll = self.theater.point_to_ll(self.tgo.position)
return [ll.latitude, ll.longitude] return [ll.lat, ll.lng]
@Property(bool, notify=deadChanged) @Property(bool, notify=deadChanged)
def dead(self) -> bool: def dead(self) -> bool:

View File

@ -160,7 +160,7 @@ class MapModel(QObject):
@staticmethod @staticmethod
def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon: def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon:
ll = theater.point_to_ll(point) ll = theater.point_to_ll(point)
return [ll.latitude, ll.longitude] return [ll.lat, ll.lng]
def reset(self) -> None: def reset(self) -> None:
if self.game_model.game is None: if self.game_model.game is None:
@ -183,7 +183,7 @@ class MapModel(QObject):
def reset_map_center(self, theater: ConflictTheater) -> None: def reset_map_center(self, theater: ConflictTheater) -> None:
ll = theater.point_to_ll(theater.terrain.map_view_default.position) 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) self.mapCenterChanged.emit(self._map_center)
@Property(str, notify=apiKeyChanged) @Property(str, notify=apiKeyChanged)

View File

@ -30,5 +30,5 @@ class UnculledZone(QObject):
for zone in game.get_culling_zones(): for zone in game.get_culling_zones():
ll = game.theater.point_to_ll(zone) ll = game.theater.point_to_ll(zone)
yield UnculledZone( yield UnculledZone(
[ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000 [ll.lat, ll.lng], game.settings.perf_culling_distance * 1000
) )

View File

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

View File

@ -20,7 +20,7 @@ from PySide2.QtWidgets import (
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game import Game, VERSION, persistency from game import Game, VERSION, persistency
from game.debriefing import Debriefing from game.debriefing import Debriefing
from game.server import EventStream from game.server import EventStream, GameContext
from game.server.security import ApiKeyManager from game.server.security import ApiKeyManager
from qt_ui import liberation_install from qt_ui import liberation_install
from qt_ui.dialogs import Dialog from qt_ui.dialogs import Dialog
@ -54,6 +54,7 @@ class QLiberationWindow(QMainWindow):
self.sim_controller = SimController(self.game) self.sim_controller = SimController(self.game)
self.sim_controller.sim_update.connect(EventStream.put_nowait) self.sim_controller.sim_update.connect(EventStream.put_nowait)
self.game_model = GameModel(game, self.sim_controller) self.game_model = GameModel(game, self.sim_controller)
GameContext.set_model(self.game_model)
Dialog.set_game(self.game_model) Dialog.set_game(self.game_model)
self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model)
self.info_panel = QInfoPanel(self.game) self.info_panel = QInfoPanel(self.game)

View File

@ -3,6 +3,8 @@ const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
const HTTP_BACKEND = "http://[::1]:5000"; const HTTP_BACKEND = "http://[::1]:5000";
const WS_BACKEND = "ws://[::1]:5000/eventstream"; const WS_BACKEND = "ws://[::1]:5000/eventstream";
METERS_TO_FEET = 3.28084;
// Uniquely generated at startup and passed to use by the QWebChannel. // Uniquely generated at startup and passed to use by the QWebChannel.
var API_KEY = null; var API_KEY = null;
@ -14,6 +16,17 @@ function getJson(endpoint) {
}).then((response) => response.json()); }).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({ const Colors = Object.freeze({
Blue: "#0084ff", Blue: "#0084ff",
Red: "#c85050", Red: "#c85050",
@ -752,79 +765,50 @@ class Waypoint {
this.number = number; this.number = number;
this.flight = flight; this.flight = flight;
this.marker = this.makeMarker(); this.marker = this.makeMarker();
this.waypoint.positionChanged.connect(() => this.relocate());
this.waypoint.timingChanged.connect(() => this.updateDescription());
} }
position() { position() {
return this.waypoint.position; return this.waypoint.latlng;
} }
shouldMark() { shouldMark() {
// We don't need a marker for the departure waypoint (and it's likely return this.waypoint.should_mark;
// 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
);
} }
draggable() { async timing(dragging) {
// Target *points* are the exact location of a unit, whereas the target area if (dragging) {
// is only the center of the objective. Allow moving the latter since its return "Waiting to recompute TOT...";
// exact location isn't very important. }
// return await getJson(`/waypoints/${this.flight.id}/${this.number}/timing`);
// 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
);
} }
description(dragging) { async description(dragging) {
const timing = dragging const alt = Math.floor(
? "Waiting to recompute TOT..." this.waypoint.alt.distance_in_meters * METERS_TO_FEET
: this.waypoint.timing; );
const altRef = this.waypoint.alt_type == "BARO" ? "MSL" : "AGL";
return ( return (
`${this.number} ${this.waypoint.name}<br />` + `${this.number} ${this.waypoint.name}<br />` +
`${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}<br />` + `${alt} ft ${altRef}<br />` +
`${timing}` `${await this.timing(dragging)}`
); );
} }
relocate() { relocate() {
this.marker.setLatLng(this.waypoint.position); this.marker.setLatLng(this.position());
} }
updateDescription(dragging) { updateDescription(dragging) {
this.marker.setTooltipContent(this.description(dragging)); this.description(dragging).then((description) => {
this.marker.setTooltipContent(description);
});
} }
makeMarker() { makeMarker() {
const zoom = map.getZoom(); const zoom = map.getZoom();
return L.marker(this.waypoint.position, { draggable: this.draggable() }) const marker = L.marker(this.position(), {
.bindTooltip(this.description(), { draggable: this.waypoint.is_movable,
permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM, })
})
.on("dragstart", (e) => { .on("dragstart", (e) => {
this.updateDescription(true); this.updateDescription(true);
}) })
@ -836,19 +820,35 @@ class Waypoint {
.on("dragend", (e) => { .on("dragend", (e) => {
const marker = e.target; const marker = e.target;
const destination = marker.getLatLng(); const destination = marker.getLatLng();
this.waypoint postJson(
.setPosition([destination.lat, destination.lng]) `/waypoints/${this.flight.id}/${this.number}/position`,
.then((err) => { destination
)
.then(() => {
this.waypoint.position = destination;
this.updateDescription(false);
this.flight.drawCommitBoundary();
})
.catch((err) => {
if (err) { if (err) {
this.relocate();
console.log(err); 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() { includeInPath() {
return !this.waypoint.isDivert && !this.waypoint.isBullseye; return this.waypoint.include_in_path;
} }
} }
@ -858,16 +858,11 @@ class Flight {
constructor(flight) { constructor(flight) {
this.flight = flight; this.flight = flight;
this.id = flight.id; this.id = flight.id;
this.flightPlan = this.flight.flightPlan.map(
(p, idx) => new Waypoint(p, idx, this)
);
this.aircraft = null; this.aircraft = null;
this.path = null; this.path = null;
this.markers = []; this.markers = [];
this.commitBoundary = null; this.commitBoundary = null;
this.flight.selectedChanged.connect(() => this.draw()); this.flight.selectedChanged.connect(() => this.draw());
this.flight.flightPlanChanged.connect(() => this.drawFlightPlan());
this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary());
Flight.registerFlight(this); Flight.registerFlight(this);
} }
@ -948,16 +943,18 @@ class Flight {
.removeFrom(allFlightPlansLayer); .removeFrom(allFlightPlansLayer);
} }
if (this.flight.selected) { if (this.flight.selected) {
if (this.flight.commitBoundary) { getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => {
this.commitBoundary = L.polyline(this.flight.commitBoundary, { if (boundary) {
color: Colors.Highlight, this.commitBoundary = L.polyline(boundary, {
weight: 1, color: Colors.Highlight,
interactive: false, weight: 1,
}) interactive: false,
.addTo(selectedFlightPlansLayer) })
.addTo(this.flightPlanLayer()) .addTo(selectedFlightPlansLayer)
.addTo(allFlightPlansLayer); .addTo(this.flightPlanLayer())
} .addTo(allFlightPlansLayer);
}
});
} }
} }
@ -993,21 +990,24 @@ class Flight {
// ATO before drawing. // ATO before drawing.
return; 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);
});
}); });
} }
} }