mirror of
https://github.com/dcs-retribution/dcs-retribution.git
synced 2025-11-10 15:41:24 +00:00
Move waypoints and commit boundaries to FastAPI.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
1
game/server/flights/__init__.py
Normal file
1
game/server/flights/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import router
|
||||
34
game/server/flights/routes.py
Normal file
34
game/server/flights/routes.py
Normal 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)
|
||||
1
game/server/waypoints/__init__.py
Normal file
1
game/server/waypoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import router
|
||||
81
game/server/waypoints/routes.py
Normal file
81
game/server/waypoints/routes.py
Normal 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()))}"
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user