Handle map reset when the game is loaded/unloaded.

https://github.com/dcs-liberation/dcs_liberation/issues/2039

Partial fix for
https://github.com/dcs-liberation/dcs_liberation/issues/2045 (now works
in the new map, old one not fixed yet).
This commit is contained in:
Dan Albert 2022-03-05 18:02:46 -08:00
parent 995e28cb32
commit 73fcfcec7b
37 changed files with 403 additions and 178 deletions

View File

@ -1,17 +1,14 @@
import LiberationMap from "./components/liberationmap"; import LiberationMap from "./components/liberationmap";
import useEventStream from "./hooks/useEventSteam"; import useEventStream from "./hooks/useEventSteam";
import useInitialGameState from "./hooks/useInitialGameState"; import useInitialGameState from "./hooks/useInitialGameState";
import { LatLng } from "leaflet";
function App() { function App() {
const mapCenter: LatLng = new LatLng(25.58, 54.9);
useInitialGameState(); useInitialGameState();
useEventStream(); useEventStream();
return ( return (
<div className="App"> <div className="App">
<LiberationMap mapCenter={mapCenter} /> <LiberationMap />
</div> </div>
); );
} }

View File

@ -0,0 +1,5 @@
import Game from "./game";
import { createAction } from "@reduxjs/toolkit";
export const gameLoaded = createAction<Game>("game/loaded");
export const gameUnloaded = createAction("game/unloaded");

View File

@ -1,4 +1,5 @@
import { RootState } from "../app/store"; import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import { ControlPoint } from "./controlpoint"; import { ControlPoint } from "./controlpoint";
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
@ -14,21 +15,28 @@ export const controlPointsSlice = createSlice({
name: "controlPoints", name: "controlPoints",
initialState, initialState,
reducers: { reducers: {
setControlPoints: (state, action: PayloadAction<ControlPoint[]>) => {
state.controlPoints = {};
for (const cp of action.payload) {
state.controlPoints[cp.id] = cp;
}
},
updateControlPoint: (state, action: PayloadAction<ControlPoint>) => { updateControlPoint: (state, action: PayloadAction<ControlPoint>) => {
const cp = action.payload; const cp = action.payload;
state.controlPoints[cp.id] = cp; state.controlPoints[cp.id] = cp;
}, },
}, },
extraReducers: (builder) => {
builder.addCase(gameLoaded, (state, action) => {
state.controlPoints = action.payload.control_points.reduce(
(acc: { [key: number]: ControlPoint }, curr) => {
acc[curr.id] = curr;
return acc;
},
{}
);
});
builder.addCase(gameUnloaded, (state) => {
state.controlPoints = {};
});
},
}); });
export const { setControlPoints, updateControlPoint } = export const { updateControlPoint } = controlPointsSlice.actions;
controlPointsSlice.actions;
export const selectControlPoints = (state: RootState) => state.controlPoints; export const selectControlPoints = (state: RootState) => state.controlPoints;

View File

@ -1,4 +1,5 @@
import { AppDispatch } from "../app/store"; import { AppDispatch } from "../app/store";
import { gameUnloaded } from "./actions";
import backend from "./backend"; import backend from "./backend";
import Combat from "./combat"; import Combat from "./combat";
import { endCombat, newCombat, updateCombat } from "./combatSlice"; import { endCombat, newCombat, updateCombat } from "./combatSlice";
@ -19,6 +20,7 @@ import {
updateFrontLine, updateFrontLine,
} from "./frontLinesSlice"; } from "./frontLinesSlice";
import FrontLine from "./frontline"; import FrontLine from "./frontline";
import reloadGameState from "./gamestate";
import Tgo from "./tgo"; import Tgo from "./tgo";
import { updateTgo } from "./tgosSlice"; import { updateTgo } from "./tgosSlice";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
@ -41,6 +43,8 @@ interface GameUpdateEvents {
deleted_front_lines: string[]; deleted_front_lines: string[];
updated_tgos: string[]; updated_tgos: string[];
updated_control_points: number[]; updated_control_points: number[];
reset_on_map_center: LatLng | null;
game_unloaded: boolean;
} }
export const handleStreamedEvents = ( export const handleStreamedEvents = (
@ -114,4 +118,12 @@ export const handleStreamedEvents = (
dispatch(updateControlPoint(cp)); dispatch(updateControlPoint(cp));
}); });
} }
if (events.reset_on_map_center != null) {
reloadGameState(dispatch);
}
if (events.game_unloaded) {
dispatch(gameUnloaded());
}
}; };

View File

