Dan Albert b5e5a3b2da Plan waypoint TOTs.
Also fixes the CAP racetracks so the AI actually stays on station.

Waypoint TOT assignment happens at mission generation time for the
sake of the UI. It's a bit messy since we have the late-initialized
field in FlightWaypoint, but on the other hand we don't have to reset
every extant waypoint whenever the player adjusts the mission's TOT.

If we want to clean this up a bit more, we could have two distinct
types for waypoints: one for the planning stage and one with the
resolved TOTs. We already do some thing like this with Flight vs
FlightData.

Future improvements:

* Estimate the group's ground speed so we don't need such wide margins
  of error.
* Delay takeoff to cut loiter fuel cost.
* Plan mission TOT based on the aircraft in the package and their
  travel times to the objective.
* Tune target area time prediction. Flights often don't need to travel
  all the way to the target point, and probably won't be doing it
  slowly, so the current planning causes a lot of extra time spent in
  enemy territory.
* Per-flight TOT offsets from the package to allow a sweep to arrive
  before the rest, etc.
2020-10-09 01:08:34 -07:00

278 lines
9.8 KiB
Python

"""Qt data models for game objects."""
import datetime
from enum import auto, IntEnum
from typing import Any, Callable, Dict, Iterator, TypeVar, Optional
from PySide2.QtCore import (
QAbstractListModel,
QModelIndex,
Qt,
Signal,
)
from PySide2.QtGui import QIcon
from game import db
from game.game import Game
from gen.ato import AirTaskingOrder, Package
from gen.flights.flight import Flight
from qt_ui.uiconstants import AIRCRAFT_ICONS
from theater.missiontarget import MissionTarget
class DeletableChildModelManager:
"""Manages lifetimes for child models.
Qt's data models don't have a good way of modeling related data aside from
lists, tables, or trees of similar objects. We could build one monolithic
GameModel that tracks all of the data in the game and use the parent/child
relationships of that model to index down into the ATO, packages, flights,
etc, but doing so is error prone because it requires us to manually manage
that relationship tree and keep our own mappings from row/column into
specific members.
However, creating child models outside of the tree means that removing an
item from the parent will not signal the child's deletion to any views, so
we must track this explicitly.
Any model which has child data types should use this class to track the
deletion of child models. All child model types must define a signal named
`deleted`. This signal will be emitted when the child model is being
deleted. Any views displaying such data should subscribe to those events and
update their display accordingly.
"""
#: The type of data owned by models created by this class.
DataType = TypeVar("DataType")
#: The type of model managed by this class.
ModelType = TypeVar("ModelType")
ModelDict = Dict[DataType, ModelType]
def __init__(self, create_model: Callable[[DataType], ModelType]) -> None:
self.create_model = create_model
self.models: DeletableChildModelManager.ModelDict = {}
def acquire(self, data: DataType) -> ModelType:
"""Returns a model for the given child data.
If a model has already been created for the given data, it will be
returned. The data type must be hashable.
"""
if data in self.models:
return self.models[data]
model = self.create_model(data)
self.models[data] = model
return model
def release(self, data: DataType) -> None:
"""Releases the model matching the given data, if one exists.
If the given data has had a model created for it, that model will be
deleted and its `deleted` signal will be emitted.
"""
if data in self.models:
model = self.models[data]
del self.models[data]
model.deleted.emit()
def clear(self) -> None:
"""Deletes all managed models."""
for data in list(self.models.keys()):
self.release(data)
class NullListModel(QAbstractListModel):
"""Generic empty list model."""
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
return None
class PackageModel(QAbstractListModel):
"""The model for an ATO package."""
#: Emitted when this package is being deleted from the ATO.
deleted = Signal()
def __init__(self, package: Package) -> None:
super().__init__()
self.package = package
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self.package.flights)
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
flight = self.flight_at_index(index)
if role == Qt.DisplayRole:
return self.text_for_flight(flight)
if role == Qt.DecorationRole:
return self.icon_for_flight(flight)
return None
@staticmethod
def text_for_flight(flight: Flight) -> str:
"""Returns the text that should be displayed for the flight."""
task = flight.flight_type.name
count = flight.count
name = db.unit_type_name(flight.unit_type)
delay = flight.scheduled_in
origin = flight.from_cp.name
return f"[{task}] {count} x {name} from {origin} in {delay} minutes"
@staticmethod
def icon_for_flight(flight: Flight) -> Optional[QIcon]:
"""Returns the icon that should be displayed for the flight."""
name = db.unit_type_name(flight.unit_type)
if name in AIRCRAFT_ICONS:
return QIcon(AIRCRAFT_ICONS[name])
return None
def add_flight(self, flight: Flight) -> None:
"""Adds the given flight to the package."""
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self.package.add_flight(flight)
self.endInsertRows()
def delete_flight_at_index(self, index: QModelIndex) -> None:
"""Removes the flight at the given index from the package."""
self.delete_flight(self.flight_at_index(index))
def delete_flight(self, flight: Flight) -> None:
"""Removes the given flight from the package.
If the flight is using claimed inventory, the caller is responsible for
returning that inventory.
"""
index = self.package.flights.index(flight)
self.beginRemoveRows(QModelIndex(), index, index)
self.package.remove_flight(flight)
self.endRemoveRows()
def flight_at_index(self, index: QModelIndex) -> Flight:
"""Returns the flight located at the given index."""
return self.package.flights[index.row()]
def update_tot(self, tot: int) -> None:
self.package.time_over_target = tot
@property
def mission_target(self) -> MissionTarget:
"""Returns the mission target of the package."""
package = self.package
target = package.target
return target
@property
def description(self) -> str:
"""Returns the description of the package."""
return self.package.package_description
@property
def flights(self) -> Iterator[Flight]:
"""Iterates over the flights in the package."""
for flight in self.package.flights:
yield flight
class AtoModel(QAbstractListModel):
"""The model for an AirTaskingOrder."""
PackageRole = Qt.UserRole
def __init__(self, game: Optional[Game], ato: AirTaskingOrder) -> None:
super().__init__()
self.game = game
self.ato = ato
self.package_models = DeletableChildModelManager(PackageModel)
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self.ato.packages)
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> Any:
if not index.isValid():
return None
package = self.ato.packages[index.row()]
if role == Qt.DisplayRole:
return f"{package.package_description} {package.target.name}"
elif role == AtoModel.PackageRole:
return package
return None
def add_package(self, package: Package) -> None:
"""Adds a package to the ATO."""
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self.ato.add_package(package)
self.endInsertRows()
def delete_package_at_index(self, index: QModelIndex) -> None:
"""Removes the package at the given index from the ATO."""
self.delete_package(self.package_at_index(index))
def delete_package(self, package: Package) -> None:
"""Removes the given package from the ATO."""
self.package_models.release(package)
index = self.ato.packages.index(package)
self.beginRemoveRows(QModelIndex(), index, index)
self.ato.remove_package(package)
for flight in package.flights:
self.game.aircraft_inventory.return_from_flight(flight)
self.endRemoveRows()
def package_at_index(self, index: QModelIndex) -> Package:
"""Returns the package at the given index."""
return self.ato.packages[index.row()]
def replace_from_game(self, game: Optional[Game]) -> None:
"""Updates the ATO object to match the updated game object.
If the game is None (as is the case when no game has been loaded), an
empty ATO will be used.
"""
self.beginResetModel()
self.game = game
self.package_models.clear()
if self.game is not None:
self.ato = game.blue_ato
else:
self.ato = AirTaskingOrder()
self.endResetModel()
def get_package_model(self, index: QModelIndex) -> PackageModel:
"""Returns a model for the package at the given index."""
return self.package_models.acquire(self.package_at_index(index))
@property
def packages(self) -> Iterator[PackageModel]:
"""Iterates over all the packages in the ATO."""
for package in self.ato.packages:
yield self.package_models.acquire(package)
class GameModel:
"""A model for the Game object.
This isn't a real Qt data model, but simplifies management of the game and
its ATO objects.
"""
def __init__(self) -> None:
self.game: Optional[Game] = None
# TODO: Add red ATO model, add cheat option to show red flight plan.
self.ato_model = AtoModel(self.game, AirTaskingOrder())
def set(self, game: Optional[Game]) -> None:
"""Updates the managed Game object.
The argument will be None when no game has been loaded. In this state,
much of the UI is still visible and needs to handle that behavior. To
simplify that case, the AtoModel will model an empty ATO when no game is
loaded.
"""
self.game = game
self.ato_model.replace_from_game(self.game)