Move front lines out of MapModel.

This commit is contained in:
Dan Albert 2022-03-03 01:52:53 -08:00
parent 89b987fc87
commit ccb510fe47
20 changed files with 252 additions and 87 deletions

View File

@ -4,8 +4,10 @@ from .database import Database
if TYPE_CHECKING:
from game.ato import Flight
from game.theater import FrontLine
class GameDb:
def __init__(self) -> None:
self.flights: Database[Flight] = Database()
self.front_lines: Database[FrontLine] = Database()

View File

@ -222,7 +222,7 @@ class Game:
# reset when we're done.
self.compute_threat_zones(GameUpdateEvents())
def finish_turn(self, skipped: bool = False) -> None:
def finish_turn(self, events: GameUpdateEvents, skipped: bool = False) -> None:
"""Finalizes the current turn and advances to the next turn.
This handles the turn-end portion of passing a turn. Initialization of the next
@ -265,6 +265,9 @@ class Game:
if not skipped:
for cp in self.theater.player_points():
for front_line in cp.front_lines.values():
front_line.update_position()
events.update_front_line(front_line)
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
self.conditions = self.generate_conditions()
@ -293,11 +296,12 @@ class Game:
from .server import EventStream
from .sim import GameUpdateEvents
events = GameUpdateEvents()
logging.info("Pass turn")
with logged_duration("Turn finalization"):
self.finish_turn(no_action)
self.finish_turn(events, no_action)
events = GameUpdateEvents()
with logged_duration("Turn initialization"):
self.initialize_turn(events)

View File

@ -9,6 +9,7 @@ from . import (
frontlines,
mapzones,
navmesh,
packagedialog,
supplyroutes,
tgos,
waypoints,
@ -28,6 +29,7 @@ app.include_router(flights.router)
app.include_router(frontlines.router)
app.include_router(mapzones.router)
app.include_router(navmesh.router)
app.include_router(packagedialog.router)
app.include_router(supplyroutes.router)
app.include_router(tgos.router)
app.include_router(waypoints.router)

View File

@ -1,6 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import Callable, TYPE_CHECKING
from game.theater import MissionTarget
if TYPE_CHECKING:
from game import Game
@ -23,3 +25,22 @@ class GameContext:
@classmethod
def get_model(cls) -> GameModel:
return cls._game_model
class QtCallbacks:
def __init__(self, create_new_package: Callable[[MissionTarget], None]) -> None:
self.create_new_package = create_new_package
class QtContext:
_callbacks: QtCallbacks
@classmethod
def set_callbacks(cls, callbacks: QtCallbacks) -> None:
cls._callbacks = callbacks
@classmethod
def get(cls) -> QtCallbacks:
if cls._callbacks is None:
raise RuntimeError("QtContext has no callbacks set")
return cls._callbacks

View File

@ -7,6 +7,7 @@ from pydantic import BaseModel
from game.server.combat.models import FrozenCombatJs
from game.server.flights.models import FlightJs
from game.server.frontlines.models import FrontLineJs
from game.server.leaflet import LeafletLatLon
if TYPE_CHECKING:
@ -27,6 +28,9 @@ class GameUpdateEventsJs(BaseModel):
deleted_flights: set[UUID]
selected_flight: UUID | None
deselected_flight: bool
new_front_lines: list[FrontLineJs]
updated_front_lines: set[UUID]
deleted_front_lines: set[UUID]
@classmethod
def from_events(cls, events: GameUpdateEvents, game: Game) -> GameUpdateEventsJs:
@ -53,4 +57,9 @@ class GameUpdateEventsJs(BaseModel):
deleted_flights=events.deleted_flights,
selected_flight=events.selected_flight,
deselected_flight=events.deselected_flight,
new_front_lines=[
FrontLineJs.for_front_line(f) for f in events.new_front_lines
],
updated_front_lines=events.updated_front_lines,
deleted_front_lines=events.deleted_front_lines,
)

View File

@ -1,9 +1,24 @@
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel
from game.server.leaflet import LeafletPoint
from game.theater import FrontLine
from game.utils import nautical_miles
class FrontLineJs(BaseModel):
id: UUID
extents: list[LeafletPoint]
@staticmethod
def for_front_line(front_line: FrontLine) -> FrontLineJs:
a = front_line.position.point_from_heading(
front_line.attack_heading.right.degrees, nautical_miles(2).meters
)
b = front_line.position.point_from_heading(
front_line.attack_heading.left.degrees, nautical_miles(2).meters
)
return FrontLineJs(id=front_line.id, extents=[a.latlng(), b.latlng()])

View File

@ -1,7 +1,8 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from game import Game
from game.utils import nautical_miles
from .models import FrontLineJs
from ..dependencies import GameContext
@ -10,13 +11,11 @@ router: APIRouter = APIRouter(prefix="/front-lines")
@router.get("/")
def list_front_lines(game: Game = Depends(GameContext.get)) -> list[FrontLineJs]:
front_lines = []
for front_line in game.theater.conflicts():
a = front_line.position.point_from_heading(
front_line.attack_heading.right.degrees, nautical_miles(2).meters
)
b = front_line.position.point_from_heading(
front_line.attack_heading.left.degrees, nautical_miles(2).meters
)
front_lines.append(FrontLineJs(extents=[a.latlng(), b.latlng()]))
return front_lines
return [FrontLineJs.for_front_line(f) for f in game.theater.conflicts()]
@router.get("/{front_line_id}")
def get_front_line(
front_line_id: UUID, game: Game = Depends(GameContext.get)
) -> FrontLineJs:
return FrontLineJs.for_front_line(game.db.front_lines.get(front_line_id))

View File

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

View File

@ -0,0 +1,18 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from game import Game
from ..dependencies import GameContext, QtCallbacks, QtContext
router: APIRouter = APIRouter(prefix="/package-dialog")
@router.post("/front-line/{front_line_id}")
def new_front_line_package(
front_line_id: UUID,
game: Game = Depends(GameContext.get),
qt: QtCallbacks = Depends(QtContext.get),
) -> None:
front_line = game.db.front_lines.get(front_line_id)
qt.create_new_package(front_line)

View File

@ -68,8 +68,9 @@ class GameLoop:
def complete_with_results(self, debriefing: Debriefing) -> None:
self.pause()
self.sim.process_results(debriefing)
self.sim.process_results(debriefing, self.events)
self.completed = True
self.send_update(rate_limit=False)
def send_update(self, rate_limit: bool) -> None:
# We don't skip empty events because we still want the tick in the Qt part of

View File

@ -9,6 +9,7 @@ from dcs import Point
if TYPE_CHECKING:
from game.ato import Flight, Package
from game.sim.combat import FrozenCombat
from game.theater import FrontLine
@dataclass
@ -26,6 +27,9 @@ class GameUpdateEvents:
deleted_flights: set[UUID] = field(default_factory=set)
selected_flight: UUID | None = None
deselected_flight: bool = False
new_front_lines: set[FrontLine] = field(default_factory=set)
updated_front_lines: set[UUID] = field(default_factory=set)
deleted_front_lines: set[UUID] = field(default_factory=set)
shutting_down: bool = False
@property
@ -95,6 +99,18 @@ class GameUpdateEvents:
self.selected_flight = None
return self
def new_front_line(self, front_line: FrontLine) -> GameUpdateEvents:
self.new_front_lines.add(front_line)
return self
def update_front_line(self, front_line: FrontLine) -> GameUpdateEvents:
self.updated_front_lines.add(front_line.id)
return self
def delete_front_line(self, front_line: FrontLine) -> GameUpdateEvents:
self.deleted_front_lines.add(front_line.id)
return self
def shut_down(self) -> GameUpdateEvents:
self.shutting_down = True
return self

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
from game.debriefing import Debriefing
from game.ground_forces.combat_stance import CombatStance
from game.theater import ControlPoint
from .gameupdateevents import GameUpdateEvents
from ..ato.airtaaskingorder import AirTaskingOrder
if TYPE_CHECKING:
@ -21,7 +22,7 @@ class MissionResultsProcessor:
def __init__(self, game: Game) -> None:
self.game = game
def commit(self, debriefing: Debriefing) -> None:
def commit(self, debriefing: Debriefing, events: GameUpdateEvents) -> None:
logging.info("Committing mission results")
self.commit_air_losses(debriefing)
self.commit_pilot_experience()
@ -31,8 +32,8 @@ class MissionResultsProcessor:
self.commit_airlift_losses(debriefing)
self.commit_ground_losses(debriefing)
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing)
self.commit_front_line_battle_impact(debriefing)
self.commit_captures(debriefing, events)
self.commit_front_line_battle_impact(debriefing, events)
self.record_carcasses(debriefing)
def commit_air_losses(self, debriefing: Debriefing) -> None:
@ -141,7 +142,7 @@ class MissionResultsProcessor:
for damaged_runway in debriefing.damaged_runways:
damaged_runway.damage_runway()
def commit_captures(self, debriefing: Debriefing) -> None:
def commit_captures(self, debriefing: Debriefing, events: GameUpdateEvents) -> None:
for captured in debriefing.base_captures:
try:
if captured.captured_by_player:
@ -155,7 +156,9 @@ class MissionResultsProcessor:
f"The enemy took control of {captured.control_point}.",
)
captured.control_point.capture(self.game, captured.captured_by_player)
captured.control_point.capture(
self.game, events, captured.captured_by_player
)
logging.info(f"Will run redeploy for {captured.control_point}")
self.redeploy_units(captured.control_point)
except Exception:
@ -165,10 +168,16 @@ class MissionResultsProcessor:
for destroyed_unit in debriefing.state_data.destroyed_statics:
self.game.add_destroyed_units(destroyed_unit)
def commit_front_line_battle_impact(self, debriefing: Debriefing) -> None:
def commit_front_line_battle_impact(
self, debriefing: Debriefing, events: GameUpdateEvents
) -> None:
for cp in self.game.theater.player_points():
enemy_cps = [e for e in cp.connected_points if not e.captured]
for enemy_cp in enemy_cps:
front_line = cp.front_line_with(enemy_cp)
front_line.update_position()
events.update_front_line(front_line)
print(
"Compute frontline progression for : "
+ cp.name

View File

@ -66,14 +66,14 @@ class MissionSimulation:
debriefing.merge_simulation_results(self.aircraft_simulation.results)
return debriefing
def process_results(self, debriefing: Debriefing) -> None:
def process_results(self, debriefing: Debriefing, events: GameUpdateEvents) -> None:
if self.unit_map is None:
raise RuntimeError(
"Simulation has no unit map. Results processing began before a mission "
"was generated."
)
MissionResultsProcessor(self.game).commit(debriefing)
MissionResultsProcessor(self.game).commit(debriefing, events)
def finish(self) -> None:
self.unit_map = None

View File

@ -52,6 +52,7 @@ from .theatergroundobject import (
from .theatergroup import TheaterUnit
from ..ato.starttype import StartType
from ..data.units import UnitClass
from ..db import Database
from ..dcs.aircrafttype import AircraftType
from ..dcs.groundunittype import GroundUnitType
from ..utils import nautical_miles
@ -59,10 +60,11 @@ from ..weather import Conditions
if TYPE_CHECKING:
from game import Game
from ..ato.flighttype import FlightType
from game.ato.flighttype import FlightType
from game.coalition import Coalition
from game.sim import GameUpdateEvents
from game.squadrons.squadron import Squadron
from ..coalition import Coalition
from ..transfers import PendingTransfers
from game.transfers import PendingTransfers
from .conflicttheater import ConflictTheater
FREE_FRONTLINE_UNIT_SUPPLY: int = 15
@ -326,6 +328,9 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
self.target_position: Optional[Point] = None
# Initialized late because ControlPoints are constructed before the game is.
self._front_line_db: Database[FrontLine] | None = None
def __repr__(self) -> str:
return f"<{self.__class__}: {self.name}>"
@ -338,31 +343,50 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
def finish_init(self, game: Game) -> None:
assert self._coalition is None
self._coalition = game.coalition_for(self.starts_blue)
assert self._front_line_db is None
self._front_line_db = game.db.front_lines
def initialize_turn_0(self) -> None:
self._recreate_front_lines()
# We don't need to send events for turn 0. The UI isn't up yet, and it'll fetch
# the entire game state when it comes up.
from game.sim import GameUpdateEvents
def _recreate_front_lines(self) -> None:
self._clear_front_lines()
self._create_missing_front_lines(GameUpdateEvents())
@property
def front_line_db(self) -> Database[FrontLine]:
assert self._front_line_db is not None
return self._front_line_db
def _create_missing_front_lines(self, events: GameUpdateEvents) -> None:
for connection in self.convoy_routes.keys():
if not connection.front_line_active_with(
self
) and not connection.is_friendly_to(self):
self._create_front_line_with(connection)
self._create_front_line_with(connection, events)
def _create_front_line_with(self, connection: ControlPoint) -> None:
def _create_front_line_with(
self, connection: ControlPoint, events: GameUpdateEvents
) -> None:
blue, red = FrontLine.sort_control_points(self, connection)
front = FrontLine(blue, red)
self.front_lines[connection] = front
connection.front_lines[self] = front
self.front_line_db.add(front.id, front)
events.new_front_line(front)
def _remove_front_line_with(self, connection: ControlPoint) -> None:
def _remove_front_line_with(
self, connection: ControlPoint, events: GameUpdateEvents
) -> None:
front = self.front_lines[connection]
del self.front_lines[connection]
del connection.front_lines[self]
self.front_line_db.remove(front.id)
events.delete_front_line(front)
def _clear_front_lines(self) -> None:
def _clear_front_lines(self, events: GameUpdateEvents) -> None:
for opponent in list(self.front_lines.keys()):
self._remove_front_line_with(opponent)
self._remove_front_line_with(opponent, events)
@property
def has_frontline(self) -> bool:
@ -713,7 +737,7 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
tgo.clear()
# TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None:
def capture(self, game: Game, events: GameUpdateEvents, for_player: bool) -> None:
new_coalition = game.coalition_for(for_player)
self.ground_unit_orders.refund_all(self.coalition)
self.retreat_ground_units(game)
@ -722,7 +746,8 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC):
self._coalition = new_coalition
self.base.set_strength_to_minimum()
self._recreate_front_lines()
self._clear_front_lines(events)
self._create_missing_front_lines(events)
@property
def required_aircraft_start_type(self) -> Optional[StartType]:
@ -1127,7 +1152,7 @@ class Carrier(NavalControlPoint):
FlightType.REFUELING,
]
def capture(self, game: Game, for_player: bool) -> None:
def capture(self, game: Game, events: GameUpdateEvents, for_player: bool) -> None:
raise RuntimeError("Carriers cannot be captured")
@property
@ -1161,7 +1186,7 @@ class Lha(NavalControlPoint):
def symbol_set_and_entity(self) -> tuple[SymbolSet, Entity]:
return SymbolSet.SEA_SURFACE, SeaSurfaceEntity.AMPHIBIOUS_ASSAULT_SHIP_GENERAL
def capture(self, game: Game, for_player: bool) -> None:
def capture(self, game: Game, events: GameUpdateEvents, for_player: bool) -> None:
raise RuntimeError("LHAs cannot be captured")
@property
@ -1198,7 +1223,7 @@ class OffMapSpawn(ControlPoint):
def symbol_set_and_entity(self) -> tuple[SymbolSet, Entity]:
return SymbolSet.LAND_INSTALLATIONS, LandInstallationEntity.AIPORT_AIR_BASE
def capture(self, game: Game, for_player: bool) -> None:
def capture(self, game: Game, events: GameUpdateEvents, for_player: bool) -> None:
raise RuntimeError("Off map control points cannot be captured")
def mission_types(self, for_player: bool) -> Iterator[FlightType]:

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from typing import Any, Iterator, List, TYPE_CHECKING, Tuple
@ -49,6 +50,7 @@ class FrontLine(MissionTarget):
blue_point: ControlPoint,
red_point: ControlPoint,
) -> None:
self.id = uuid.uuid4()
self.blue_cp = blue_point
self.red_cp = red_point
try:
@ -67,8 +69,7 @@ class FrontLine(MissionTarget):
FrontLineSegment(a, b) for a, b in pairwise(route)
]
super().__init__(
f"Front line {blue_point}/{red_point}",
self.point_from_a(self._position_distance),
f"Front line {blue_point}/{red_point}", self._compute_position()
)
def __eq__(self, other: Any) -> bool:
@ -79,10 +80,11 @@ class FrontLine(MissionTarget):
def __hash__(self) -> int:
return hash((self.blue_cp, self.red_cp))
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
if not hasattr(self, "position"):
self.position = self.point_from_a(self._position_distance)
def _compute_position(self) -> Point:
return self.point_from_a(self._position_distance)
def update_position(self) -> None:
self.position = self._compute_position()
def control_point_friendly_to(self, player: bool) -> ControlPoint:
if player:

View File

@ -1,32 +0,0 @@
from __future__ import annotations
from typing import List
from PySide2.QtCore import Property, QObject, Signal, Slot
from game.server.leaflet import LeafletLatLon
from game.theater import FrontLine
from game.utils import nautical_miles
from qt_ui.dialogs import Dialog
class FrontLineJs(QObject):
extentsChanged = Signal()
def __init__(self, front_line: FrontLine) -> None:
super().__init__()
self.front_line = front_line
@Property(list, notify=extentsChanged)
def extents(self) -> List[LeafletLatLon]:
a = self.front_line.position.point_from_heading(
self.front_line.attack_heading.right.degrees, nautical_miles(2).meters
).latlng()
b = self.front_line.position.point_from_heading(
self.front_line.attack_heading.left.degrees, nautical_miles(2).meters
).latlng()
return [a.as_list(), b.as_list()]
@Slot()
def showPackageDialog(self) -> None:
Dialog.open_new_package_dialog(self.front_line)

View File

@ -3,7 +3,7 @@ import traceback
import webbrowser
from typing import Optional
from PySide2.QtCore import QSettings, Qt
from PySide2.QtCore import QSettings, Qt, Signal
from PySide2.QtGui import QCloseEvent, QIcon
from PySide2.QtWidgets import (
QAction,
@ -22,7 +22,9 @@ from game import Game, VERSION, persistency
from game.debriefing import Debriefing
from game.layout import LAYOUTS
from game.server import EventStream, GameContext
from game.server.dependencies import QtCallbacks, QtContext
from game.server.security import ApiKeyManager
from game.theater import MissionTarget
from qt_ui import liberation_install
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel
@ -46,6 +48,8 @@ from qt_ui.windows.stats.QStatsWindow import QStatsWindow
class QLiberationWindow(QMainWindow):
new_package_signal = Signal(MissionTarget)
def __init__(self, game: Optional[Game], new_map: bool) -> None:
super().__init__()
@ -56,6 +60,12 @@ class QLiberationWindow(QMainWindow):
self.sim_controller.sim_update.connect(EventStream.put_nowait)
self.game_model = GameModel(game, self.sim_controller)
GameContext.set_model(self.game_model)
self.new_package_signal.connect(
lambda target: Dialog.open_new_package_dialog(target, self)
)
QtContext.set_callbacks(
QtCallbacks(lambda target: self.new_package_signal.emit(target))
)
Dialog.set_game(self.game_model)
self.ato_panel = QAirTaskingOrderPanel(self.game_model)
self.info_panel = QInfoPanel(self.game)

View File

@ -124,10 +124,10 @@ class QBaseMenu2(QDialog):
return self.game_model.game.settings.enable_base_capture_cheat
def cheat_capture(self) -> None:
self.cp.capture(self.game_model.game, for_player=not self.cp.captured)
events = GameUpdateEvents()
self.cp.capture(self.game_model.game, events, for_player=not self.cp.captured)
# Reinitialized ground planners and the like. The ATO needs to be reset because
# missions planned against the flipped base are no longer valid.
events = GameUpdateEvents()
self.game_model.game.initialize_turn(events)
EventStream.put_nowait(events)
GameUpdateSignal.get_instance().updateGame(self.game_model.game)

View File

@ -57,8 +57,10 @@ class QGroundForcesStrategy(QGroupBox):
amount *= -1
self.cp.base.affect_strength(amount)
enemy_point.base.affect_strength(-amount)
front_line = self.cp.front_line_with(enemy_point)
front_line.update_position()
events = GameUpdateEvents().update_front_line(front_line)
# Clear the ATO to replan missions affected by the front line.
events = GameUpdateEvents()
self.game.initialize_turn(events)
EventStream.put_nowait(events)
GameUpdateSignal.get_instance().updateGame(self.game)

View File

@ -308,6 +308,18 @@ function handleStreamedEvents(events) {
for (const flightId of events.deleted_flights) {
Flight.popId(flightId).clear();
}
for (const frontLine of events.new_front_lines) {
new FrontLine(frontLine).draw();
}
for (const id of events.updated_front_lines) {
FrontLine.withId(id).update();
}
for (const id of events.deleted_front_lines) {
FrontLine.popId(id).clear();
}
}
function recenterMap(center) {
@ -622,15 +634,64 @@ function drawSupplyRoutes() {
});
}
class FrontLine {
static registered = {};
constructor(frontLine) {
this.id = frontLine.id;
this.extents = frontLine.extents;
this.line = null;
FrontLine.register(this);
}
static register(frontLine) {
FrontLine.registered[frontLine.id] = frontLine;
}
static unregister(id) {
delete FrontLine.registered[id];
}
static withId(id) {
return FrontLine.registered[id];
}
static popId(id) {
const front = FrontLine.withId(id);
FrontLine.unregister(id);
return front;
}
update() {
getJson(`/front-lines/${this.id}`).then((frontLine) => {
this.extents = frontLine.extents;
this.draw();
});
}
clear() {
if (this.line != null) {
this.line.removeFrom(frontLinesLayer);
}
}
draw() {
this.clear();
this.line = L.polyline(this.extents, { weight: 8, color: "#fe7d0a" })
.on("contextmenu", () => this.openNewPackageDialog())
.addTo(frontLinesLayer);
}
openNewPackageDialog() {
postJson(`/package-dialog/front-line/${this.id}`);
}
}
function drawFrontLines() {
frontLinesLayer.clearLayers();
getJson("/front-lines").then((frontLines) => {
for (const front of frontLines) {
L.polyline(front.extents, { weight: 8, color: "#fe7d0a" })
.on("contextmenu", function () {
front.showPackageDialog();
})
.addTo(frontLinesLayer);
new FrontLine(front).draw();
}
});
}