@ -1,4 +1,5 @@
import { RootState } from "../app/store"; import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import { Flight } from "./flight"; import { Flight } from "./flight";
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
@ -17,9 +18,6 @@ export const flightsSlice = createSlice({
name: "flights", name: "flights",
initialState, initialState,
reducers: { reducers: {
clearFlights: (state) => {
state.flights = {};
},
registerFlight: (state, action: PayloadAction<Flight>) => { registerFlight: (state, action: PayloadAction<Flight>) => {
const flight = action.payload; const flight = action.payload;
if (flight.id in state.flights) { if (flight.id in state.flights) {
@ -51,10 +49,25 @@ export const flightsSlice = createSlice({
} }
}, },
}, },
extraReducers: (builder) => {
builder.addCase(gameLoaded, (state, action) => {
state.selected = null;
state.flights = action.payload.flights.reduce(
(acc: { [key: string]: Flight }, curr) => {
acc[curr.id] = curr;
return acc;
},
{}
);
});
builder.addCase(gameUnloaded, (state) => {
state.selected = null;
state.flights = {};
});
},
}); });
export const { export const {
clearFlights,
registerFlight, registerFlight,
unregisterFlight, unregisterFlight,
updateFlight, updateFlight,

View File

@ -1,4 +1,5 @@
import { RootState } from "../app/store"; import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import FrontLine from "./frontline"; import FrontLine from "./frontline";
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
@ -14,12 +15,6 @@ export const frontLinesSlice = createSlice({
name: "frontLines", name: "frontLines",
initialState, initialState,
reducers: { reducers: {
setFrontLines: (state, action: PayloadAction<FrontLine[]>) => {
state.fronts = {};
for (const front of action.payload) {
state.fronts[front.id] = front;
}
},
addFrontLine: (state, action: PayloadAction<FrontLine>) => { addFrontLine: (state, action: PayloadAction<FrontLine>) => {
const front = action.payload; const front = action.payload;
state.fronts[front.id] = front; state.fronts[front.id] = front;
@ -32,9 +27,23 @@ export const frontLinesSlice = createSlice({
delete state.fronts[action.payload]; delete state.fronts[action.payload];
}, },
}, },
extraReducers: (builder) => {
builder.addCase(gameLoaded, (state, action) => {
state.fronts = action.payload.front_lines.reduce(
(acc: { [key: string]: FrontLine }, curr) => {
acc[curr.id] = curr;
return acc;
},
{}
);
});
builder.addCase(gameUnloaded, (state) => {
state.fronts = {};
});
},
}); });
export const { setFrontLines, addFrontLine, updateFrontLine, deleteFrontLine } = export const { addFrontLine, updateFrontLine, deleteFrontLine } =
frontLinesSlice.actions; frontLinesSlice.actions;
export const selectFrontLines = (state: RootState) => state.frontLines; export const selectFrontLines = (state: RootState) => state.frontLines;

15
client/src/api/game.ts Normal file
View File

@ -0,0 +1,15 @@
import { ControlPoint } from "./controlpoint";
import { Flight } from "./flight";
import FrontLine from "./frontline";
import SupplyRoute from "./supplyroute";
import Tgo from "./tgo";
import { LatLngLiteral } from "leaflet";
export default interface Game {
control_points: ControlPoint[];
tgos: Tgo[];
supply_routes: SupplyRoute[];
front_lines: FrontLine[];
flights: Flight[];
map_center: LatLngLiteral;
}

View File

@ -0,0 +1,17 @@
import { AppDispatch } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import backend from "./backend";
import Game from "./game";
export default function reloadGameState(dispatch: AppDispatch) {
backend
.get("/game")
.catch((error) => console.log(`Error fetching game state: ${error}`))
.then((response) => {
if (response == null || response.data == null) {
dispatch(gameUnloaded());
return;
}
dispatch(gameLoaded(response.data as Game));
});
}

View File

@ -0,0 +1,30 @@
import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import { createSlice } from "@reduxjs/toolkit";
import { LatLngLiteral } from "leaflet";
interface MapState {
center: LatLngLiteral;
}
const initialState: MapState = {
center: { lat: 0, lng: 0 },
};
const mapSlice = createSlice({
name: "map",
initialState: initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(gameLoaded, (state, action) => {
state.center = action.payload.map_center;
});
builder.addCase(gameUnloaded, (state) => {
state.center = { lat: 0, lng: 0 };
});
},
});
export const selectMapCenter = (state: RootState) => state.map.center;
export default mapSlice.reducer;

View File

@ -1,6 +1,7 @@
import { RootState } from "../app/store"; import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import SupplyRoute from "./supplyroute"; import SupplyRoute from "./supplyroute";
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
interface SupplyRoutesState { interface SupplyRoutesState {
routes: SupplyRoute[]; routes: SupplyRoute[];
@ -13,15 +14,17 @@ const initialState: SupplyRoutesState = {
export const supplyRoutesSlice = createSlice({ export const supplyRoutesSlice = createSlice({
name: "supplyRoutes", name: "supplyRoutes",
initialState, initialState,
reducers: { reducers: {},
setSupplyRoutes: (state, action: PayloadAction<SupplyRoute[]>) => { extraReducers: (builder) => {
state.routes = action.payload; builder.addCase(gameLoaded, (state, action) => {
}, state.routes = action.payload.supply_routes;
});
builder.addCase(gameUnloaded, (state) => {
state.routes = [];
});
}, },
}); });
export const { setSupplyRoutes } = supplyRoutesSlice.actions;
export const selectSupplyRoutes = (state: RootState) => state.supplyRoutes; export const selectSupplyRoutes = (state: RootState) => state.supplyRoutes;
export default supplyRoutesSlice.reducer; export default supplyRoutesSlice.reducer;

View File

@ -1,4 +1,5 @@
import { RootState } from "../app/store"; import { RootState } from "../app/store";
import { gameLoaded, gameUnloaded } from "./actions";
import { Tgo } from "./tgo"; import { Tgo } from "./tgo";
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
@ -14,20 +15,28 @@ export const tgosSlice = createSlice({
name: "tgos", name: "tgos",
initialState, initialState,
reducers: { reducers: {
setTgos: (state, action: PayloadAction<Tgo[]>) => {
state.tgos = {};
for (const tgo of action.payload) {
state.tgos[tgo.id] = tgo;
}
},
updateTgo: (state, action: PayloadAction<Tgo>) => { updateTgo: (state, action: PayloadAction<Tgo>) => {
const tgo = action.payload; const tgo = action.payload;
state.tgos[tgo.id] = tgo; state.tgos[tgo.id] = tgo;
}, },
}, },
extraReducers: (builder) => {
builder.addCase(gameLoaded, (state, action) => {
state.tgos = action.payload.tgos.reduce(
(acc: { [key: string]: Tgo }, curr) => {
acc[curr.id] = curr;
return acc;
},
{}
);
});
builder.addCase(gameUnloaded, (state) => {
state.tgos = {};
});
},
}); });
export const { setTgos, updateTgo } = tgosSlice.actions; export const { updateTgo } = tgosSlice.actions;
export const selectTgos = (state: RootState) => state.tgos; export const selectTgos = (state: RootState) => state.tgos;

View File

@ -3,6 +3,7 @@ import combatReducer from "../api/combatSlice";
import controlPointsReducer from "../api/controlPointsSlice"; import controlPointsReducer from "../api/controlPointsSlice";
import flightsReducer from "../api/flightsSlice"; import flightsReducer from "../api/flightsSlice";
import frontLinesReducer from "../api/frontLinesSlice"; import frontLinesReducer from "../api/frontLinesSlice";
import mapReducer from "../api/mapSlice";
import supplyRoutesReducer from "../api/supplyRoutesSlice"; import supplyRoutesReducer from "../api/supplyRoutesSlice";
import tgosReducer from "../api/tgosSlice"; import tgosReducer from "../api/tgosSlice";
import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit"; import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit";
@ -13,6 +14,7 @@ export const store = configureStore({
controlPoints: controlPointsReducer, controlPoints: controlPointsReducer,
flights: flightsReducer, flights: flightsReducer,
frontLines: frontLinesReducer, frontLines: frontLinesReducer,
map: mapReducer,
supplyRoutes: supplyRoutesReducer, supplyRoutes: supplyRoutesReducer,
tgos: tgosReducer, tgos: tgosReducer,
[apiSlice.reducerPath]: apiSlice.reducer, [apiSlice.reducerPath]: apiSlice.reducer,

View File

@ -1,3 +1,5 @@
import { selectMapCenter } from "../../api/mapSlice";
import { useAppSelector } from "../../app/hooks";
import AircraftLayer from "../aircraftlayer"; import AircraftLayer from "../aircraftlayer";
import AirDefenseRangeLayer from "../airdefenserangelayer"; import AirDefenseRangeLayer from "../airdefenserangelayer";
import CombatLayer from "../combatlayer"; import CombatLayer from "../combatlayer";
@ -7,17 +9,23 @@ import FrontLinesLayer from "../frontlineslayer";
import SupplyRoutesLayer from "../supplyrouteslayer"; import SupplyRoutesLayer from "../supplyrouteslayer";
import TgosLayer from "../tgoslayer/TgosLayer"; import TgosLayer from "../tgoslayer/TgosLayer";
import "./LiberationMap.css"; import "./LiberationMap.css";
import { LatLng } from "leaflet"; import { Map } from "leaflet";
import { useEffect, useRef } from "react";
import { BasemapLayer } from "react-esri-leaflet"; import { BasemapLayer } from "react-esri-leaflet";
import { LayersControl, MapContainer, ScaleControl } from "react-leaflet"; import { LayersControl, MapContainer, ScaleControl } from "react-leaflet";
interface GameProps { export default function LiberationMap() {
mapCenter: LatLng; const map = useRef<Map>();
} const mapCenter = useAppSelector(selectMapCenter);
useEffect(() => {
export default function LiberationMap(props: GameProps) { map.current?.setView(mapCenter, 8, { animate: true, duration: 1 });
});
return ( return (
<MapContainer zoom={8} center={props.mapCenter} zoomControl={false}> <MapContainer
zoom={8}
zoomControl={false}
whenCreated={(mapInstance) => (map.current = mapInstance)}
>
<ScaleControl /> <ScaleControl />
<LayersControl collapsed={false}> <LayersControl collapsed={false}>
<LayersControl.BaseLayer name="Imagery Clarity" checked> <LayersControl.BaseLayer name="Imagery Clarity" checked>

View File

@ -1,14 +1,4 @@
import backend from "../api/backend"; import reloadGameState from "../api/gamestate";
import { setControlPoints } from "../api/controlPointsSlice";
import { ControlPoint } from "../api/controlpoint";
import { Flight } from "../api/flight";
import { registerFlight } from "../api/flightsSlice";
import { setFrontLines } from "../api/frontLinesSlice";
import FrontLine from "../api/frontline";
import { setSupplyRoutes } from "../api/supplyRoutesSlice";
import SupplyRoute from "../api/supplyroute";
import Tgo from "../api/tgo";
import { setTgos } from "../api/tgosSlice";
import { useAppDispatch } from "../app/hooks"; import { useAppDispatch } from "../app/hooks";
import { useEffect } from "react"; import { useEffect } from "react";
@ -19,48 +9,7 @@ import { useEffect } from "react";
export const useInitialGameState = () => { export const useInitialGameState = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
backend reloadGameState(dispatch);
.get("/control-points")
.catch((error) => console.log(`Error fetching control points: ${error}`))
.then((response) => {
if (response != null) {
dispatch(setControlPoints(response.data as ControlPoint[]));
}
});
backend
.get("/tgos")
.catch((error) => console.log(`Error fetching TGOs: ${error}`))
.then((response) => {
if (response != null) {
dispatch(setTgos(response.data as Tgo[]));
}
});
backend
.get("/supply-routes")
.catch((error) => console.log(`Error fetching supply routes: ${error}`))
.then((response) => {
if (response != null) {
dispatch(setSupplyRoutes(response.data as SupplyRoute[]));
}
});
backend
.get("/front-lines")
.catch((error) => console.log(`Error fetching front-lines: ${error}`))
.then((response) => {
if (response != null) {
dispatch(setFrontLines(response.data as FrontLine[]));
}
});
backend
.get("/flights?with_waypoints=true")
.catch((error) => console.log(`Error fetching flights: ${error}`))
.then((response) => {
if (response != null) {
for (const flight of response.data) {
dispatch(registerFlight(flight as Flight));
}
}
});
}); });
}; };

View File

@ -7,6 +7,7 @@ from . import (
eventstream, eventstream,
flights, flights,
frontlines, frontlines,
game,
mapzones, mapzones,
navmesh, navmesh,
qt, qt,
@ -27,6 +28,7 @@ app.include_router(debuggeometries.router)
app.include_router(eventstream.router) app.include_router(eventstream.router)
app.include_router(flights.router) app.include_router(flights.router)
app.include_router(frontlines.router) app.include_router(frontlines.router)
app.include_router(game.router)
app.include_router(mapzones.router) app.include_router(mapzones.router)
app.include_router(navmesh.router) app.include_router(navmesh.router)
app.include_router(qt.router) app.include_router(qt.router)

View File

@ -1,9 +1,14 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel from pydantic import BaseModel
from game.server.leaflet import LeafletPoint from game.server.leaflet import LeafletPoint
from game.theater import ControlPoint
if TYPE_CHECKING:
from game import Game
from game.theater import ControlPoint
class ControlPointJs(BaseModel): class ControlPointJs(BaseModel):
@ -29,3 +34,9 @@ class ControlPointJs(BaseModel):
destination=destination, destination=destination,
sidc=str(control_point.sidc()), sidc=str(control_point.sidc()),
) )
@staticmethod
def all_in_game(game: Game) -> list[ControlPointJs]:
return [
ControlPointJs.for_control_point(cp) for cp in game.theater.controlpoints
]

View File

@ -13,16 +13,15 @@ router: APIRouter = APIRouter(prefix="/control-points")
@router.get("/") @router.get("/")
def list_control_points(game: Game = Depends(GameContext.get)) -> list[ControlPointJs]: def list_control_points(
control_points = [] game: Game = Depends(GameContext.require),
for control_point in game.theater.controlpoints: ) -> list[ControlPointJs]:
control_points.append(ControlPointJs.for_control_point(control_point)) return ControlPointJs.all_in_game(game)
return control_points
@router.get("/{cp_id}") @router.get("/{cp_id}")
def get_control_point( def get_control_point(
cp_id: int, game: Game = Depends(GameContext.get) cp_id: int, game: Game = Depends(GameContext.require)
) -> ControlPointJs: ) -> ControlPointJs:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:
@ -35,7 +34,7 @@ def get_control_point(
@router.get("/{cp_id}/destination-in-range") @router.get("/{cp_id}/destination-in-range")
def destination_in_range( def destination_in_range(
cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.get) cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.require)
) -> bool: ) -> bool:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:
@ -50,7 +49,7 @@ def destination_in_range(
@router.put("/{cp_id}/destination") @router.put("/{cp_id}/destination")
def set_destination( def set_destination(
cp_id: int, destination: LeafletPoint, game: Game = Depends(GameContext.get) cp_id: int, destination: LeafletPoint, game: Game = Depends(GameContext.require)
) -> None: ) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:
@ -79,7 +78,7 @@ def set_destination(
@router.put("/{cp_id}/cancel-travel") @router.put("/{cp_id}/cancel-travel")
def cancel_travel(cp_id: int, game: Game = Depends(GameContext.get)) -> None: def cancel_travel(cp_id: int, game: Game = Depends(GameContext.require)) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
if cp is None: if cp is None:
raise HTTPException( raise HTTPException(

View File

@ -10,15 +10,19 @@ router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries")
@router.get("/hold/{flight_id}") @router.get("/hold/{flight_id}")
def hold_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> HoldZonesJs: def hold_zones(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> HoldZonesJs:
return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game) return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game)
@router.get("/ip/{flight_id}") @router.get("/ip/{flight_id}")
def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> IpZonesJs: def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.require)) -> IpZonesJs:
return IpZonesJs.for_flight(game.db.flights.get(flight_id), game) return IpZonesJs.for_flight(game.db.flights.get(flight_id), game)
@router.get("/join/{flight_id}") @router.get("/join/{flight_id}")
def join_zones(flight_id: UUID, game: Game = Depends(GameContext.get)) -> JoinZonesJs: def join_zones(
flight_id: UUID, game: Game = Depends(GameContext.require)
) -> JoinZonesJs:
return JoinZonesJs.for_flight(game.db.flights.get(flight_id), game) return JoinZonesJs.for_flight(game.db.flights.get(flight_id), game)

View File

@ -17,7 +17,11 @@ class GameContext:
cls._game_model = game_model cls._game_model = game_model
@classmethod @classmethod
def get(cls) -> Game: def get(cls) -> Game | None:
return cls._game_model.game
@classmethod
def require(cls) -> Game:
if cls._game_model.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_model.game return cls._game_model.game

View File

@ -33,21 +33,38 @@ class GameUpdateEventsJs(BaseModel):
deleted_front_lines: set[UUID] deleted_front_lines: set[UUID]
updated_tgos: set[UUID] updated_tgos: set[UUID]
updated_control_points: set[int] updated_control_points: set[int]
reset_on_map_center: LeafletLatLon | None
game_unloaded: bool
@classmethod @classmethod
def from_events(cls, events: GameUpdateEvents, game: Game) -> GameUpdateEventsJs: def from_events(
cls, events: GameUpdateEvents, game: Game | None
) -> GameUpdateEventsJs:
# We still need to be able to send update events when there is no game loaded
# because we need to send the unload event.
new_combats = []
updated_combats = []
if game is not None:
new_combats = [
FrozenCombatJs.for_combat(c, game.theater) for c in events.new_combats
]
updated_combats = [
FrozenCombatJs.for_combat(c, game.theater)
for c in events.updated_combats
]
recenter_map = None
if events.reset_on_map_center is not None:
recenter_map = events.reset_on_map_center.as_list()
return GameUpdateEventsJs( return GameUpdateEventsJs(
updated_flight_positions={ updated_flight_positions={
f[0].id: f[1].latlng().as_list() f[0].id: f[1].latlng().as_list()
for f in events.updated_flight_positions for f in events.updated_flight_positions
}, },
new_combats=[ new_combats=new_combats,
FrozenCombatJs.for_combat(c, game.theater) for c in events.new_combats updated_combats=updated_combats,
],
updated_combats=[
FrozenCombatJs.for_combat(c, game.theater)
for c in events.updated_combats
],
ended_combats=[c.id for c in events.ended_combats], ended_combats=[c.id for c in events.ended_combats],
navmesh_updates=events.navmesh_updates, navmesh_updates=events.navmesh_updates,
unculled_zones_updated=events.unculled_zones_updated, unculled_zones_updated=events.unculled_zones_updated,
@ -66,4 +83,6 @@ class GameUpdateEventsJs(BaseModel):
deleted_front_lines=events.deleted_front_lines, deleted_front_lines=events.deleted_front_lines,
updated_tgos=events.updated_tgos, updated_tgos=events.updated_tgos,
updated_control_points=events.updated_control_points, updated_control_points=events.updated_control_points,
reset_on_map_center=recenter_map,
game_unloaded=events.game_unloaded,
) )

View File

@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from game.ato import Flight
from game.ato.flightstate import InFlight from game.ato.flightstate import InFlight
from game.server.leaflet import LeafletPoint from game.server.leaflet import LeafletPoint
from game.server.waypoints.models import FlightWaypointJs from game.server.waypoints.models import FlightWaypointJs
from game.server.waypoints.routes import waypoints_for_flight from game.server.waypoints.routes import waypoints_for_flight
if TYPE_CHECKING:
from game import Game
from game.ato import Flight
class FlightJs(BaseModel): class FlightJs(BaseModel):
id: UUID id: UUID
@ -37,3 +41,12 @@ class FlightJs(BaseModel):
sidc=str(flight.sidc()), sidc=str(flight.sidc()),
waypoints=waypoints, waypoints=waypoints,
) )
@staticmethod
def all_in_game(game: Game, with_waypoints: bool) -> list[FlightJs]:
flights = []
for coalition in game.coalitions:
for package in coalition.ato.packages:
for flight in package.flights:
flights.append(FlightJs.for_flight(flight, with_waypoints))
return flights

