diff --git a/client/src/App.tsx b/client/src/App.tsx index d5fb269a..a0852c87 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,17 +1,14 @@ import LiberationMap from "./components/liberationmap"; import useEventStream from "./hooks/useEventSteam"; import useInitialGameState from "./hooks/useInitialGameState"; -import { LatLng } from "leaflet"; function App() { - const mapCenter: LatLng = new LatLng(25.58, 54.9); - useInitialGameState(); useEventStream(); return (
- +
); } diff --git a/client/src/api/actions.ts b/client/src/api/actions.ts new file mode 100644 index 00000000..003ad2bc --- /dev/null +++ b/client/src/api/actions.ts @@ -0,0 +1,5 @@ +import Game from "./game"; +import { createAction } from "@reduxjs/toolkit"; + +export const gameLoaded = createAction("game/loaded"); +export const gameUnloaded = createAction("game/unloaded"); diff --git a/client/src/api/controlPointsSlice.ts b/client/src/api/controlPointsSlice.ts index 15cd8d96..085c9d69 100644 --- a/client/src/api/controlPointsSlice.ts +++ b/client/src/api/controlPointsSlice.ts @@ -1,4 +1,5 @@ import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; import { ControlPoint } from "./controlpoint"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; @@ -14,21 +15,28 @@ export const controlPointsSlice = createSlice({ name: "controlPoints", initialState, reducers: { - setControlPoints: (state, action: PayloadAction) => { - state.controlPoints = {}; - for (const cp of action.payload) { - state.controlPoints[cp.id] = cp; - } - }, updateControlPoint: (state, action: PayloadAction) => { const cp = action.payload; 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 } = - controlPointsSlice.actions; +export const { updateControlPoint } = controlPointsSlice.actions; export const selectControlPoints = (state: RootState) => state.controlPoints; diff --git a/client/src/api/eventstream.tsx b/client/src/api/eventstream.tsx index 04615be7..eeed7485 100644 --- a/client/src/api/eventstream.tsx +++ b/client/src/api/eventstream.tsx @@ -1,4 +1,5 @@ import { AppDispatch } from "../app/store"; +import { gameUnloaded } from "./actions"; import backend from "./backend"; import Combat from "./combat"; import { endCombat, newCombat, updateCombat } from "./combatSlice"; @@ -19,6 +20,7 @@ import { updateFrontLine, } from "./frontLinesSlice"; import FrontLine from "./frontline"; +import reloadGameState from "./gamestate"; import Tgo from "./tgo"; import { updateTgo } from "./tgosSlice"; import { LatLng } from "leaflet"; @@ -41,6 +43,8 @@ interface GameUpdateEvents { deleted_front_lines: string[]; updated_tgos: string[]; updated_control_points: number[]; + reset_on_map_center: LatLng | null; + game_unloaded: boolean; } export const handleStreamedEvents = ( @@ -114,4 +118,12 @@ export const handleStreamedEvents = ( dispatch(updateControlPoint(cp)); }); } + + if (events.reset_on_map_center != null) { + reloadGameState(dispatch); + } + + if (events.game_unloaded) { + dispatch(gameUnloaded()); + } }; diff --git a/client/src/api/flightsSlice.ts b/client/src/api/flightsSlice.ts index ff4fd2b1..1f5a2a23 100644 --- a/client/src/api/flightsSlice.ts +++ b/client/src/api/flightsSlice.ts @@ -1,4 +1,5 @@ import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; import { Flight } from "./flight"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { LatLng } from "leaflet"; @@ -17,9 +18,6 @@ export const flightsSlice = createSlice({ name: "flights", initialState, reducers: { - clearFlights: (state) => { - state.flights = {}; - }, registerFlight: (state, action: PayloadAction) => { const flight = action.payload; 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 { - clearFlights, registerFlight, unregisterFlight, updateFlight, diff --git a/client/src/api/frontLinesSlice.ts b/client/src/api/frontLinesSlice.ts index 41c4be9a..3b1af109 100644 --- a/client/src/api/frontLinesSlice.ts +++ b/client/src/api/frontLinesSlice.ts @@ -1,4 +1,5 @@ import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; import FrontLine from "./frontline"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; @@ -14,12 +15,6 @@ export const frontLinesSlice = createSlice({ name: "frontLines", initialState, reducers: { - setFrontLines: (state, action: PayloadAction) => { - state.fronts = {}; - for (const front of action.payload) { - state.fronts[front.id] = front; - } - }, addFrontLine: (state, action: PayloadAction) => { const front = action.payload; state.fronts[front.id] = front; @@ -32,9 +27,23 @@ export const frontLinesSlice = createSlice({ 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; export const selectFrontLines = (state: RootState) => state.frontLines; diff --git a/client/src/api/game.ts b/client/src/api/game.ts new file mode 100644 index 00000000..04ff805e --- /dev/null +++ b/client/src/api/game.ts @@ -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; +} diff --git a/client/src/api/gamestate.ts b/client/src/api/gamestate.ts new file mode 100644 index 00000000..3924bc46 --- /dev/null +++ b/client/src/api/gamestate.ts @@ -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)); + }); +} diff --git a/client/src/api/mapSlice.ts b/client/src/api/mapSlice.ts new file mode 100644 index 00000000..9906dccb --- /dev/null +++ b/client/src/api/mapSlice.ts @@ -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; diff --git a/client/src/api/supplyRoutesSlice.ts b/client/src/api/supplyRoutesSlice.ts index da228987..b6fc0639 100644 --- a/client/src/api/supplyRoutesSlice.ts +++ b/client/src/api/supplyRoutesSlice.ts @@ -1,6 +1,7 @@ import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; import SupplyRoute from "./supplyroute"; -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { createSlice } from "@reduxjs/toolkit"; interface SupplyRoutesState { routes: SupplyRoute[]; @@ -13,15 +14,17 @@ const initialState: SupplyRoutesState = { export const supplyRoutesSlice = createSlice({ name: "supplyRoutes", initialState, - reducers: { - setSupplyRoutes: (state, action: PayloadAction) => { - state.routes = action.payload; - }, + reducers: {}, + extraReducers: (builder) => { + 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 default supplyRoutesSlice.reducer; diff --git a/client/src/api/tgosSlice.ts b/client/src/api/tgosSlice.ts index fa17a184..52b838bb 100644 --- a/client/src/api/tgosSlice.ts +++ b/client/src/api/tgosSlice.ts @@ -1,4 +1,5 @@ import { RootState } from "../app/store"; +import { gameLoaded, gameUnloaded } from "./actions"; import { Tgo } from "./tgo"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; @@ -14,20 +15,28 @@ export const tgosSlice = createSlice({ name: "tgos", initialState, reducers: { - setTgos: (state, action: PayloadAction) => { - state.tgos = {}; - for (const tgo of action.payload) { - state.tgos[tgo.id] = tgo; - } - }, updateTgo: (state, action: PayloadAction) => { const tgo = action.payload; 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; diff --git a/client/src/app/store.ts b/client/src/app/store.ts index 48492b87..203c427b 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -3,6 +3,7 @@ import combatReducer from "../api/combatSlice"; import controlPointsReducer from "../api/controlPointsSlice"; import flightsReducer from "../api/flightsSlice"; import frontLinesReducer from "../api/frontLinesSlice"; +import mapReducer from "../api/mapSlice"; import supplyRoutesReducer from "../api/supplyRoutesSlice"; import tgosReducer from "../api/tgosSlice"; import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit"; @@ -13,6 +14,7 @@ export const store = configureStore({ controlPoints: controlPointsReducer, flights: flightsReducer, frontLines: frontLinesReducer, + map: mapReducer, supplyRoutes: supplyRoutesReducer, tgos: tgosReducer, [apiSlice.reducerPath]: apiSlice.reducer, diff --git a/client/src/components/liberationmap/LiberationMap.tsx b/client/src/components/liberationmap/LiberationMap.tsx index 47f52a54..ac6a61f3 100644 --- a/client/src/components/liberationmap/LiberationMap.tsx +++ b/client/src/components/liberationmap/LiberationMap.tsx @@ -1,3 +1,5 @@ +import { selectMapCenter } from "../../api/mapSlice"; +import { useAppSelector } from "../../app/hooks"; import AircraftLayer from "../aircraftlayer"; import AirDefenseRangeLayer from "../airdefenserangelayer"; import CombatLayer from "../combatlayer"; @@ -7,17 +9,23 @@ import FrontLinesLayer from "../frontlineslayer"; import SupplyRoutesLayer from "../supplyrouteslayer"; import TgosLayer from "../tgoslayer/TgosLayer"; import "./LiberationMap.css"; -import { LatLng } from "leaflet"; +import { Map } from "leaflet"; +import { useEffect, useRef } from "react"; import { BasemapLayer } from "react-esri-leaflet"; import { LayersControl, MapContainer, ScaleControl } from "react-leaflet"; -interface GameProps { - mapCenter: LatLng; -} - -export default function LiberationMap(props: GameProps) { +export default function LiberationMap() { + const map = useRef(); + const mapCenter = useAppSelector(selectMapCenter); + useEffect(() => { + map.current?.setView(mapCenter, 8, { animate: true, duration: 1 }); + }); return ( - + (map.current = mapInstance)} + > diff --git a/client/src/hooks/useInitialGameState.tsx b/client/src/hooks/useInitialGameState.tsx index 9ea24d63..a10dfa52 100644 --- a/client/src/hooks/useInitialGameState.tsx +++ b/client/src/hooks/useInitialGameState.tsx @@ -1,14 +1,4 @@ -import backend from "../api/backend"; -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 reloadGameState from "../api/gamestate"; import { useAppDispatch } from "../app/hooks"; import { useEffect } from "react"; @@ -19,48 +9,7 @@ import { useEffect } from "react"; export const useInitialGameState = () => { const dispatch = useAppDispatch(); useEffect(() => { - backend - .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)); - } - } - }); + reloadGameState(dispatch); }); }; diff --git a/game/server/app.py b/game/server/app.py index 6b43003e..f308d032 100644 --- a/game/server/app.py +++ b/game/server/app.py @@ -7,6 +7,7 @@ from . import ( eventstream, flights, frontlines, + game, mapzones, navmesh, qt, @@ -27,6 +28,7 @@ app.include_router(debuggeometries.router) app.include_router(eventstream.router) app.include_router(flights.router) app.include_router(frontlines.router) +app.include_router(game.router) app.include_router(mapzones.router) app.include_router(navmesh.router) app.include_router(qt.router) diff --git a/game/server/controlpoints/models.py b/game/server/controlpoints/models.py index c67129e5..3bd5875c 100644 --- a/game/server/controlpoints/models.py +++ b/game/server/controlpoints/models.py @@ -1,9 +1,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import BaseModel 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): @@ -29,3 +34,9 @@ class ControlPointJs(BaseModel): destination=destination, 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 + ] diff --git a/game/server/controlpoints/routes.py b/game/server/controlpoints/routes.py index 234ac332..3bd9850b 100644 --- a/game/server/controlpoints/routes.py +++ b/game/server/controlpoints/routes.py @@ -13,16 +13,15 @@ router: APIRouter = APIRouter(prefix="/control-points") @router.get("/") -def list_control_points(game: Game = Depends(GameContext.get)) -> list[ControlPointJs]: - control_points = [] - for control_point in game.theater.controlpoints: - control_points.append(ControlPointJs.for_control_point(control_point)) - return control_points +def list_control_points( + game: Game = Depends(GameContext.require), +) -> list[ControlPointJs]: + return ControlPointJs.all_in_game(game) @router.get("/{cp_id}") def get_control_point( - cp_id: int, game: Game = Depends(GameContext.get) + cp_id: int, game: Game = Depends(GameContext.require) ) -> ControlPointJs: cp = game.theater.find_control_point_by_id(cp_id) if cp is None: @@ -35,7 +34,7 @@ def get_control_point( @router.get("/{cp_id}/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: cp = game.theater.find_control_point_by_id(cp_id) if cp is None: @@ -50,7 +49,7 @@ def destination_in_range( @router.put("/{cp_id}/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: cp = game.theater.find_control_point_by_id(cp_id) if cp is None: @@ -79,7 +78,7 @@ def set_destination( @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) if cp is None: raise HTTPException( diff --git a/game/server/debuggeometries/routes.py b/game/server/debuggeometries/routes.py index d306ce1c..3b6f50e4 100644 --- a/game/server/debuggeometries/routes.py +++ b/game/server/debuggeometries/routes.py @@ -10,15 +10,19 @@ router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries") @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) @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) @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) diff --git a/game/server/dependencies.py b/game/server/dependencies.py index 147431e3..3a9caaf4 100644 --- a/game/server/dependencies.py +++ b/game/server/dependencies.py @@ -17,7 +17,11 @@ class GameContext: cls._game_model = game_model @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: raise RuntimeError("GameContext has no Game set") return cls._game_model.game diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py index 6bff3e9c..cde919a1 100644 --- a/game/server/eventstream/models.py +++ b/game/server/eventstream/models.py @@ -33,21 +33,38 @@ class GameUpdateEventsJs(BaseModel): deleted_front_lines: set[UUID] updated_tgos: set[UUID] updated_control_points: set[int] + reset_on_map_center: LeafletLatLon | None + game_unloaded: bool @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( updated_flight_positions={ f[0].id: f[1].latlng().as_list() for f in events.updated_flight_positions }, - 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 - ], + new_combats=new_combats, + updated_combats=updated_combats, ended_combats=[c.id for c in events.ended_combats], navmesh_updates=events.navmesh_updates, unculled_zones_updated=events.unculled_zones_updated, @@ -66,4 +83,6 @@ class GameUpdateEventsJs(BaseModel): deleted_front_lines=events.deleted_front_lines, updated_tgos=events.updated_tgos, updated_control_points=events.updated_control_points, + reset_on_map_center=recenter_map, + game_unloaded=events.game_unloaded, ) diff --git a/game/server/flights/models.py b/game/server/flights/models.py index 68d004b2..f306a2c0 100644 --- a/game/server/flights/models.py +++ b/game/server/flights/models.py @@ -1,15 +1,19 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import UUID from pydantic import BaseModel -from game.ato import Flight from game.ato.flightstate import InFlight from game.server.leaflet import LeafletPoint from game.server.waypoints.models import FlightWaypointJs from game.server.waypoints.routes import waypoints_for_flight +if TYPE_CHECKING: + from game import Game + from game.ato import Flight + class FlightJs(BaseModel): id: UUID @@ -37,3 +41,12 @@ class FlightJs(BaseModel): sidc=str(flight.sidc()), 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 diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py index 3d9ce93d..d66d60a1 100644 --- a/game/server/flights/routes.py +++ b/game/server/flights/routes.py @@ -14,19 +14,16 @@ router: APIRouter = APIRouter(prefix="/flights") @router.get("/") def list_flights( - with_waypoints: bool = False, game: Game = Depends(GameContext.get) + with_waypoints: bool = False, game: Game = Depends(GameContext.require) ) -> 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 + return FlightJs.all_in_game(game, with_waypoints) @router.get("/{flight_id}") 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: flight = game.db.flights.get(flight_id) return FlightJs.for_flight(flight, with_waypoints) @@ -34,7 +31,7 @@ def get_flight( @router.get("/{flight_id}/commit-boundary") def commit_boundary( - flight_id: UUID, game: Game = Depends(GameContext.get) + flight_id: UUID, game: Game = Depends(GameContext.require) ) -> LeafletPoly: flight = game.db.flights.get(flight_id) if not isinstance(flight.flight_plan, PatrollingFlightPlan): diff --git a/game/server/frontlines/models.py b/game/server/frontlines/models.py index 73290537..1eab8d13 100644 --- a/game/server/frontlines/models.py +++ b/game/server/frontlines/models.py @@ -1,13 +1,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING 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 +if TYPE_CHECKING: + from game import Game + from game.theater import FrontLine + class FrontLineJs(BaseModel): id: UUID @@ -22,3 +26,7 @@ class FrontLineJs(BaseModel): front_line.attack_heading.left.degrees, nautical_miles(2).meters ) 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()] diff --git a/game/server/frontlines/routes.py b/game/server/frontlines/routes.py index 9d141b00..ec217c5f 100644 --- a/game/server/frontlines/routes.py +++ b/game/server/frontlines/routes.py @@ -10,12 +10,12 @@ router: APIRouter = APIRouter(prefix="/front-lines") @router.get("/") -def list_front_lines(game: Game = Depends(GameContext.get)) -> list[FrontLineJs]: - return [FrontLineJs.for_front_line(f) for f in game.theater.conflicts()] +def list_front_lines(game: Game = Depends(GameContext.require)) -> list[FrontLineJs]: + return FrontLineJs.all_in_game(game) @router.get("/{front_line_id}") def get_front_line( - front_line_id: UUID, game: Game = Depends(GameContext.get) + front_line_id: UUID, game: Game = Depends(GameContext.require) ) -> FrontLineJs: return FrontLineJs.for_front_line(game.db.front_lines.get(front_line_id)) diff --git a/game/server/game/__init__.py b/game/server/game/__init__.py new file mode 100644 index 00000000..3a27ef1c --- /dev/null +++ b/game/server/game/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/game/server/game/models.py b/game/server/game/models.py new file mode 100644 index 00000000..80ce07de --- /dev/null +++ b/game/server/game/models.py @@ -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(), + ) diff --git a/game/server/game/routes.py b/game/server/game/routes.py new file mode 100644 index 00000000..dad7d7ba --- /dev/null +++ b/game/server/game/routes.py @@ -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) diff --git a/game/server/mapzones/routes.py b/game/server/mapzones/routes.py index fa69b012..54ca8d99 100644 --- a/game/server/mapzones/routes.py +++ b/game/server/mapzones/routes.py @@ -9,7 +9,7 @@ router: APIRouter = APIRouter(prefix="/map-zones") @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 if zones is None: 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") -def get_unculled_zones(game: Game = Depends(GameContext.get)) -> list[UnculledZoneJs]: +def get_unculled_zones( + game: Game = Depends(GameContext.require), +) -> list[UnculledZoneJs]: return [ UnculledZoneJs( 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") -def get_threat_zones(game: Game = Depends(GameContext.get)) -> ThreatZoneContainerJs: +def get_threat_zones( + game: Game = Depends(GameContext.require), +) -> ThreatZoneContainerJs: return ThreatZoneContainerJs( blue=ThreatZonesJs.from_zones(game.threat_zone_for(player=True), game.theater), red=ThreatZonesJs.from_zones(game.threat_zone_for(player=False), game.theater), diff --git a/game/server/navmesh/routes.py b/game/server/navmesh/routes.py index becba8da..ffd76c7d 100644 --- a/game/server/navmesh/routes.py +++ b/game/server/navmesh/routes.py @@ -9,7 +9,9 @@ router: APIRouter = APIRouter(prefix="/navmesh") @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 return [ NavMeshPolyJs( diff --git a/game/server/qt/routes.py b/game/server/qt/routes.py index f40d5781..ae6e1ff9 100644 --- a/game/server/qt/routes.py +++ b/game/server/qt/routes.py @@ -11,7 +11,7 @@ router: APIRouter = APIRouter(prefix="/qt") @router.post("/create-package/front-line/{front_line_id}") def new_front_line_package( front_line_id: UUID, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), qt: QtCallbacks = Depends(QtContext.get), ) -> None: 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}") def new_tgo_package( tgo_id: UUID, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), qt: QtCallbacks = Depends(QtContext.get), ) -> None: qt.create_new_package(game.db.tgos.get(tgo_id)) @@ -29,7 +29,7 @@ def new_tgo_package( @router.post("/info/tgo/{tgo_id}") def show_tgo_info( tgo_id: UUID, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), qt: QtCallbacks = Depends(QtContext.get), ) -> None: 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}") def new_cp_package( cp_id: int, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), qt: QtCallbacks = Depends(QtContext.get), ) -> None: 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}") def show_control_point_info( cp_id: int, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), qt: QtCallbacks = Depends(QtContext.get), ) -> None: cp = game.theater.find_control_point_by_id(cp_id) diff --git a/game/server/supplyroutes/models.py b/game/server/supplyroutes/models.py index f60163b7..66bf349a 100644 --- a/game/server/supplyroutes/models.py +++ b/game/server/supplyroutes/models.py @@ -81,3 +81,29 @@ class SupplyRouteJs(BaseModel): 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 diff --git a/game/server/supplyroutes/routes.py b/game/server/supplyroutes/routes.py index 73fd2613..3475fbd5 100644 --- a/game/server/supplyroutes/routes.py +++ b/game/server/supplyroutes/routes.py @@ -8,27 +8,7 @@ router: APIRouter = APIRouter(prefix="/supply-routes") @router.get("/") -def list_supply_routes(game: Game = Depends(GameContext.get)) -> 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 +def list_supply_routes( + game: Game = Depends(GameContext.require), +) -> list[SupplyRouteJs]: + return SupplyRouteJs.all_in_game(game) diff --git a/game/server/tgos/models.py b/game/server/tgos/models.py index 3c34135b..a6e797c4 100644 --- a/game/server/tgos/models.py +++ b/game/server/tgos/models.py @@ -1,11 +1,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import UUID from pydantic import BaseModel 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): @@ -44,3 +48,12 @@ class TgoJs(BaseModel): dead=tgo.is_dead, 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 diff --git a/game/server/tgos/routes.py b/game/server/tgos/routes.py index 813d1d30..4653bf58 100644 --- a/game/server/tgos/routes.py +++ b/game/server/tgos/routes.py @@ -10,15 +10,10 @@ router: APIRouter = APIRouter(prefix="/tgos") @router.get("/") -def list_tgos(game: Game = Depends(GameContext.get)) -> 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 +def list_tgos(game: Game = Depends(GameContext.require)) -> list[TgoJs]: + return TgoJs.all_in_game(game) @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)) diff --git a/game/server/waypoints/routes.py b/game/server/waypoints/routes.py index 0f4b6e86..9f51f468 100644 --- a/game/server/waypoints/routes.py +++ b/game/server/waypoints/routes.py @@ -36,7 +36,7 @@ def waypoints_for_flight(flight: Flight) -> list[FlightWaypointJs]: @router.get("/{flight_id}", response_model=list[FlightWaypointJs]) def all_waypoints_for_flight( - flight_id: UUID, game: Game = Depends(GameContext.get) + flight_id: UUID, game: Game = Depends(GameContext.require) ) -> list[FlightWaypointJs]: return waypoints_for_flight(game.db.flights.get(flight_id)) @@ -46,7 +46,7 @@ def set_position( flight_id: UUID, waypoint_idx: int, position: LeafletPoint, - game: Game = Depends(GameContext.get), + game: Game = Depends(GameContext.require), ) -> None: from game.server import EventStream diff --git a/game/sim/gameupdateevents.py b/game/sim/gameupdateevents.py index 67fd6fd8..bcd62e85 100644 --- a/game/sim/gameupdateevents.py +++ b/game/sim/gameupdateevents.py @@ -5,8 +5,10 @@ from typing import TYPE_CHECKING from uuid import UUID from dcs import Point +from dcs.mapping import LatLng if TYPE_CHECKING: + from game import Game from game.ato import Flight, Package from game.sim.combat import FrozenCombat from game.theater import ControlPoint, FrontLine, TheaterGroundObject @@ -32,6 +34,8 @@ class GameUpdateEvents: deleted_front_lines: set[UUID] = field(default_factory=set) updated_tgos: set[UUID] = 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 @property @@ -121,6 +125,17 @@ class GameUpdateEvents: self.updated_control_points.add(control_point.id) 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: self.shutting_down = True return self diff --git a/qt_ui/main.py b/qt_ui/main.py index 5bbbebef..712b6a9e 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -21,6 +21,7 @@ from game.factions import FACTIONS from game.profiling import logged_duration from game.server import EventStream, Server from game.settings import Settings +from game.sim import GameUpdateEvents from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from pydcs_extensions import load_mods from qt_ui import ( @@ -58,6 +59,11 @@ def inject_custom_payloads(user_path: Path) -> None: 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: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" # Potential fix for 4K screens app = QApplication(sys.argv) @@ -143,7 +149,7 @@ def run_ui(game: Optional[Game], new_map: bool, dev: bool) -> None: # Apply CSS (need works) GameUpdateSignal() - GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain) + GameUpdateSignal.get_instance().game_loaded.connect(on_game_load) # Start window window = QLiberationWindow(game, new_map)