View File

@ -14,19 +14,16 @@ router: APIRouter = APIRouter(prefix="/flights")
@router.get("/") @router.get("/")
def list_flights( def list_flights(
with_waypoints: bool = False, game: Game = Depends(GameContext.get) with_waypoints: bool = False, game: Game = Depends(GameContext.require)
) -> list[FlightJs]: ) -> list[FlightJs]:
flights = [] return FlightJs.all_in_game(game, with_waypoints)
for coalition in game.coalitions:
for package in coalition.ato.packages:
for flight in package.flights:
flights.append(FlightJs.for_flight(flight, with_waypoints))
return flights
@router.get("/{flight_id}") @router.get("/{flight_id}")
def get_flight( def get_flight(
flight_id: UUID, with_waypoints: bool = False, game: Game = Depends(GameContext.get) flight_id: UUID,
with_waypoints: bool = False,
game: Game = Depends(GameContext.require),
) -> FlightJs: ) -> FlightJs:
flight = game.db.flights.get(flight_id) flight = game.db.flights.get(flight_id)
return FlightJs.for_flight(flight, with_waypoints) return FlightJs.for_flight(flight, with_waypoints)
@ -34,7 +31,7 @@ def get_flight(
@router.get("/{flight_id}/commit-boundary") @router.get("/{flight_id}/commit-boundary")
def commit_boundary( def commit_boundary(
flight_id: UUID, game: Game = Depends(GameContext.get) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> LeafletPoly: ) -> LeafletPoly:
flight = game.db.flights.get(flight_id) flight = game.db.flights.get(flight_id)
if not isinstance(flight.flight_plan, PatrollingFlightPlan): if not isinstance(flight.flight_plan, PatrollingFlightPlan):

View File

@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from game.server.leaflet import LeafletPoint from game.server.leaflet import LeafletPoint
from game.theater import FrontLine
from game.utils import nautical_miles from game.utils import nautical_miles
if TYPE_CHECKING:
from game import Game
from game.theater import FrontLine
class FrontLineJs(BaseModel): class FrontLineJs(BaseModel):
id: UUID id: UUID
@ -22,3 +26,7 @@ class FrontLineJs(BaseModel):
front_line.attack_heading.left.degrees, nautical_miles(2).meters front_line.attack_heading.left.degrees, nautical_miles(2).meters
) )
return FrontLineJs(id=front_line.id, extents=[a.latlng(), b.latlng()]) return FrontLineJs(id=front_line.id, extents=[a.latlng(), b.latlng()])
@staticmethod
def all_in_game(game: Game) -> list[FrontLineJs]:
return [FrontLineJs.for_front_line(f) for f in game.theater.conflicts()]

View File

@ -10,12 +10,12 @@ router: APIRouter = APIRouter(prefix="/front-lines")
@router.get("/") @router.get("/")
def list_front_lines(game: Game = Depends(GameContext.get)) -> list[FrontLineJs]: def list_front_lines(game: Game = Depends(GameContext.require)) -> list[FrontLineJs]:
return [FrontLineJs.for_front_line(f) for f in game.theater.conflicts()] return FrontLineJs.all_in_game(game)
@router.get("/{front_line_id}") @router.get("/{front_line_id}")
def get_front_line( def get_front_line(
front_line_id: UUID, game: Game = Depends(GameContext.get) front_line_id: UUID, game: Game = Depends(GameContext.require)
) -> FrontLineJs: ) -> FrontLineJs:
return FrontLineJs.for_front_line(game.db.front_lines.get(front_line_id)) 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,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel
from game.server.controlpoints.models import ControlPointJs
from game.server.flights.models import FlightJs
from game.server.frontlines.models import FrontLineJs
from game.server.leaflet import LeafletPoint
from game.server.supplyroutes.models import SupplyRouteJs
from game.server.tgos.models import TgoJs
if TYPE_CHECKING:
from game import Game
class GameJs(BaseModel):
control_points: list[ControlPointJs]
tgos: list[TgoJs]
supply_routes: list[SupplyRouteJs]
front_lines: list[FrontLineJs]
flights: list[FlightJs]
map_center: LeafletPoint
@staticmethod
def from_game(game: Game) -> GameJs:
return GameJs(
control_points=ControlPointJs.all_in_game(game),
tgos=TgoJs.all_in_game(game),
supply_routes=SupplyRouteJs.all_in_game(game),
front_lines=FrontLineJs.all_in_game(game),
flights=FlightJs.all_in_game(game, with_waypoints=True),
map_center=game.theater.terrain.map_view_default.position.latlng(),
)

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from game import Game
from game.server import GameContext
from .models import GameJs
router: APIRouter = APIRouter(prefix="/game")
@router.get("/")
def game_state(game: Game | None = Depends(GameContext.get)) -> GameJs | None:
if game is None:
return None
return GameJs.from_game(game)

View File

@ -9,7 +9,7 @@ router: APIRouter = APIRouter(prefix="/map-zones")
@router.get("/terrain") @router.get("/terrain")
def get_terrain(game: Game = Depends(GameContext.get)) -> MapZonesJs: def get_terrain(game: Game = Depends(GameContext.require)) -> MapZonesJs:
zones = game.theater.landmap zones = game.theater.landmap
if zones is None: if zones is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -22,7 +22,9 @@ def get_terrain(game: Game = Depends(GameContext.get)) -> MapZonesJs:
@router.get("/unculled") @router.get("/unculled")
def get_unculled_zones(game: Game = Depends(GameContext.get)) -> list[UnculledZoneJs]: def get_unculled_zones(
game: Game = Depends(GameContext.require),
) -> list[UnculledZoneJs]:
return [ return [
UnculledZoneJs( UnculledZoneJs(
position=zone.latlng(), radius=game.settings.perf_culling_distance * 1000 position=zone.latlng(), radius=game.settings.perf_culling_distance * 1000
@ -32,7 +34,9 @@ def get_unculled_zones(game: Game = Depends(GameContext.get)) -> list[UnculledZo
@router.get("/threats") @router.get("/threats")
def get_threat_zones(game: Game = Depends(GameContext.get)) -> ThreatZoneContainerJs: def get_threat_zones(
game: Game = Depends(GameContext.require),
) -> ThreatZoneContainerJs:
return ThreatZoneContainerJs( return ThreatZoneContainerJs(
blue=ThreatZonesJs.from_zones(game.threat_zone_for(player=True), game.theater), blue=ThreatZonesJs.from_zones(game.threat_zone_for(player=True), game.theater),
red=ThreatZonesJs.from_zones(game.threat_zone_for(player=False), game.theater), red=ThreatZonesJs.from_zones(game.threat_zone_for(player=False), game.theater),

View File

@ -9,7 +9,9 @@ router: APIRouter = APIRouter(prefix="/navmesh")
@router.get("/", response_model=list[NavMeshPolyJs]) @router.get("/", response_model=list[NavMeshPolyJs])
def get(for_player: bool, game: Game = Depends(GameContext.get)) -> list[NavMeshPolyJs]: def get(
for_player: bool, game: Game = Depends(GameContext.require)
) -> list[NavMeshPolyJs]:
mesh = game.coalition_for(for_player).nav_mesh mesh = game.coalition_for(for_player).nav_mesh
return [ return [
NavMeshPolyJs( NavMeshPolyJs(

View File

@ -11,7 +11,7 @@ router: APIRouter = APIRouter(prefix="/qt")
@router.post("/create-package/front-line/{front_line_id}") @router.post("/create-package/front-line/{front_line_id}")
def new_front_line_package( def new_front_line_package(
front_line_id: UUID, front_line_id: UUID,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
qt: QtCallbacks = Depends(QtContext.get), qt: QtCallbacks = Depends(QtContext.get),
) -> None: ) -> None:
qt.create_new_package(game.db.front_lines.get(front_line_id)) qt.create_new_package(game.db.front_lines.get(front_line_id))
@ -20,7 +20,7 @@ def new_front_line_package(
@router.post("/create-package/tgo/{tgo_id}") @router.post("/create-package/tgo/{tgo_id}")
def new_tgo_package( def new_tgo_package(
tgo_id: UUID, tgo_id: UUID,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
qt: QtCallbacks = Depends(QtContext.get), qt: QtCallbacks = Depends(QtContext.get),
) -> None: ) -> None:
qt.create_new_package(game.db.tgos.get(tgo_id)) qt.create_new_package(game.db.tgos.get(tgo_id))
@ -29,7 +29,7 @@ def new_tgo_package(
@router.post("/info/tgo/{tgo_id}") @router.post("/info/tgo/{tgo_id}")
def show_tgo_info( def show_tgo_info(
tgo_id: UUID, tgo_id: UUID,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
qt: QtCallbacks = Depends(QtContext.get), qt: QtCallbacks = Depends(QtContext.get),
) -> None: ) -> None:
qt.show_tgo_info(game.db.tgos.get(tgo_id)) qt.show_tgo_info(game.db.tgos.get(tgo_id))
@ -38,7 +38,7 @@ def show_tgo_info(
@router.post("/create-package/control-point/{cp_id}") @router.post("/create-package/control-point/{cp_id}")
def new_cp_package( def new_cp_package(
cp_id: int, cp_id: int,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
qt: QtCallbacks = Depends(QtContext.get), qt: QtCallbacks = Depends(QtContext.get),
) -> None: ) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)
@ -53,7 +53,7 @@ def new_cp_package(
@router.post("/info/control-point/{cp_id}") @router.post("/info/control-point/{cp_id}")
def show_control_point_info( def show_control_point_info(
cp_id: int, cp_id: int,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
qt: QtCallbacks = Depends(QtContext.get), qt: QtCallbacks = Depends(QtContext.get),
) -> None: ) -> None:
cp = game.theater.find_control_point_by_id(cp_id) cp = game.theater.find_control_point_by_id(cp_id)

View File

@ -81,3 +81,29 @@ class SupplyRouteJs(BaseModel):
sea sea
), ),
) )
@staticmethod
def all_in_game(game: Game) -> list[SupplyRouteJs]:
seen = set()
routes = []
for control_point in game.theater.controlpoints:
seen.add(control_point)
for destination, route in control_point.convoy_routes.items():
if destination in seen:
continue
routes.append(
SupplyRouteJs.for_link(
game, control_point, destination, list(route), sea=False
)
)
for destination, route in control_point.shipping_lanes.items():
if destination in seen:
continue
if not destination.is_friendly_to(control_point):
continue
routes.append(
SupplyRouteJs.for_link(
game, control_point, destination, list(route), sea=True
)
)
return routes

View File

@ -8,27 +8,7 @@ router: APIRouter = APIRouter(prefix="/supply-routes")
@router.get("/") @router.get("/")
def list_supply_routes(game: Game = Depends(GameContext.get)) -> list[SupplyRouteJs]: def list_supply_routes(
seen = set() game: Game = Depends(GameContext.require),
routes = [] ) -> list[SupplyRouteJs]:
for control_point in game.theater.controlpoints: return SupplyRouteJs.all_in_game(game)
seen.add(control_point)
for destination, route in control_point.convoy_routes.items():
if destination in seen:
continue
routes.append(
SupplyRouteJs.for_link(
game, control_point, destination, list(route), sea=False
)
)
for destination, route in control_point.shipping_lanes.items():
if destination in seen:
continue
if not destination.is_friendly_to(control_point):
continue
routes.append(
SupplyRouteJs.for_link(
game, control_point, destination, list(route), sea=True
)
)
return routes

View File

@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from game.server.leaflet import LeafletPoint from game.server.leaflet import LeafletPoint
from game.theater import TheaterGroundObject
if TYPE_CHECKING:
from game import Game
from game.theater import TheaterGroundObject
class TgoJs(BaseModel): class TgoJs(BaseModel):
@ -44,3 +48,12 @@ class TgoJs(BaseModel):
dead=tgo.is_dead, dead=tgo.is_dead,
sidc=str(tgo.sidc()), sidc=str(tgo.sidc()),
) )
@staticmethod
def all_in_game(game: Game) -> list[TgoJs]:
tgos = []
for control_point in game.theater.controlpoints:
for tgo in control_point.connected_objectives:
if not tgo.is_control_point:
tgos.append(TgoJs.for_tgo(tgo))
return tgos

View File

@ -10,15 +10,10 @@ router: APIRouter = APIRouter(prefix="/tgos")
@router.get("/") @router.get("/")
def list_tgos(game: Game = Depends(GameContext.get)) -> list[TgoJs]: def list_tgos(game: Game = Depends(GameContext.require)) -> list[TgoJs]:
tgos = [] return TgoJs.all_in_game(game)
for control_point in game.theater.controlpoints:
for tgo in control_point.connected_objectives:
if not tgo.is_control_point:
tgos.append(TgoJs.for_tgo(tgo))
return tgos
@router.get("/{tgo_id}") @router.get("/{tgo_id}")
def get_tgo(tgo_id: UUID, game: Game = Depends(GameContext.get)) -> TgoJs: def get_tgo(tgo_id: UUID, game: Game = Depends(GameContext.require)) -> TgoJs:
return TgoJs.for_tgo(game.db.tgos.get(tgo_id)) return TgoJs.for_tgo(game.db.tgos.get(tgo_id))

View File

@ -36,7 +36,7 @@ def waypoints_for_flight(flight: Flight) -> list[FlightWaypointJs]:
@router.get("/{flight_id}", response_model=list[FlightWaypointJs]) @router.get("/{flight_id}", response_model=list[FlightWaypointJs])
def all_waypoints_for_flight( def all_waypoints_for_flight(
flight_id: UUID, game: Game = Depends(GameContext.get) flight_id: UUID, game: Game = Depends(GameContext.require)
) -> list[FlightWaypointJs]: ) -> list[FlightWaypointJs]:
return waypoints_for_flight(game.db.flights.get(flight_id)) return waypoints_for_flight(game.db.flights.get(flight_id))
@ -46,7 +46,7 @@ def set_position(
flight_id: UUID, flight_id: UUID,
waypoint_idx: int, waypoint_idx: int,
position: LeafletPoint, position: LeafletPoint,
game: Game = Depends(GameContext.get), game: Game = Depends(GameContext.require),
) -> None: ) -> None:
from game.server import EventStream from game.server import EventStream

View File

@ -5,8 +5,10 @@ from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from dcs import Point from dcs import Point
from dcs.mapping import LatLng
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game
from game.ato import Flight, Package from game.ato import Flight, Package
from game.sim.combat import FrozenCombat from game.sim.combat import FrozenCombat
from game.theater import ControlPoint, FrontLine, TheaterGroundObject from game.theater import ControlPoint, FrontLine, TheaterGroundObject
@ -32,6 +34,8 @@ class GameUpdateEvents:
deleted_front_lines: set[UUID] = field(default_factory=set) deleted_front_lines: set[UUID] = field(default_factory=set)
updated_tgos: set[UUID] = field(default_factory=set) updated_tgos: set[UUID] = field(default_factory=set)
updated_control_points: set[int] = field(default_factory=set) updated_control_points: set[int] = field(default_factory=set)
reset_on_map_center: LatLng | None = None
game_unloaded: bool = False
shutting_down: bool = False shutting_down: bool = False
@property @property
@ -121,6 +125,17 @@ class GameUpdateEvents:
self.updated_control_points.add(control_point.id) self.updated_control_points.add(control_point.id)
return self return self
def game_loaded(self, game: Game | None) -> GameUpdateEvents:
if game is None:
self.game_unloaded = True
self.reset_on_map_center = None
else:
self.reset_on_map_center = (
game.theater.terrain.map_view_default.position.latlng()
)
self.game_unloaded = False
return self
def shut_down(self) -> GameUpdateEvents: def shut_down(self) -> GameUpdateEvents:
self.shutting_down = True self.shutting_down = True
return self return self

View File

@ -21,6 +21,7 @@ from game.factions import FACTIONS
from game.profiling import logged_duration from game.profiling import logged_duration
from game.server import EventStream, Server from game.server import EventStream, Server
from game.settings import Settings from game.settings import Settings
from game.sim import GameUpdateEvents
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
from qt_ui import ( from qt_ui import (
@ -58,6 +59,11 @@ def inject_custom_payloads(user_path: Path) -> None:
PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads") PayloadDirectories.set_preferred(user_path / "MissionEditor" / "UnitPayloads")
def on_game_load(game: Game | None) -> None:
EventStream.drain()
EventStream.put_nowait(GameUpdateEvents().game_loaded(game))
def run_ui(game: Optional[Game], new_map: bool, dev: bool) -> None: def run_ui(game: Optional[Game], new_map: bool, dev: bool) -> None:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens
app = QApplication(sys.argv) app = QApplication(sys.argv)
@ -143,7 +149,7 @@ def run_ui(game: Optional[Game], new_map: bool, dev: bool) -> None:
# Apply CSS (need works) # Apply CSS (need works)
GameUpdateSignal() GameUpdateSignal()
GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain) GameUpdateSignal.get_instance().game_loaded.connect(on_game_load)
# Start window # Start window
window = QLiberationWindow(game, new_map) window = QLiberationWindow(game, new_map)