mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
Compare commits
17 Commits
9.0.0
...
develop-8.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da4956df8 | ||
|
|
618159c1fa | ||
|
|
d8c662e7f8 | ||
|
|
12c41b57c9 | ||
|
|
85a27845bc | ||
|
|
e3f6347e16 | ||
|
|
fffe1b6e94 | ||
|
|
5a7a730e23 | ||
|
|
576f777320 | ||
|
|
87f7fe5307 | ||
|
|
e1434378a8 | ||
|
|
e03b0d99d8 | ||
|
|
e4eb3dec1b | ||
|
|
b365016496 | ||
|
|
c359b3f7fc | ||
|
|
302613069e | ||
|
|
5a22b62e3b |
22
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
22
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -31,7 +31,7 @@ body:
|
||||
If the bug was found in a development build, select "Development build"
|
||||
and provide a link to the build in the field below.
|
||||
options:
|
||||
- 8.1.0
|
||||
- 7.1.0
|
||||
- Development build
|
||||
- type: textarea
|
||||
attributes:
|
||||
@@ -49,19 +49,13 @@ body:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Save game and other files (save game required, bugs without saves will be closed)
|
||||
label: Save game and other files
|
||||
description: >
|
||||
Attach any files needed to reproduce the bug here. **A save game is
|
||||
required.** Even if it seems unnecessary to you, this is required.
|
||||
Repro steps that are obvious to you might not be obvious to anyone
|
||||
else, and it is impossible for us to know what default settings or mods
|
||||
may be impacting behavior without a save game. Bugs filed without a
|
||||
save game are very often not reproducible, and those waste scarce
|
||||
developer time. It is **much** easier for you to attach a save game
|
||||
than it is for us to recreate your save state by guessing at what you
|
||||
did. As such, bug reports that do not attach a saved game will be
|
||||
closed without investigation. Attach the `.liberation.zip` file found
|
||||
in `%USERPROFILE%/Saved Games/DCS/Liberation/Saves`.
|
||||
required.** We typically cannot help without a save game (the
|
||||
`.liberation.zip` file found in `%USERPROFILE%/Saved
|
||||
Games/DCS/Liberation/Saves`), so most bugs filed without saved games
|
||||
will be closed without investigation.
|
||||
|
||||
|
||||
Other useful files to include are:
|
||||
@@ -82,10 +76,6 @@ body:
|
||||
investigating any issues with end-of-turn results processing.
|
||||
|
||||
|
||||
If reporting an issue that occurred during or after flying the mission
|
||||
in DCS, the DCS log file found in `%USERPROFILE%/Saved Games/DCS/Logs`.
|
||||
|
||||
|
||||
You can attach files to the bug by dragging and dropping the file into
|
||||
this text box. GitHub will not allow uploads of all file types, so
|
||||
attach a zip of the files if needed.
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/new-game-bug.yml
vendored
2
.github/ISSUE_TEMPLATE/new-game-bug.yml
vendored
@@ -39,7 +39,7 @@ body:
|
||||
If the bug was found in a development build, select "Development build"
|
||||
and provide a link to the build in the field below.
|
||||
options:
|
||||
- 8.1.0
|
||||
- 7.1.0
|
||||
- Development build
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
54
changelog.md
54
changelog.md
@@ -1,57 +1,3 @@
|
||||
# 9.0.0
|
||||
|
||||
Saves from 8.x are not compatible with 9.0.0.
|
||||
|
||||
## Features/Improvements
|
||||
|
||||
* **[Engine]** Support for DCS Open Beta 2.9.0.46801.
|
||||
* **[Campaign]** Added ferry only control points, which offer campaign designers a way to add squadrons that can be brought in after additional airfields are captured.
|
||||
* **[Campaign]** The new squadron rules (size limits, beginning the campaign at full strength) are now the default and required. The old style of unlimited squadron sizes and starting with zero aircraft has been removed.
|
||||
* **[Data]** Added support for the ARA Veinticinco de Mayo.
|
||||
* **[Data]** Changed display name of the AI-only F-15E Strike Eagle for clarity.
|
||||
* **[Flight Planning]** Improved IP selection for targets that are near the center of a threat zone.
|
||||
* **[Flight Planning]** Moved CAS ingress point off the front line so that the AI begins their target search earlier.
|
||||
* **[Flight Planning]** Loadouts and aircraft properties can now be set per-flight member. Warning: AI flights should not use mixed loadouts.
|
||||
* **[Flight Planning]** Laser codes that are pre-assigned to weapons at mission start can now be chosen from a list in the loadout UI. This does not affect the aircraft's TGP, just the weapons. Currently only implemented for the F-15E S4+ and F-16C.
|
||||
* **[Mission Generation]** Configured target and initial points for F-15E S4+.
|
||||
* **[Mission Generation]** Added a package kneeboard page that shows the radio frequencies, tasks, and laser codes for each member of your package.
|
||||
* **[Mission Generation]** Added option to generate AI flights with unlimited fuel (enabled by default).
|
||||
* **[Modding]** Factions can now specify the ship type to be used for cargo shipping. The Handy Wind will be used by default, but WW2 factions can pick something more appropriate.
|
||||
* **[Modding]** Unit variants can now set a display name separate from their ID.
|
||||
* **[Modding]** Updated Community A-4E-C mod version support to 2.2.0 release.
|
||||
* **[UI]** An error will be displayed when invalid fast-forward options are selected rather than beginning a never ending simulation.
|
||||
* **[UI]** Added cheats for instantly repairing and destroying runways.
|
||||
* **[UI]** Improved usability of the flight properties UI. It now shows human-readable names and uses more appropriate UI elements.
|
||||
* **[UI]** The map now shows the real front line bounds.
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[Campaign]** Fixed error when canceling squadron transfer if the current location would be exactly full.
|
||||
* **[Data]** Fixed the class of the Samuel Chase so it can't be picked for a AAA or SHORAD site.
|
||||
* **[Data]** Allow CH-47D, CH-53E and UH-60A to operate from carriers and LHAs.
|
||||
* **[Data]** Added the F-15E's LANTIRN to the list of known targeting pods. Player F-15E flight with TGPs will now be assigned laser codes.
|
||||
* **[Flight Planning]** Patrolling flight plans (CAS, CAP, refueling, etc) now handle TOT offsets.
|
||||
* **[Loadouts]** Fixed error when loading certain DCS loadouts which contained an empty pylon (notably the Mosquito).
|
||||
* **[Mission Generation]** Restored previous AI behavior for anti-ship missions. A DCS update caused only a single aircraft in a flight to attack. The full flight will now attack like they used to.
|
||||
* **[Mission Generation]** Fix generation of OCA Runway missions to allow LGBs to be used.
|
||||
* **[Mission Generation]** Fixed AI flights flying far too slowly toward NAV points.
|
||||
* **[Mission Generation]** Fixed Recovery Tanker mission type intermittently failing due to not being able to find the CVN.
|
||||
* **[Mission Generation]** Fixed "division by zero" error on mission generation when a flight has an "In-Flight" start type and starts on top of a mission waypoint.
|
||||
* **[Mission Generation]** Fixed flights not being selectable in the mission editor if fast-forward was used and they were generated at a waypoint that had a fixed TOT (such as a BARCAP that was on-station).
|
||||
* **[Mission Generation]** Fixed error when planning TARCAPs on the sole remaining enemy airfield.
|
||||
* **[Mission Generation]** Fixed allocation range for carrier Link 4 datalink.
|
||||
* **[Modding]** Unit variants can now actually override base unit type properties.
|
||||
* **[New Game Wizard]** Factions are reset to default after clicking "Back" to Theater Configuration screen.
|
||||
* **[Plugins]** Fixed Lua errors in Skynet plugin that would occur whenever one coalition had no IADS nodes.
|
||||
* **[UI]** Fixed deleting waypoints in custom flight plans deleting the wrong waypoint.
|
||||
* **[UI]** Fixed flight properties UI to support F-15E S4+ laser codes.
|
||||
* **[UI]** In unit transfer dialog, only list control points that are reachable from the control point units are being transferred from.
|
||||
* **[UI]** Fixed UI bug where altering an "ahead of package" TOT offset would change the offset back to a "behind pacakge" offset.
|
||||
* **[UI]** Fixed bug where changing TOT offsets could result in flight startup times that are in the past.
|
||||
* **[UI]** Fixed odd spacing of the finance window when there were not enough items to fill the page.
|
||||
* **[UI]** Fixed regression where waypoint altitude changes in the waypoint list screen are applied to the wrong waypoint.
|
||||
* **[UI]** Fixed regression where waypoint additions in custom flight plans are not reflected until the window is reloaded.
|
||||
|
||||
# 8.1.0
|
||||
|
||||
Saves from 8.0.0 are compatible with 8.1.0
|
||||
|
||||
2576
client/package-lock.json
generated
2576
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-redux": "^7.1.24",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.1.2",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"esri-leaflet": "^3.0.8",
|
||||
"leaflet": "^1.9.2",
|
||||
@@ -62,16 +62,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rtk-query/codegen-openapi": "^1.0.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.3.0",
|
||||
"@types/leaflet": "^1.8.0",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"electron": "^22.3.25",
|
||||
"electron": "^21.1.0",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"generate-license-file": "^2.0.0",
|
||||
"jest-transform-stub": "^2.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"msw": "^1.2.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"wait-on": "^6.0.1"
|
||||
@@ -81,7 +80,7 @@
|
||||
"node_modules/(?!(@?react-leaflet|axios)/)"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub"
|
||||
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "identity-obj-proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ const injectedRtkApi = api.injectEndpoints({
|
||||
url: `/debug/waypoint-geometries/hold/${queryArg.flightId}`,
|
||||
}),
|
||||
}),
|
||||
getDebugIpZones: build.query<
|
||||
GetDebugIpZonesApiResponse,
|
||||
GetDebugIpZonesApiArg
|
||||
>({
|
||||
query: (queryArg) => ({
|
||||
url: `/debug/waypoint-geometries/ip/${queryArg.flightId}`,
|
||||
}),
|
||||
}),
|
||||
getDebugJoinZones: build.query<
|
||||
GetDebugJoinZonesApiResponse,
|
||||
GetDebugJoinZonesApiArg
|
||||
@@ -237,6 +245,11 @@ export type GetDebugHoldZonesApiResponse =
|
||||
export type GetDebugHoldZonesApiArg = {
|
||||
flightId: string;
|
||||
};
|
||||
export type GetDebugIpZonesApiResponse =
|
||||
/** status 200 Successful Response */ IpZones;
|
||||
export type GetDebugIpZonesApiArg = {
|
||||
flightId: string;
|
||||
};
|
||||
export type GetDebugJoinZonesApiResponse =
|
||||
/** status 200 Successful Response */ JoinZones;
|
||||
export type GetDebugJoinZonesApiArg = {
|
||||
@@ -366,6 +379,12 @@ export type HoldZones = {
|
||||
permissibleZones: LatLng[][][];
|
||||
preferredLines: LatLng[][];
|
||||
};
|
||||
export type IpZones = {
|
||||
homeBubble: LatLng[][];
|
||||
ipBubble: LatLng[][];
|
||||
permissibleZone: LatLng[][];
|
||||
safeZones: LatLng[][][];
|
||||
};
|
||||
export type JoinZones = {
|
||||
homeBubble: LatLng[][];
|
||||
targetBubble: LatLng[][];
|
||||
@@ -478,6 +497,7 @@ export const {
|
||||
useSetControlPointDestinationMutation,
|
||||
useClearControlPointDestinationMutation,
|
||||
useGetDebugHoldZonesQuery,
|
||||
useGetDebugIpZonesQuery,
|
||||
useGetDebugJoinZonesQuery,
|
||||
useListFlightsQuery,
|
||||
useGetFlightByIdQuery,
|
||||
|
||||
@@ -4,10 +4,7 @@ const backendAddr =
|
||||
new URL(window.location.toString()).searchParams.get("server") ??
|
||||
"[::1]:16880";
|
||||
|
||||
// MSW can't handle IPv6 URLs...
|
||||
// https://github.com/mswjs/msw/issues/1388
|
||||
export const HTTP_URL =
|
||||
process.env.NODE_ENV === "test" ? "" : `http://${backendAddr}/`;
|
||||
export const HTTP_URL = `http://${backendAddr}/`;
|
||||
|
||||
export const backend = axios.create({
|
||||
baseURL: HTTP_URL,
|
||||
|
||||
@@ -30,6 +30,11 @@ export const liberationApi = _liberationApi.enhanceEndpoints({
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
],
|
||||
},
|
||||
getDebugIpZones: {
|
||||
providesTags: (result, error, arg) => [
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
],
|
||||
},
|
||||
getDebugJoinZones: {
|
||||
providesTags: (result, error, arg) => [
|
||||
{ type: Tags.FLIGHT_PLAN, id: arg.flightId },
|
||||
|
||||
73
client/src/components/waypointdebugzones/IpZones.tsx
Normal file
73
client/src/components/waypointdebugzones/IpZones.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useGetDebugIpZonesQuery } from "../../api/liberationApi";
|
||||
import { LayerGroup, Polygon } from "react-leaflet";
|
||||
|
||||
interface IpZonesProps {
|
||||
flightId: string;
|
||||
}
|
||||
|
||||
function IpZones(props: IpZonesProps) {
|
||||
const { data, error, isLoading } = useGetDebugIpZonesQuery({
|
||||
flightId: props.flightId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("Error while loading waypoint IP zone info", error);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
console.log("Waypoint IP zone returned empty response");
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Polygon
|
||||
positions={data.homeBubble}
|
||||
color="#ffff00"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
<Polygon
|
||||
positions={data.ipBubble}
|
||||
color="#bb89ff"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
<Polygon
|
||||
positions={data.permissibleZone}
|
||||
color="#ffffff"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
{data.safeZones.map((zone, idx) => {
|
||||
return (
|
||||
<Polygon
|
||||
key={idx}
|
||||
positions={zone}
|
||||
color="#80BA80"
|
||||
fillOpacity={0.1}
|
||||
interactive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface IpZonesLayerProps {
|
||||
flightId: string | null;
|
||||
}
|
||||
|
||||
export function IpZonesLayer(props: IpZonesLayerProps) {
|
||||
return (
|
||||
<LayerGroup>
|
||||
{props.flightId ? <IpZones flightId={props.flightId} /> : <></>}
|
||||
</LayerGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { selectSelectedFlightId } from "../../api/flightsSlice";
|
||||
import { useAppSelector } from "../../app/hooks";
|
||||
import { HoldZonesLayer } from "./HoldZones";
|
||||
import { IpZonesLayer } from "./IpZones";
|
||||
import { JoinZonesLayer } from "./JoinZones";
|
||||
import { LayersControl } from "react-leaflet";
|
||||
|
||||
@@ -15,6 +16,9 @@ export function WaypointDebugZonesControls() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayersControl.Overlay name="IP zones">
|
||||
<IpZonesLayer flightId={selectedFlightId} />
|
||||
</LayersControl.Overlay>
|
||||
<LayersControl.Overlay name="Join zones">
|
||||
<JoinZonesLayer flightId={selectedFlightId} />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { HTTP_URL } from "../../api/backend";
|
||||
import { renderWithProviders } from "../../testutils";
|
||||
import WaypointMarker, { TOOLTIP_ZOOM_LEVEL } from "./WaypointMarker";
|
||||
import { Map, Marker } from "leaflet";
|
||||
import { rest, MockedRequest, matchRequestUrl } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import React from "react";
|
||||
import { MapContainer } from "react-leaflet";
|
||||
|
||||
// https://mswjs.io/docs/extensions/life-cycle-events#asserting-request-payload
|
||||
const waitForRequest = (method: string, url: string) => {
|
||||
let requestId = "";
|
||||
|
||||
return new Promise<MockedRequest>((resolve, reject) => {
|
||||
server.events.on("request:start", (req) => {
|
||||
const matchesMethod = req.method.toLowerCase() === method.toLowerCase();
|
||||
const matchesUrl = matchRequestUrl(req.url, url).matches;
|
||||
|
||||
if (matchesMethod && matchesUrl) {
|
||||
requestId = req.id;
|
||||
}
|
||||
});
|
||||
|
||||
server.events.on("request:match", (req) => {
|
||||
if (req.id === requestId) {
|
||||
resolve(req);
|
||||
}
|
||||
});
|
||||
|
||||
server.events.on("request:unhandled", (req) => {
|
||||
if (req.id === requestId) {
|
||||
reject(
|
||||
new Error(`The ${req.method} ${req.url.href} request was unhandled.`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
rest.post(
|
||||
`${HTTP_URL}/waypoints/:flightId/:waypointIdx/position`,
|
||||
(req, res, ctx) => {
|
||||
if (req.params.flightId === "") {
|
||||
return res(ctx.status(500));
|
||||
}
|
||||
if (req.params.waypointIdx === "0") {
|
||||
return res(ctx.status(403));
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("WaypointMarker", () => {
|
||||
it("is placed in the correct location", () => {
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 0,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "",
|
||||
};
|
||||
const marker = React.createRef<Marker>();
|
||||
renderWithProviders(
|
||||
<MapContainer>
|
||||
<WaypointMarker
|
||||
number={0}
|
||||
waypoint={waypoint}
|
||||
flight={{
|
||||
id: "",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [waypoint],
|
||||
}}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
expect(marker.current?.getLatLng()).toEqual({ lat: 0, lng: 0 });
|
||||
});
|
||||
|
||||
it("tooltip is hidden when zoomed out", () => {
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 0,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "",
|
||||
};
|
||||
const map = React.createRef<Map>();
|
||||
const marker = React.createRef<Marker>();
|
||||
renderWithProviders(
|
||||
<MapContainer zoom={0} ref={map}>
|
||||
<WaypointMarker
|
||||
number={0}
|
||||
waypoint={waypoint}
|
||||
flight={{
|
||||
id: "",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [waypoint],
|
||||
}}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
map.current?.setView({ lat: 0, lng: 0 }, TOOLTIP_ZOOM_LEVEL - 1);
|
||||
expect(marker.current?.getTooltip()?.isOpen()).toBeFalsy();
|
||||
});
|
||||
|
||||
it("tooltip is shown when zoomed in", () => {
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 0,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "",
|
||||
};
|
||||
const map = React.createRef<Map>();
|
||||
const marker = React.createRef<Marker>();
|
||||
renderWithProviders(
|
||||
<MapContainer ref={map}>
|
||||
<WaypointMarker
|
||||
number={0}
|
||||
waypoint={waypoint}
|
||||
flight={{
|
||||
id: "",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [waypoint],
|
||||
}}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
map.current?.setView({ lat: 0, lng: 0 }, TOOLTIP_ZOOM_LEVEL);
|
||||
expect(marker.current?.getTooltip()?.isOpen()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tooltip has correct contents", () => {
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 25000,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "09:00:00",
|
||||
};
|
||||
const map = React.createRef<Map>();
|
||||
const marker = React.createRef<Marker>();
|
||||
renderWithProviders(
|
||||
<MapContainer ref={map}>
|
||||
<WaypointMarker
|
||||
number={0}
|
||||
waypoint={waypoint}
|
||||
flight={{
|
||||
id: "",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [waypoint],
|
||||
}}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
expect(marker.current?.getTooltip()?.getContent()).toEqual(
|
||||
"0 <br />25000 ft MSL<br />09:00:00"
|
||||
);
|
||||
});
|
||||
|
||||
it("resets the tooltip while dragging", () => {
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 25000,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "09:00:00",
|
||||
};
|
||||
const marker = React.createRef<Marker>();
|
||||
renderWithProviders(
|
||||
<MapContainer>
|
||||
<WaypointMarker
|
||||
number={0}
|
||||
waypoint={waypoint}
|
||||
flight={{
|
||||
id: "",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [waypoint],
|
||||
}}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
marker.current?.fireEvent("dragstart");
|
||||
expect(marker.current?.getTooltip()?.getContent()).toEqual(
|
||||
"Waiting to recompute TOT..."
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the new position to the backend on dragend", async () => {
|
||||
const departure = {
|
||||
name: "",
|
||||
position: { lat: 0, lng: 0 },
|
||||
altitude_ft: 25000,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "09:00:00",
|
||||
};
|
||||
const waypoint = {
|
||||
name: "",
|
||||
position: { lat: 1, lng: 1 },
|
||||
altitude_ft: 25000,
|
||||
altitude_reference: "MSL",
|
||||
is_movable: false,
|
||||
should_mark: false,
|
||||
include_in_path: true,
|
||||
timing: "09:00:00",
|
||||
};
|
||||
const flight = {
|
||||
id: "1234",
|
||||
blue: true,
|
||||
sidc: "",
|
||||
waypoints: [departure, waypoint],
|
||||
};
|
||||
const marker = React.createRef<Marker>();
|
||||
|
||||
// There is no observable UI change from moving a waypoint, just a message
|
||||
// to the backend to record the frontend change. The real backend will then
|
||||
// push an updated game state which will update redux, but that's not part
|
||||
// of this component's behavior.
|
||||
const pendingRequest = waitForRequest(
|
||||
"POST",
|
||||
`${HTTP_URL}/waypoints/1234/1/position`
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<MapContainer>
|
||||
<WaypointMarker number={0} waypoint={departure} flight={flight} />
|
||||
<WaypointMarker
|
||||
number={1}
|
||||
waypoint={waypoint}
|
||||
flight={flight}
|
||||
ref={marker}
|
||||
/>
|
||||
</MapContainer>
|
||||
);
|
||||
|
||||
marker.current?.fireEvent("dragstart");
|
||||
marker.current?.fireEvent("dragend", { target: marker.current });
|
||||
|
||||
const request = await pendingRequest;
|
||||
const response = await request.json();
|
||||
expect(response).toEqual({ lat: 1, lng: 1 });
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,13 @@ import {
|
||||
Waypoint,
|
||||
useSetWaypointPositionMutation,
|
||||
} from "../../api/liberationApi";
|
||||
import mergeRefs from "../../mergeRefs";
|
||||
import { Icon } from "leaflet";
|
||||
import { Marker as LMarker } from "leaflet";
|
||||
import icon from "leaflet/dist/images/marker-icon.png";
|
||||
import iconShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import {
|
||||
ForwardedRef,
|
||||
MutableRefObject,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { MutableRefObject, useCallback, useEffect, useRef } from "react";
|
||||
import { Marker, Tooltip, useMap, useMapEvent } from "react-leaflet";
|
||||
|
||||
export const TOOLTIP_ZOOM_LEVEL = 9;
|
||||
|
||||
const WAYPOINT_ICON = new Icon({
|
||||
iconUrl: icon,
|
||||
shadowUrl: iconShadow,
|
||||
@@ -32,84 +22,84 @@ interface WaypointMarkerProps {
|
||||
flight: Flight;
|
||||
}
|
||||
|
||||
const WaypointMarker = forwardRef(
|
||||
(props: WaypointMarkerProps, ref: ForwardedRef<LMarker>) => {
|
||||
// Most props of react-leaflet types are immutable and components will not
|
||||
// update to account for changes, so we can't simply use the `permanent`
|
||||
// property of the tooltip to control tooltip visibility based on the zoom
|
||||
// level.
|
||||
//
|
||||
// On top of that, listening for zoom changes and opening/closing is not
|
||||
// sufficient because clicking anywhere will close any opened tooltips (even
|
||||
// if they are permanent; once openTooltip has been called that seems to no
|
||||
// longer have any effect).
|
||||
//
|
||||
// Instead, listen for zoom changes and rebind the tooltip when the zoom level
|
||||
// changes.
|
||||
const map = useMap();
|
||||
const marker: MutableRefObject<LMarker | null> = useRef(null);
|
||||
const WaypointMarker = (props: WaypointMarkerProps) => {
|
||||
// Most props of react-leaflet types are immutable and components will not
|
||||
// update to account for changes, so we can't simply use the `permanent`
|
||||
// property of the tooltip to control tooltip visibility based on the zoom
|
||||
// level.
|
||||
//
|
||||
// On top of that, listening for zoom changes and opening/closing is not
|
||||
// sufficient because clicking anywhere will close any opened tooltips (even
|
||||
// if they are permanent; once openTooltip has been called that seems to no
|
||||
// longer have any effect).
|
||||
//
|
||||
// Instead, listen for zoom changes and rebind the tooltip when the zoom level
|
||||
// changes.
|
||||
const map = useMap();
|
||||
const marker: MutableRefObject<LMarker | undefined> = useRef();
|
||||
|
||||
const [putDestination] = useSetWaypointPositionMutation();
|
||||
const [putDestination] = useSetWaypointPositionMutation();
|
||||
|
||||
const rebindTooltip = useCallback(() => {
|
||||
if (marker.current === null) {
|
||||
return;
|
||||
}
|
||||
const rebindTooltip = useCallback(() => {
|
||||
if (marker.current === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tooltip = marker.current.getTooltip();
|
||||
if (tooltip === undefined) {
|
||||
return;
|
||||
}
|
||||
const tooltip = marker.current.getTooltip();
|
||||
if (tooltip === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const permanent = map.getZoom() >= TOOLTIP_ZOOM_LEVEL;
|
||||
marker.current
|
||||
.unbindTooltip()
|
||||
.bindTooltip(tooltip, { permanent: permanent });
|
||||
}, [map]);
|
||||
useMapEvent("zoomend", rebindTooltip);
|
||||
|
||||
useEffect(() => {
|
||||
const waypoint = props.waypoint;
|
||||
marker.current?.setTooltipContent(
|
||||
`${props.number} ${waypoint.name}<br />` +
|
||||
`${waypoint.altitude_ft.toFixed()} ft ${
|
||||
waypoint.altitude_reference
|
||||
}<br />` +
|
||||
waypoint.timing
|
||||
);
|
||||
});
|
||||
const permanent = map.getZoom() >= 9;
|
||||
marker.current
|
||||
.unbindTooltip()
|
||||
.bindTooltip(tooltip, { permanent: permanent });
|
||||
}, [map]);
|
||||
useMapEvent("zoomend", rebindTooltip);
|
||||
|
||||
useEffect(() => {
|
||||
const waypoint = props.waypoint;
|
||||
return (
|
||||
<Marker
|
||||
position={waypoint.position}
|
||||
icon={WAYPOINT_ICON}
|
||||
draggable
|
||||
eventHandlers={{
|
||||
dragstart: (e) => {
|
||||
const m: LMarker = e.target;
|
||||
m.setTooltipContent("Waiting to recompute TOT...");
|
||||
},
|
||||
dragend: async (e) => {
|
||||
const m: LMarker = e.target;
|
||||
const destination = m.getLatLng();
|
||||
try {
|
||||
await putDestination({
|
||||
flightId: props.flight.id,
|
||||
waypointIdx: props.number,
|
||||
leafletPoint: { lat: destination.lat, lng: destination.lng },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to set waypoint position", e);
|
||||
}
|
||||
},
|
||||
}}
|
||||
ref={mergeRefs(ref, marker)}
|
||||
>
|
||||
<Tooltip position={waypoint.position} />
|
||||
</Marker>
|
||||
marker.current?.setTooltipContent(
|
||||
`${props.number} ${waypoint.name}<br />` +
|
||||
`${waypoint.altitude_ft.toFixed()} ft ${waypoint.altitude_reference}<br />` +
|
||||
waypoint.timing
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const waypoint = props.waypoint;
|
||||
return (
|
||||
<Marker
|
||||
position={waypoint.position}
|
||||
icon={WAYPOINT_ICON}
|
||||
draggable
|
||||
eventHandlers={{
|
||||
dragstart: (e) => {
|
||||
const m: LMarker = e.target;
|
||||
m.setTooltipContent("Waiting to recompute TOT...");
|
||||
},
|
||||
dragend: async (e) => {
|
||||
const m: LMarker = e.target;
|
||||
const destination = m.getLatLng();
|
||||
try {
|
||||
await putDestination({
|
||||
flightId: props.flight.id,
|
||||
waypointIdx: props.number,
|
||||
leafletPoint: { lat: destination.lat, lng: destination.lng },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to set waypoint position", e);
|
||||
}
|
||||
},
|
||||
}}
|
||||
ref={(ref) => {
|
||||
if (ref != null) {
|
||||
marker.current = ref;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip position={waypoint.position} />
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
|
||||
export default WaypointMarker;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import mergeRefs from "./mergeRefs";
|
||||
|
||||
describe("mergeRefs", () => {
|
||||
it("merges all kinds of refs", () => {
|
||||
const referent = "foobar";
|
||||
const ref = { current: null };
|
||||
var callbackResult = null;
|
||||
const callbackRef = (node: string | null) => {
|
||||
if (node != null) {
|
||||
callbackResult = node;
|
||||
}
|
||||
};
|
||||
mergeRefs(ref, callbackRef)(referent);
|
||||
expect(callbackResult).toEqual("foobar");
|
||||
expect(ref.current).toEqual("foobar");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ForwardedRef } from "react";
|
||||
|
||||
const mergeRefs = <T extends any>(...refs: ForwardedRef<T>[]) => {
|
||||
return (node: T) => {
|
||||
for (const ref of refs) {
|
||||
if (ref == null) {
|
||||
} else if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else {
|
||||
ref.current = node;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default mergeRefs;
|
||||
@@ -9,7 +9,7 @@
|
||||
project = "DCS Liberation"
|
||||
copyright = "2023, DCS Liberation Team"
|
||||
author = "DCS Liberation Team"
|
||||
release = "9.0.0"
|
||||
release = "8.1.0"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
@@ -10,18 +10,19 @@ import yaml
|
||||
from dcs.unittype import ShipType, StaticType, UnitType as DcsUnitType, VehicleType
|
||||
|
||||
from game.data.groups import GroupTask
|
||||
from game.data.radar_db import UNITS_WITH_RADAR
|
||||
from game.dcs.groundunittype import GroundUnitType
|
||||
from game.dcs.helpers import static_type_from_name
|
||||
from game.dcs.shipunittype import ShipUnitType
|
||||
from game.dcs.unittype import UnitType
|
||||
from game.layout import LAYOUTS
|
||||
from game.layout.layout import TgoLayout, TgoLayoutUnitGroup
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.theater.theatergroundobject import (
|
||||
IadsGroundObject,
|
||||
IadsBuildingGroundObject,
|
||||
NavalGroundObject,
|
||||
)
|
||||
from game.layout import LAYOUTS
|
||||
from game.layout.layout import TgoLayout, TgoLayoutUnitGroup
|
||||
from game.point_with_heading import PointWithHeading
|
||||
from game.theater.theatergroup import IadsGroundGroup, IadsRole, TheaterGroup
|
||||
from game.utils import escape_string_for_lua
|
||||
|
||||
@@ -287,7 +288,7 @@ class ForceGroup:
|
||||
unit.id = game.next_unit_id()
|
||||
# Add unit name escaped so that we do not have scripting issues later
|
||||
unit.name = escape_string_for_lua(
|
||||
unit.unit_type.variant_id if unit.unit_type else unit.type.name
|
||||
unit.unit_type.name if unit.unit_type else unit.type.name
|
||||
)
|
||||
unit.position = PointWithHeading.from_point(
|
||||
ground_object.position + unit.position,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, List, Optional, TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
from dcs.planes import C_101CC, C_101EB, Su_33
|
||||
|
||||
from .flightmembers import FlightMembers
|
||||
from .flightroster import FlightRoster
|
||||
from .flightstate import FlightState, Navigating, Uninitialized
|
||||
from .flightstate.killed import Killed
|
||||
from .loadouts import Loadout
|
||||
from ..sidc import (
|
||||
Entity,
|
||||
SidcDescribable,
|
||||
@@ -27,8 +26,6 @@ if TYPE_CHECKING:
|
||||
from game.squadrons import Squadron, Pilot
|
||||
from game.theater import ControlPoint
|
||||
from game.transfers import TransferOrder
|
||||
from game.data.weapons import WeaponType
|
||||
from .flightmember import FlightMember
|
||||
from .flightplans.flightplan import FlightPlan
|
||||
from .flighttype import FlightType
|
||||
from .flightwaypoint import FlightWaypoint
|
||||
@@ -55,16 +52,17 @@ class Flight(SidcDescribable):
|
||||
self.country = country
|
||||
self.coalition = squadron.coalition
|
||||
self.squadron = squadron
|
||||
self.flight_type = flight_type
|
||||
self.squadron.claim_inventory(count)
|
||||
if roster is None:
|
||||
self.roster = FlightMembers(self, initial_size=count)
|
||||
self.roster = FlightRoster(self.squadron, initial_size=count)
|
||||
else:
|
||||
self.roster = FlightMembers.from_roster(self, roster)
|
||||
self.roster = roster
|
||||
self.divert = divert
|
||||
self.flight_type = flight_type
|
||||
self.loadout = Loadout.default_for(self)
|
||||
self.start_type = start_type
|
||||
self.use_custom_loadout = False
|
||||
self.custom_name = custom_name
|
||||
self.use_same_loadout_for_all_members = True
|
||||
|
||||
# Only used by transport missions.
|
||||
self.cargo = cargo
|
||||
@@ -97,13 +95,6 @@ class Flight(SidcDescribable):
|
||||
|
||||
self._flight_plan_builder = CustomBuilder(self, self.flight_plan.waypoints[1:])
|
||||
self.recreate_flight_plan()
|
||||
# We need to clear the existing actions/options when moving the waypoints into
|
||||
# the new flight plan because the actions/options that are currently set will be
|
||||
# the actions of whatever flight plan was previously used.
|
||||
# https://github.com/dcs-liberation/dcs_liberation/issues/3189
|
||||
for waypoint in self.flight_plan.iter_waypoints():
|
||||
waypoint.actions.clear()
|
||||
waypoint.options.clear()
|
||||
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
@@ -158,6 +149,10 @@ class Flight(SidcDescribable):
|
||||
def is_helo(self) -> bool:
|
||||
return self.unit_type.dcs_unit_type.helicopter
|
||||
|
||||
@property
|
||||
def from_cp(self) -> ControlPoint:
|
||||
return self.departure
|
||||
|
||||
@property
|
||||
def points(self) -> List[FlightWaypoint]:
|
||||
return self.flight_plan.waypoints[1:]
|
||||
@@ -176,9 +171,6 @@ class Flight(SidcDescribable):
|
||||
def missing_pilots(self) -> int:
|
||||
return self.roster.missing_pilots
|
||||
|
||||
def iter_members(self) -> Iterator[FlightMember]:
|
||||
yield from self.roster.members
|
||||
|
||||
def set_flight_type(self, var: FlightType) -> None:
|
||||
self.flight_type = var
|
||||
|
||||
@@ -204,11 +196,6 @@ class Flight(SidcDescribable):
|
||||
return unit_type.fuel_max * 0.5
|
||||
return None
|
||||
|
||||
def any_member_has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
|
||||
return any(
|
||||
m.loadout.has_weapon_of_type(weapon_type) for m in self.iter_members()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.custom_name:
|
||||
return f"{self.custom_name} {self.count} x {self.unit_type}"
|
||||
@@ -269,9 +256,9 @@ class Flight(SidcDescribable):
|
||||
Killed(self.state.estimate_position(), self, self.squadron.settings)
|
||||
)
|
||||
events.update_flight(self)
|
||||
for pilot in self.roster.iter_pilots():
|
||||
for pilot in self.roster.pilots:
|
||||
if pilot is not None:
|
||||
results.kill_pilot(self, pilot)
|
||||
|
||||
def recreate_flight_plan(self, dump_debug_info: bool = False) -> None:
|
||||
self._flight_plan_builder.regenerate(dump_debug_info)
|
||||
def recreate_flight_plan(self) -> None:
|
||||
self._flight_plan_builder.regenerate()
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.ato.loadouts import Loadout
|
||||
from game.lasercodes import LaserCode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot
|
||||
|
||||
|
||||
class FlightMember:
|
||||
def __init__(self, pilot: Pilot | None, loadout: Loadout) -> None:
|
||||
self.pilot = pilot
|
||||
self.loadout = loadout
|
||||
self.use_custom_loadout = False
|
||||
self.tgp_laser_code: LaserCode | None = None
|
||||
self.weapon_laser_code: LaserCode | None = None
|
||||
self.properties: dict[str, bool | float | int] = {}
|
||||
|
||||
def assign_tgp_laser_code(self, code: LaserCode) -> None:
|
||||
if self.tgp_laser_code is not None:
|
||||
raise RuntimeError(
|
||||
f"{self.pilot} already has already been assigned laser code "
|
||||
f"{self.tgp_laser_code}"
|
||||
)
|
||||
self.tgp_laser_code = code
|
||||
|
||||
def release_tgp_laser_code(self) -> None:
|
||||
if self.tgp_laser_code is None:
|
||||
raise RuntimeError(f"{self.pilot} has no assigned laser code")
|
||||
|
||||
if self.weapon_laser_code == self.tgp_laser_code:
|
||||
self.weapon_laser_code = None
|
||||
self.tgp_laser_code.release()
|
||||
self.tgp_laser_code = None
|
||||
|
||||
@property
|
||||
def is_player(self) -> bool:
|
||||
if self.pilot is None:
|
||||
return False
|
||||
return self.pilot.player
|
||||
@@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from .flightmember import FlightMember
|
||||
from .flightroster import FlightRoster
|
||||
from .iflightroster import IFlightRoster
|
||||
from .loadouts import Loadout
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot
|
||||
from .flight import Flight
|
||||
|
||||
|
||||
class FlightMembers(IFlightRoster):
|
||||
def __init__(self, flight: Flight, initial_size: int = 0) -> None:
|
||||
self.flight = flight
|
||||
self.members: list[FlightMember] = []
|
||||
self.resize(initial_size)
|
||||
|
||||
@staticmethod
|
||||
def from_roster(flight: Flight, roster: FlightRoster) -> FlightMembers:
|
||||
members = FlightMembers(flight)
|
||||
loadout = Loadout.default_for(flight)
|
||||
members.members = [FlightMember(p, loadout) for p in roster.pilots]
|
||||
return members
|
||||
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
yield from (m.pilot for m in self.members)
|
||||
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
return self.members[idx].pilot
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return len(self.members)
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
return len([m for m in self.members if m.is_player])
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return len([m for m in self.members if m.pilot is None])
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.max_size > new_size:
|
||||
for member in self.members[new_size:]:
|
||||
if (pilot := member.pilot) is not None:
|
||||
self.flight.squadron.return_pilot(pilot)
|
||||
if (code := member.tgp_laser_code) is not None:
|
||||
code.release()
|
||||
self.members = self.members[:new_size]
|
||||
return
|
||||
if self.max_size:
|
||||
loadout = self.members[0].loadout.clone()
|
||||
else:
|
||||
loadout = Loadout.default_for(self.flight)
|
||||
for _ in range(new_size - self.max_size):
|
||||
member = FlightMember(self.flight.squadron.claim_available_pilot(), loadout)
|
||||
member.use_custom_loadout = loadout.is_custom
|
||||
self.members.append(member)
|
||||
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
if pilot is not None:
|
||||
self.flight.squadron.claim_pilot(pilot)
|
||||
if (current_pilot := self.pilot_at(index)) is not None:
|
||||
self.flight.squadron.return_pilot(current_pilot)
|
||||
self.members[index].pilot = pilot
|
||||
|
||||
def clear(self) -> None:
|
||||
self.flight.squadron.return_pilots(
|
||||
[p for p in self.iter_pilots() if p is not None]
|
||||
)
|
||||
for member in self.members:
|
||||
if (code := member.tgp_laser_code) is not None:
|
||||
code.release()
|
||||
|
||||
def use_same_loadout_for_all_members(self) -> None:
|
||||
if not self.members:
|
||||
return
|
||||
loadout = self.members[0].loadout
|
||||
for member in self.members[1:]:
|
||||
# Do not clone the loadout, we want any changes in the UI to be mirrored
|
||||
# across all flight members.
|
||||
member.loadout = loadout
|
||||
|
||||
def use_distinct_loadouts_for_each_member(self) -> None:
|
||||
for member in self.members:
|
||||
member.loadout = member.loadout.clone()
|
||||
@@ -90,5 +90,5 @@ class Builder(IBuilder[AewcFlightPlan, PatrollingLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> AewcFlightPlan:
|
||||
def build(self) -> AewcFlightPlan:
|
||||
return AewcFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -152,5 +152,5 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> AirAssaultFlightPlan:
|
||||
def build(self) -> AirAssaultFlightPlan:
|
||||
return AirAssaultFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -155,5 +155,5 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> AirliftFlightPlan:
|
||||
def build(self) -> AirliftFlightPlan:
|
||||
return AirliftFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -35,11 +35,11 @@ class Builder(FormationAttackBuilder[AntiShipFlightPlan, FormationAttackLayout])
|
||||
else:
|
||||
raise InvalidObjectiveLocation(self.flight.flight_type, location)
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_ANTI_SHIP, targets)
|
||||
return self._build(FlightWaypointType.INGRESS_BAI, targets)
|
||||
|
||||
@staticmethod
|
||||
def anti_ship_targets_for_tgo(tgo: NavalGroundObject) -> list[StrikeTarget]:
|
||||
return [StrikeTarget(f"{g.group_name} at {tgo.name}", g) for g in tgo.groups]
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> AntiShipFlightPlan:
|
||||
def build(self) -> AntiShipFlightPlan:
|
||||
return AntiShipFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -39,5 +39,5 @@ class Builder(FormationAttackBuilder[BaiFlightPlan, FormationAttackLayout]):
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_BAI, targets)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> BaiFlightPlan:
|
||||
def build(self) -> BaiFlightPlan:
|
||||
return BaiFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -66,5 +66,5 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> BarCapFlightPlan:
|
||||
def build(self) -> BarCapFlightPlan:
|
||||
return BarCapFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import random
|
||||
from abc import ABC
|
||||
from typing import Any, TYPE_CHECKING, TypeVar
|
||||
@@ -27,9 +26,6 @@ class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
self, location: MissionTarget, barcap: bool
|
||||
) -> tuple[Point, Point]:
|
||||
closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
|
||||
closest_friendly_field = (
|
||||
None # keep track of closest frieldly airfield in case we need it
|
||||
)
|
||||
for airfield in closest_cache.operational_airfields:
|
||||
# If the mission is a BARCAP of an enemy airfield, find the *next*
|
||||
# closest enemy airfield.
|
||||
@@ -38,43 +34,8 @@ class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
if airfield.captured != self.is_player:
|
||||
closest_airfield = airfield
|
||||
break
|
||||
elif closest_friendly_field is None:
|
||||
closest_friendly_field = airfield
|
||||
else:
|
||||
if barcap:
|
||||
# If planning a BARCAP, we should be able to find at least one enemy
|
||||
# airfield. If we can't, it's an error.
|
||||
raise PlanningError("Could not find any enemy airfields")
|
||||
else:
|
||||
# if we cannot find any friendly or enemy airfields other than the target,
|
||||
# there's nothing we can do
|
||||
if closest_friendly_field is None:
|
||||
raise PlanningError(
|
||||
"Could not find any enemy or friendly airfields"
|
||||
)
|
||||
|
||||
# If planning other race tracks (TARCAPs, currently), the target may be
|
||||
# the only enemy airfield. In this case, set the race track orientation using
|
||||
# a virtual point equi-distant from but opposite to the target from the closest
|
||||
# friendly airfield like below, where F is the closest friendly airfield, T is
|
||||
# the sole enemy airfield and V the virtual point
|
||||
#
|
||||
# F ---- T ----- V
|
||||
#
|
||||
# We need to create this virtual point, rather than using F to make sure
|
||||
# the race track is aligned towards the target.
|
||||
closest_friendly_field_position = copy.deepcopy(
|
||||
closest_friendly_field.position
|
||||
)
|
||||
closest_airfield = closest_friendly_field
|
||||
closest_airfield.position.x = (
|
||||
2 * self.package.target.position.x
|
||||
- closest_friendly_field_position.x
|
||||
)
|
||||
closest_airfield.position.y = (
|
||||
2 * self.package.target.position.y
|
||||
- closest_friendly_field_position.y
|
||||
)
|
||||
raise PlanningError("Could not find any enemy airfields")
|
||||
|
||||
heading = Heading.from_degrees(
|
||||
location.position.heading_between_point(closest_airfield.position)
|
||||
|
||||
@@ -6,15 +6,13 @@ from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from game.theater import FrontLine
|
||||
from game.utils import Distance, Speed, kph, meters, dcs_to_shapely_point
|
||||
from game.utils import Distance, Speed, kph, meters
|
||||
from .ibuilder import IBuilder
|
||||
from .invalidobjectivelocation import InvalidObjectiveLocation
|
||||
from .patrolling import PatrollingFlightPlan, PatrollingLayout
|
||||
from .uizonedisplay import UiZone, UiZoneDisplay
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ...flightplan.ipsolver import IpSolver
|
||||
from ...persistence.paths import waypoint_debug_directory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -22,13 +20,13 @@ if TYPE_CHECKING:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CasLayout(PatrollingLayout):
|
||||
ingress: FlightWaypoint
|
||||
target: FlightWaypoint
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield from self.nav_to
|
||||
yield self.ingress
|
||||
yield self.patrol_start
|
||||
yield self.target
|
||||
yield self.patrol_end
|
||||
yield from self.nav_from
|
||||
yield self.arrival
|
||||
@@ -61,20 +59,23 @@ class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
|
||||
|
||||
@property
|
||||
def combat_speed_waypoints(self) -> set[FlightWaypoint]:
|
||||
return {self.layout.ingress, self.layout.patrol_start, self.layout.patrol_end}
|
||||
return {self.layout.patrol_start, self.layout.target, self.layout.patrol_end}
|
||||
|
||||
def request_escort_at(self) -> FlightWaypoint | None:
|
||||
return self.layout.patrol_start
|
||||
|
||||
def dismiss_escort_at(self) -> FlightWaypoint | None:
|
||||
return self.layout.patrol_end
|
||||
|
||||
def ui_zone(self) -> UiZone:
|
||||
midpoint = (
|
||||
self.layout.patrol_start.position + self.layout.patrol_end.position
|
||||
) / 2
|
||||
return UiZone(
|
||||
[midpoint],
|
||||
[self.layout.target.position],
|
||||
self.engagement_distance,
|
||||
)
|
||||
|
||||
|
||||
class Builder(IBuilder[CasFlightPlan, CasLayout]):
|
||||
def layout(self, dump_debug_info: bool) -> CasLayout:
|
||||
def layout(self) -> CasLayout:
|
||||
location = self.package.target
|
||||
|
||||
if not isinstance(location, FrontLine):
|
||||
@@ -85,79 +86,46 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]):
|
||||
)
|
||||
|
||||
bounds = FrontLineConflictDescription.frontline_bounds(location, self.theater)
|
||||
patrol_start = bounds.left_position
|
||||
patrol_end = bounds.right_position
|
||||
ingress = bounds.left_position
|
||||
center = bounds.center
|
||||
egress = bounds.right_position
|
||||
|
||||
start_distance = patrol_start.distance_to_point(self.flight.departure.position)
|
||||
end_distance = patrol_end.distance_to_point(self.flight.departure.position)
|
||||
if end_distance < start_distance:
|
||||
patrol_start, patrol_end = patrol_end, patrol_start
|
||||
ingress_distance = ingress.distance_to_point(self.flight.departure.position)
|
||||
egress_distance = egress.distance_to_point(self.flight.departure.position)
|
||||
if egress_distance < ingress_distance:
|
||||
ingress, egress = egress, ingress
|
||||
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
|
||||
is_helo = self.flight.unit_type.dcs_unit_type.helicopter
|
||||
patrol_altitude = self.doctrine.ingress_altitude if not is_helo else meters(50)
|
||||
use_agl_patrol_altitude = is_helo
|
||||
|
||||
ip_solver = IpSolver(
|
||||
dcs_to_shapely_point(self.flight.departure.position),
|
||||
dcs_to_shapely_point(patrol_start),
|
||||
self.doctrine,
|
||||
self.threat_zones.all,
|
||||
ingress_egress_altitude = (
|
||||
self.doctrine.ingress_altitude if not is_helo else meters(50)
|
||||
)
|
||||
ip_solver.set_debug_properties(
|
||||
waypoint_debug_directory() / "IP", self.theater.terrain
|
||||
)
|
||||
ingress_point_shapely = ip_solver.solve()
|
||||
if dump_debug_info:
|
||||
ip_solver.dump_debug_info()
|
||||
|
||||
ingress_point = patrol_start.new_in_same_map(
|
||||
ingress_point_shapely.x, ingress_point_shapely.y
|
||||
)
|
||||
|
||||
patrol_start_waypoint = builder.nav(
|
||||
patrol_start, patrol_altitude, use_agl_patrol_altitude
|
||||
)
|
||||
patrol_start_waypoint.name = "FLOT START"
|
||||
patrol_start_waypoint.pretty_name = "FLOT start"
|
||||
patrol_start_waypoint.description = "FLOT boundary"
|
||||
patrol_start_waypoint.wants_escort = True
|
||||
|
||||
patrol_end_waypoint = builder.nav(
|
||||
patrol_end, patrol_altitude, use_agl_patrol_altitude
|
||||
)
|
||||
patrol_end_waypoint.name = "FLOT END"
|
||||
patrol_end_waypoint.pretty_name = "FLOT end"
|
||||
patrol_end_waypoint.description = "FLOT boundary"
|
||||
patrol_end_waypoint.wants_escort = True
|
||||
|
||||
ingress = builder.ingress(
|
||||
FlightWaypointType.INGRESS_CAS, ingress_point, location
|
||||
)
|
||||
ingress.description = f"Ingress to provide CAS at {location}"
|
||||
use_agl_ingress_egress = is_helo
|
||||
|
||||
return CasLayout(
|
||||
departure=builder.takeoff(self.flight.departure),
|
||||
nav_to=builder.nav_path(
|
||||
self.flight.departure.position,
|
||||
ingress_point,
|
||||
patrol_altitude,
|
||||
use_agl_patrol_altitude,
|
||||
ingress,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
),
|
||||
nav_from=builder.nav_path(
|
||||
patrol_end,
|
||||
egress,
|
||||
self.flight.arrival.position,
|
||||
patrol_altitude,
|
||||
use_agl_patrol_altitude,
|
||||
ingress_egress_altitude,
|
||||
use_agl_ingress_egress,
|
||||
),
|
||||
ingress=ingress,
|
||||
patrol_start=patrol_start_waypoint,
|
||||
patrol_end=patrol_end_waypoint,
|
||||
patrol_start=builder.ingress(
|
||||
FlightWaypointType.INGRESS_CAS, ingress, location
|
||||
),
|
||||
target=builder.cas(center),
|
||||
patrol_end=builder.egress(egress, location),
|
||||
arrival=builder.land(self.flight.arrival),
|
||||
divert=builder.divert(self.flight.divert),
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> CasFlightPlan:
|
||||
return CasFlightPlan(self.flight, self.layout(dump_debug_info))
|
||||
def build(self) -> CasFlightPlan:
|
||||
return CasFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -72,5 +72,5 @@ class Builder(IBuilder[CustomFlightPlan, CustomLayout]):
|
||||
builder = WaypointBuilder(self.flight, self.coalition)
|
||||
return CustomLayout(builder.takeoff(self.flight.departure), self.waypoints)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> CustomFlightPlan:
|
||||
def build(self) -> CustomFlightPlan:
|
||||
return CustomFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -37,5 +37,5 @@ class Builder(FormationAttackBuilder[DeadFlightPlan, FormationAttackLayout]):
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_DEAD)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> DeadFlightPlan:
|
||||
def build(self) -> DeadFlightPlan:
|
||||
return DeadFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -50,5 +50,5 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> EscortFlightPlan:
|
||||
def build(self) -> EscortFlightPlan:
|
||||
return EscortFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -83,5 +83,5 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> FerryFlightPlan:
|
||||
def build(self) -> FerryFlightPlan:
|
||||
return FerryFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -12,6 +12,7 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cached_property
|
||||
from typing import Any, Generic, TYPE_CHECKING, TypeGuard, TypeVar
|
||||
|
||||
from game.typeguard import self_type_guard
|
||||
@@ -19,9 +20,11 @@ from game.utils import Distance, Speed, meters
|
||||
from .planningerror import PlanningError
|
||||
from ..flightwaypointtype import FlightWaypointType
|
||||
from ..starttype import StartType
|
||||
from ..traveltime import GroundSpeed
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
from ...savecompat import has_save_compat_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.dcs.aircrafttype import FuelConsumption
|
||||
from game.theater import ControlPoint
|
||||
from ..flight import Flight
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -30,6 +33,14 @@ if TYPE_CHECKING:
|
||||
from .loiter import LoiterFlightPlan
|
||||
from .patrolling import PatrollingFlightPlan
|
||||
|
||||
INGRESS_TYPES = {
|
||||
FlightWaypointType.INGRESS_CAS,
|
||||
FlightWaypointType.INGRESS_ESCORT,
|
||||
FlightWaypointType.INGRESS_SEAD,
|
||||
FlightWaypointType.INGRESS_STRIKE,
|
||||
FlightWaypointType.INGRESS_DEAD,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout(ABC):
|
||||
@@ -54,6 +65,12 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
self.layout = layout
|
||||
self.tot_offset = self.default_tot_offset()
|
||||
|
||||
@has_save_compat_for(7)
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
if "tot_offset" not in state:
|
||||
state["tot_offset"] = self.default_tot_offset()
|
||||
self.__dict__.update(state)
|
||||
|
||||
@property
|
||||
def package(self) -> Package:
|
||||
return self.flight.package
|
||||
@@ -143,6 +160,39 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
def tot(self) -> datetime:
|
||||
return self.package.time_over_target + self.tot_offset
|
||||
|
||||
@cached_property
|
||||
def bingo_fuel(self) -> int:
|
||||
"""Bingo fuel value for the FlightPlan"""
|
||||
if (fuel := self.flight.unit_type.fuel_consumption) is not None:
|
||||
return self._bingo_estimate(fuel)
|
||||
return self._legacy_bingo_estimate()
|
||||
|
||||
def _bingo_estimate(self, fuel: FuelConsumption) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
|
||||
bingo = fuel_consumed + fuel.min_safe
|
||||
return math.ceil(bingo / 100) * 100
|
||||
|
||||
def _legacy_bingo_estimate(self) -> int:
|
||||
distance_to_arrival = self.max_distance_from(self.flight.arrival)
|
||||
|
||||
bingo = 1000.0 # Minimum Emergency Fuel
|
||||
bingo += 500 # Visual Traffic
|
||||
bingo += 15 * distance_to_arrival.nautical_miles
|
||||
|
||||
# TODO: Per aircraft tweaks.
|
||||
|
||||
if self.flight.divert is not None:
|
||||
max_divert_distance = self.max_distance_from(self.flight.divert)
|
||||
bingo += 10 * max_divert_distance.nautical_miles
|
||||
|
||||
return round(bingo / 100) * 100
|
||||
|
||||
@cached_property
|
||||
def joker_fuel(self) -> int:
|
||||
"""Joker fuel value for the FlightPlan"""
|
||||
return self.bingo_fuel + 1000
|
||||
|
||||
def max_distance_from(self, cp: ControlPoint) -> Distance:
|
||||
"""Returns the farthest waypoint of the flight plan from a ControlPoint.
|
||||
:arg cp The ControlPoint to measure distance from.
|
||||
@@ -171,7 +221,7 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
)
|
||||
|
||||
for previous_waypoint, waypoint in self.edges(until=destination):
|
||||
total += self.total_time_between_waypoints(previous_waypoint, waypoint)
|
||||
total += self.travel_time_between_waypoints(previous_waypoint, waypoint)
|
||||
|
||||
# Trim microseconds. Our simulation tick rate is 1 second, so anything that
|
||||
# takes 100.1 or 100.9 seconds will take 100 seconds. DCS doesn't handle
|
||||
@@ -180,23 +230,12 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
# model.
|
||||
return timedelta(seconds=math.floor(total.total_seconds()))
|
||||
|
||||
def total_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
"""Returns the total time spent between a and b.
|
||||
|
||||
The total time between waypoints differs from the travel time in that it may
|
||||
include additional time for actions such as loitering.
|
||||
"""
|
||||
return self.travel_time_between_waypoints(a, b)
|
||||
|
||||
def travel_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
error_factor = 1.05
|
||||
speed = self.speed_between_waypoints(a, b)
|
||||
distance = meters(a.position.distance_to_point(b.position))
|
||||
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
|
||||
return TravelTime.between_points(
|
||||
a.position, b.position, self.speed_between_waypoints(a, b)
|
||||
)
|
||||
|
||||
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
|
||||
raise NotImplementedError
|
||||
@@ -205,21 +244,24 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
raise NotImplementedError
|
||||
|
||||
def request_escort_at(self) -> FlightWaypoint | None:
|
||||
try:
|
||||
return next(self.escorted_waypoints())
|
||||
except StopIteration:
|
||||
return None
|
||||
return None
|
||||
|
||||
def dismiss_escort_at(self) -> FlightWaypoint | None:
|
||||
try:
|
||||
return list(self.escorted_waypoints())[-1]
|
||||
except IndexError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def escorted_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
for waypoint in self.iter_waypoints():
|
||||
if waypoint.wants_escort:
|
||||
begin = self.request_escort_at()
|
||||
end = self.dismiss_escort_at()
|
||||
if begin is None or end is None:
|
||||
return
|
||||
escorting = False
|
||||
for waypoint in self.waypoints:
|
||||
if waypoint == begin:
|
||||
escorting = True
|
||||
if escorting:
|
||||
yield waypoint
|
||||
if waypoint == end:
|
||||
return
|
||||
|
||||
def takeoff_time(self) -> datetime:
|
||||
return self.tot - self._travel_time_to_waypoint(self.tot_waypoint)
|
||||
@@ -248,7 +290,7 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
def estimate_ground_ops(self) -> timedelta:
|
||||
if self.flight.start_type in {StartType.RUNWAY, StartType.IN_FLIGHT}:
|
||||
return timedelta()
|
||||
if self.flight.departure.is_fleet:
|
||||
if self.flight.from_cp.is_fleet:
|
||||
return timedelta(minutes=2)
|
||||
else:
|
||||
return timedelta(minutes=8)
|
||||
@@ -269,9 +311,7 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
raise NotImplementedError
|
||||
|
||||
@self_type_guard
|
||||
def is_loiter(
|
||||
self, flight_plan: FlightPlan[Any]
|
||||
) -> TypeGuard[LoiterFlightPlan[Any]]:
|
||||
def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]:
|
||||
return False
|
||||
|
||||
@self_type_guard
|
||||
@@ -283,8 +323,5 @@ class FlightPlan(ABC, Generic[LayoutT]):
|
||||
@self_type_guard
|
||||
def is_formation(
|
||||
self, flight_plan: FlightPlan[Any]
|
||||
) -> TypeGuard[FormationFlightPlan[Any]]:
|
||||
) -> TypeGuard[FormationFlightPlan]:
|
||||
return False
|
||||
|
||||
def add_waypoint_actions(self) -> None:
|
||||
pass
|
||||
|
||||
@@ -4,12 +4,13 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cached_property
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard
|
||||
|
||||
from game.typeguard import self_type_guard
|
||||
from game.utils import Speed
|
||||
from .flightplan import FlightPlan
|
||||
from .loiter import LoiterFlightPlan, LoiterLayout
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -24,10 +25,7 @@ class FormationLayout(LoiterLayout, ABC):
|
||||
nav_from: list[FlightWaypoint]
|
||||
|
||||
|
||||
LayoutT = TypeVar("LayoutT", bound=FormationLayout)
|
||||
|
||||
|
||||
class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
|
||||
class FormationFlightPlan(LoiterFlightPlan, ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def package_speed_waypoints(self) -> set[FlightWaypoint]:
|
||||
@@ -37,6 +35,12 @@ class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
|
||||
def combat_speed_waypoints(self) -> set[FlightWaypoint]:
|
||||
return self.package_speed_waypoints
|
||||
|
||||
def request_escort_at(self) -> FlightWaypoint | None:
|
||||
return self.layout.join
|
||||
|
||||
def dismiss_escort_at(self) -> FlightWaypoint | None:
|
||||
return self.layout.split
|
||||
|
||||
@cached_property
|
||||
def best_flight_formation_speed(self) -> Speed:
|
||||
"""The best speed this flight is capable at all formation waypoints.
|
||||
@@ -86,8 +90,10 @@ class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
|
||||
|
||||
@property
|
||||
def push_time(self) -> datetime:
|
||||
return self.join_time - self.travel_time_between_waypoints(
|
||||
self.layout.hold, self.layout.join
|
||||
return self.join_time - TravelTime.between_points(
|
||||
self.layout.hold.position,
|
||||
self.layout.join.position,
|
||||
GroundSpeed.for_flight(self.flight, self.layout.hold.alt),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -101,5 +107,5 @@ class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
|
||||
@self_type_guard
|
||||
def is_formation(
|
||||
self, flight_plan: FlightPlan[Any]
|
||||
) -> TypeGuard[FormationFlightPlan[Any]]:
|
||||
) -> TypeGuard[FormationFlightPlan]:
|
||||
return True
|
||||
|
||||
@@ -14,6 +14,7 @@ from game.utils import Speed, meters
|
||||
from .flightplan import FlightPlan
|
||||
from .formation import FormationFlightPlan, FormationLayout
|
||||
from .ibuilder import IBuilder
|
||||
from .planningerror import PlanningError
|
||||
from .waypointbuilder import StrikeTarget, WaypointBuilder
|
||||
from .. import FlightType
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -23,29 +24,7 @@ if TYPE_CHECKING:
|
||||
from ..flight import Flight
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormationAttackLayout(FormationLayout):
|
||||
ingress: FlightWaypoint
|
||||
targets: list[FlightWaypoint]
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield self.hold
|
||||
yield from self.nav_to
|
||||
yield self.join
|
||||
yield self.ingress
|
||||
yield from self.targets
|
||||
yield self.split
|
||||
if self.refuel is not None:
|
||||
yield self.refuel
|
||||
yield from self.nav_from
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
|
||||
class FormationAttackFlightPlan(FormationFlightPlan[FormationAttackLayout], ABC):
|
||||
class FormationAttackFlightPlan(FormationFlightPlan, ABC):
|
||||
@property
|
||||
def package_speed_waypoints(self) -> set[FlightWaypoint]:
|
||||
return {
|
||||
@@ -77,19 +56,42 @@ class FormationAttackFlightPlan(FormationFlightPlan[FormationAttackLayout], ABC)
|
||||
"RADIO",
|
||||
)
|
||||
|
||||
@property
|
||||
def travel_time_to_target(self) -> timedelta:
|
||||
"""The estimated time between the first waypoint and the target."""
|
||||
destination = self.tot_waypoint
|
||||
total = timedelta()
|
||||
for previous_waypoint, waypoint in self.edges():
|
||||
if waypoint == self.tot_waypoint:
|
||||
# For anything strike-like the TOT waypoint is the *flight's*
|
||||
# mission target, but to synchronize with the rest of the
|
||||
# package we need to use the travel time to the same position as
|
||||
# the others.
|
||||
total += self.travel_time_between_waypoints(
|
||||
previous_waypoint, self.target_area_waypoint
|
||||
)
|
||||
break
|
||||
total += self.travel_time_between_waypoints(previous_waypoint, waypoint)
|
||||
else:
|
||||
raise PlanningError(
|
||||
f"Did not find destination waypoint {destination} in "
|
||||
f"waypoints for {self.flight}"
|
||||
)
|
||||
return total
|
||||
|
||||
@property
|
||||
def join_time(self) -> datetime:
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.layout.join, self.layout.ingress
|
||||
)
|
||||
return self.ingress_time - travel_time
|
||||
|
||||
@property
|
||||
def split_time(self) -> datetime:
|
||||
travel_time_ingress = self.total_time_between_waypoints(
|
||||
travel_time_ingress = self.travel_time_between_waypoints(
|
||||
self.layout.ingress, self.target_area_waypoint
|
||||
)
|
||||
travel_time_egress = self.total_time_between_waypoints(
|
||||
travel_time_egress = self.travel_time_between_waypoints(
|
||||
self.target_area_waypoint, self.layout.split
|
||||
)
|
||||
minutes_at_target = 0.75 * len(self.layout.targets)
|
||||
@@ -104,7 +106,7 @@ class FormationAttackFlightPlan(FormationFlightPlan[FormationAttackLayout], ABC)
|
||||
@property
|
||||
def ingress_time(self) -> datetime:
|
||||
tot = self.tot
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.layout.ingress, self.target_area_waypoint
|
||||
)
|
||||
return tot - travel_time
|
||||
@@ -117,6 +119,28 @@ class FormationAttackFlightPlan(FormationFlightPlan[FormationAttackLayout], ABC)
|
||||
return super().tot_for_waypoint(waypoint)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormationAttackLayout(FormationLayout):
|
||||
ingress: FlightWaypoint
|
||||
targets: list[FlightWaypoint]
|
||||
|
||||
def iter_waypoints(self) -> Iterator[FlightWaypoint]:
|
||||
yield self.departure
|
||||
yield self.hold
|
||||
yield from self.nav_to
|
||||
yield self.join
|
||||
yield self.ingress
|
||||
yield from self.targets
|
||||
yield self.split
|
||||
if self.refuel is not None:
|
||||
yield self.refuel
|
||||
yield from self.nav_from
|
||||
yield self.arrival
|
||||
if self.divert is not None:
|
||||
yield self.divert
|
||||
yield self.bullseye
|
||||
|
||||
|
||||
FlightPlanT = TypeVar("FlightPlanT", bound=FlightPlan[FormationAttackLayout])
|
||||
LayoutT = TypeVar("LayoutT", bound=FormationAttackLayout)
|
||||
|
||||
@@ -145,18 +169,7 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
|
||||
hold = builder.hold(self._hold_point())
|
||||
join = builder.join(self.package.waypoints.join)
|
||||
join.wants_escort = True
|
||||
|
||||
ingress = builder.ingress(
|
||||
ingress_type, self.package.waypoints.ingress, self.package.target
|
||||
)
|
||||
ingress.wants_escort = True
|
||||
|
||||
for target_waypoint in target_waypoints:
|
||||
target_waypoint.wants_escort = True
|
||||
|
||||
split = builder.split(self.package.waypoints.split)
|
||||
split.wants_escort = True
|
||||
refuel = builder.refuel(self.package.waypoints.refuel)
|
||||
|
||||
return FormationAttackLayout(
|
||||
@@ -166,7 +179,9 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
|
||||
hold.position, join.position, self.doctrine.ingress_altitude
|
||||
),
|
||||
join=join,
|
||||
ingress=ingress,
|
||||
ingress=builder.ingress(
|
||||
ingress_type, self.package.waypoints.ingress, self.package.target
|
||||
),
|
||||
targets=target_waypoints,
|
||||
split=split,
|
||||
refuel=refuel,
|
||||
|
||||
@@ -32,11 +32,10 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
assert self._flight_plan is not None
|
||||
return self._flight_plan
|
||||
|
||||
def regenerate(self, dump_debug_info: bool = False) -> None:
|
||||
def regenerate(self) -> None:
|
||||
try:
|
||||
self._generate_package_waypoints_if_needed(dump_debug_info)
|
||||
self._flight_plan = self.build(dump_debug_info)
|
||||
self._flight_plan.add_waypoint_actions()
|
||||
self._generate_package_waypoints_if_needed()
|
||||
self._flight_plan = self.build()
|
||||
except NavMeshError as ex:
|
||||
color = "blue" if self.flight.squadron.player else "red"
|
||||
raise PlanningError(
|
||||
@@ -44,15 +43,10 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
f"{self.flight.departure} to {self.package.target}"
|
||||
) from ex
|
||||
|
||||
def _generate_package_waypoints_if_needed(self, dump_debug_info: bool) -> None:
|
||||
# Package waypoints are only valid for offensive missions. Skip this if the
|
||||
# target is friendly.
|
||||
if self.package.target.is_friendly(self.is_player):
|
||||
return
|
||||
|
||||
if self.package.waypoints is None or dump_debug_info:
|
||||
def _generate_package_waypoints_if_needed(self) -> None:
|
||||
if self.package.waypoints is None:
|
||||
self.package.waypoints = PackageWaypoints.create(
|
||||
self.package, self.coalition, dump_debug_info
|
||||
self.package, self.coalition
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -60,7 +54,11 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
|
||||
return self.flight.departure.theater
|
||||
|
||||
@abstractmethod
|
||||
def build(self, dump_debug_info: bool = False) -> FlightPlanT:
|
||||
def layout(self) -> LayoutT:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build(self) -> FlightPlanT:
|
||||
...
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,11 +3,9 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar
|
||||
from typing import Any, TYPE_CHECKING, TypeGuard
|
||||
|
||||
from game.flightplan.waypointactions.hold import Hold
|
||||
from game.typeguard import self_type_guard
|
||||
from game.utils import Speed
|
||||
from .flightplan import FlightPlan
|
||||
from .standard import StandardFlightPlan, StandardLayout
|
||||
|
||||
@@ -20,10 +18,7 @@ class LoiterLayout(StandardLayout, ABC):
|
||||
hold: FlightWaypoint
|
||||
|
||||
|
||||
LayoutT = TypeVar("LayoutT", bound=LoiterLayout)
|
||||
|
||||
|
||||
class LoiterFlightPlan(StandardFlightPlan[LayoutT], ABC):
|
||||
class LoiterFlightPlan(StandardFlightPlan[Any], ABC):
|
||||
@property
|
||||
def hold_duration(self) -> timedelta:
|
||||
return timedelta(minutes=5)
|
||||
@@ -38,26 +33,14 @@ class LoiterFlightPlan(StandardFlightPlan[LayoutT], ABC):
|
||||
return self.push_time
|
||||
return None
|
||||
|
||||
def total_time_between_waypoints(
|
||||
def travel_time_between_waypoints(
|
||||
self, a: FlightWaypoint, b: FlightWaypoint
|
||||
) -> timedelta:
|
||||
travel_time = super().total_time_between_waypoints(a, b)
|
||||
travel_time = super().travel_time_between_waypoints(a, b)
|
||||
if a != self.layout.hold:
|
||||
return travel_time
|
||||
return travel_time + self.hold_duration
|
||||
|
||||
@self_type_guard
|
||||
def is_loiter(
|
||||
self, flight_plan: FlightPlan[Any]
|
||||
) -> TypeGuard[LoiterFlightPlan[Any]]:
|
||||
def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]:
|
||||
return True
|
||||
|
||||
def provide_push_time(self) -> datetime:
|
||||
return self.push_time
|
||||
|
||||
def add_waypoint_actions(self) -> None:
|
||||
hold = self.layout.hold
|
||||
speed = self.flight.unit_type.patrol_speed
|
||||
if speed is None:
|
||||
speed = Speed.from_mach(0.6, hold.alt)
|
||||
hold.add_action(Hold(self.provide_push_time, hold.alt, speed))
|
||||
|
||||
@@ -32,5 +32,5 @@ class Builder(FormationAttackBuilder[OcaAircraftFlightPlan, FormationAttackLayou
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_OCA_AIRCRAFT)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> OcaAircraftFlightPlan:
|
||||
def build(self) -> OcaAircraftFlightPlan:
|
||||
return OcaAircraftFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -32,5 +32,5 @@ class Builder(FormationAttackBuilder[OcaRunwayFlightPlan, FormationAttackLayout]
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_OCA_RUNWAY)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> OcaRunwayFlightPlan:
|
||||
def build(self) -> OcaRunwayFlightPlan:
|
||||
return OcaRunwayFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -59,10 +59,10 @@ class PackageRefuelingFlightPlan(RefuelingFlightPlan):
|
||||
"REFUEL", FlightWaypointType.REFUEL, refuel, altitude
|
||||
)
|
||||
|
||||
delay_target_to_split: timedelta = self.total_time_between_waypoints(
|
||||
delay_target_to_split: timedelta = self.travel_time_between_waypoints(
|
||||
self.target_area_waypoint(), split_waypoint
|
||||
)
|
||||
delay_split_to_refuel: timedelta = self.total_time_between_waypoints(
|
||||
delay_split_to_refuel: timedelta = self.travel_time_between_waypoints(
|
||||
split_waypoint, refuel_waypoint
|
||||
)
|
||||
|
||||
@@ -121,5 +121,5 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> PackageRefuelingFlightPlan:
|
||||
def build(self) -> PackageRefuelingFlightPlan:
|
||||
return PackageRefuelingFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -62,7 +62,7 @@ class PatrollingFlightPlan(StandardFlightPlan[LayoutT], UiZoneDisplay, ABC):
|
||||
|
||||
@property
|
||||
def patrol_start_time(self) -> datetime:
|
||||
return self.tot
|
||||
return self.package.time_over_target
|
||||
|
||||
@property
|
||||
def patrol_end_time(self) -> datetime:
|
||||
|
||||
@@ -93,5 +93,5 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> RtbFlightPlan:
|
||||
def build(self) -> RtbFlightPlan:
|
||||
return RtbFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -24,5 +24,5 @@ class Builder(FormationAttackBuilder[SeadFlightPlan, FormationAttackLayout]):
|
||||
def layout(self) -> FormationAttackLayout:
|
||||
return self._build(FlightWaypointType.INGRESS_SEAD)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> SeadFlightPlan:
|
||||
def build(self) -> SeadFlightPlan:
|
||||
return SeadFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -91,5 +91,5 @@ class Builder(IBuilder[RecoveryTankerFlightPlan, RecoveryTankerLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> RecoveryTankerFlightPlan:
|
||||
def build(self) -> RecoveryTankerFlightPlan:
|
||||
return RecoveryTankerFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -32,5 +32,5 @@ class Builder(FormationAttackBuilder[StrikeFlightPlan, FormationAttackLayout]):
|
||||
|
||||
return self._build(FlightWaypointType.INGRESS_STRIKE, targets)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> StrikeFlightPlan:
|
||||
def build(self) -> StrikeFlightPlan:
|
||||
return StrikeFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -5,15 +5,13 @@ from datetime import datetime, timedelta
|
||||
from typing import Iterator, TYPE_CHECKING, Type
|
||||
|
||||
from dcs import Point
|
||||
from dcs.task import Targets
|
||||
|
||||
from game.flightplan import HoldZoneGeometry
|
||||
from game.flightplan.waypointactions.engagetargets import EngageTargets
|
||||
from game.flightplan.waypointoptions.formation import Formation
|
||||
from game.utils import Heading, nautical_miles
|
||||
from game.utils import Heading
|
||||
from .ibuilder import IBuilder
|
||||
from .loiter import LoiterFlightPlan, LoiterLayout
|
||||
from .waypointbuilder import WaypointBuilder
|
||||
from ..traveltime import GroundSpeed, TravelTime
|
||||
from ...flightplan import HoldZoneGeometry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..flightwaypoint import FlightWaypoint
|
||||
@@ -39,7 +37,7 @@ class SweepLayout(LoiterLayout):
|
||||
yield self.bullseye
|
||||
|
||||
|
||||
class SweepFlightPlan(LoiterFlightPlan[SweepLayout]):
|
||||
class SweepFlightPlan(LoiterFlightPlan):
|
||||
@staticmethod
|
||||
def builder_type() -> Type[Builder]:
|
||||
return Builder
|
||||
@@ -57,7 +55,7 @@ class SweepFlightPlan(LoiterFlightPlan[SweepLayout]):
|
||||
|
||||
@property
|
||||
def sweep_start_time(self) -> datetime:
|
||||
travel_time = self.total_time_between_waypoints(
|
||||
travel_time = self.travel_time_between_waypoints(
|
||||
self.layout.sweep_start, self.layout.sweep_end
|
||||
)
|
||||
return self.sweep_end_time - travel_time
|
||||
@@ -80,8 +78,10 @@ class SweepFlightPlan(LoiterFlightPlan[SweepLayout]):
|
||||
|
||||
@property
|
||||
def push_time(self) -> datetime:
|
||||
return self.sweep_end_time - self.travel_time_between_waypoints(
|
||||
self.layout.hold, self.layout.sweep_end
|
||||
return self.sweep_end_time - TravelTime.between_points(
|
||||
self.layout.hold.position,
|
||||
self.layout.sweep_end.position,
|
||||
GroundSpeed.for_flight(self.flight, self.layout.hold.alt),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -92,19 +92,6 @@ class SweepFlightPlan(LoiterFlightPlan[SweepLayout]):
|
||||
def mission_departure_time(self) -> datetime:
|
||||
return self.sweep_end_time
|
||||
|
||||
def add_waypoint_actions(self) -> None:
|
||||
super().add_waypoint_actions()
|
||||
self.layout.sweep_start.set_option(Formation.LINE_ABREAST_OPEN)
|
||||
self.layout.sweep_start.add_action(
|
||||
EngageTargets(
|
||||
nautical_miles(50),
|
||||
[
|
||||
Targets.All.Air.Planes.Fighters,
|
||||
Targets.All.Air.Planes.MultiroleFighters,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
|
||||
def layout(self) -> SweepLayout:
|
||||
@@ -150,5 +137,5 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
|
||||
target, origin, ip, join, self.coalition, self.theater
|
||||
).find_best_hold_point()
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> SweepFlightPlan:
|
||||
def build(self) -> SweepFlightPlan:
|
||||
return SweepFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -122,5 +122,5 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> TarCapFlightPlan:
|
||||
def build(self) -> TarCapFlightPlan:
|
||||
return TarCapFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -79,5 +79,5 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
|
||||
bullseye=builder.bullseye(),
|
||||
)
|
||||
|
||||
def build(self, dump_debug_info: bool = False) -> TheaterRefuelingFlightPlan:
|
||||
def build(self) -> TheaterRefuelingFlightPlan:
|
||||
return TheaterRefuelingFlightPlan(self.flight, self.layout())
|
||||
|
||||
@@ -168,9 +168,6 @@ class WaypointBuilder:
|
||||
"HOLD",
|
||||
FlightWaypointType.LOITER,
|
||||
position,
|
||||
# Bug: DCS only accepts MSL altitudes for the orbit task and 500 meters is
|
||||
# below the ground for most if not all of NTTR (and lots of places in other
|
||||
# maps).
|
||||
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
|
||||
alt_type,
|
||||
description="Wait until push time",
|
||||
@@ -256,6 +253,21 @@ class WaypointBuilder:
|
||||
targets=objective.strike_targets,
|
||||
)
|
||||
|
||||
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
|
||||
alt_type: AltitudeReference = "BARO"
|
||||
if self.is_helo:
|
||||
alt_type = "RADIO"
|
||||
|
||||
return FlightWaypoint(
|
||||
"EGRESS",
|
||||
FlightWaypointType.EGRESS,
|
||||
position,
|
||||
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
|
||||
alt_type,
|
||||
description=f"EGRESS from {target.name}",
|
||||
pretty_name=f"EGRESS from {target.name}",
|
||||
)
|
||||
|
||||
def bai_group(self, target: StrikeTarget) -> FlightWaypoint:
|
||||
return self._target_point(target, f"ATTACK {target.name}")
|
||||
|
||||
@@ -345,6 +357,17 @@ class WaypointBuilder:
|
||||
waypoint.only_for_player = True
|
||||
return waypoint
|
||||
|
||||
def cas(self, position: Point) -> FlightWaypoint:
|
||||
return FlightWaypoint(
|
||||
"CAS",
|
||||
FlightWaypointType.CAS,
|
||||
position,
|
||||
meters(60) if self.is_helo else meters(1000),
|
||||
"RADIO",
|
||||
description="Provide CAS",
|
||||
pretty_name="CAS",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def race_track_start(position: Point, altitude: Distance) -> FlightWaypoint:
|
||||
"""Creates a racetrack start waypoint.
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from game.ato.iflightroster import IFlightRoster
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Squadron, Pilot
|
||||
|
||||
|
||||
class FlightRoster(IFlightRoster):
|
||||
class FlightRoster:
|
||||
def __init__(self, squadron: Squadron, initial_size: int = 0) -> None:
|
||||
self.squadron = squadron
|
||||
self.pilots: list[Optional[Pilot]] = []
|
||||
self.resize(initial_size)
|
||||
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
yield from self.pilots
|
||||
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
return self.pilots[idx]
|
||||
|
||||
@property
|
||||
def max_size(self) -> int:
|
||||
return len(self.pilots)
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
return len([p for p in self.pilots if p is not None and p.player])
|
||||
|
||||
@property
|
||||
def missing_pilots(self) -> int:
|
||||
return len([p for p in self.pilots if p is None])
|
||||
|
||||
def resize(self, new_size: int) -> None:
|
||||
if self.max_size > new_size:
|
||||
self.squadron.return_pilots(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.flightplan.waypointactions.waypointaction import WaypointAction
|
||||
|
||||
|
||||
class ActionState:
|
||||
def __init__(self, action: WaypointAction) -> None:
|
||||
self.action = action
|
||||
self._finished = False
|
||||
|
||||
def describe(self) -> str:
|
||||
return self.action.describe()
|
||||
|
||||
def finish(self) -> None:
|
||||
self._finished = True
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
return self._finished
|
||||
|
||||
def on_game_tick(self, time: datetime, duration: timedelta) -> timedelta:
|
||||
return self.action.update_state(self, time, duration)
|
||||
@@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.ato.flightstate import Completed
|
||||
from game.ato.flightstate.actionstate import ActionState
|
||||
from game.ato.flightstate.flightstate import FlightState
|
||||
from game.ato.flightwaypoint import FlightWaypoint
|
||||
from game.ato.flightwaypointtype import FlightWaypointType
|
||||
@@ -39,15 +37,6 @@ class InFlight(FlightState, ABC):
|
||||
self.total_time_to_next_waypoint = self.travel_time_between_waypoints()
|
||||
self.elapsed_time = timedelta()
|
||||
self.current_waypoint_elapsed = False
|
||||
self.pending_actions: deque[ActionState] = deque(
|
||||
ActionState(a) for a in self.current_waypoint.actions
|
||||
)
|
||||
|
||||
@property
|
||||
def current_action(self) -> ActionState | None:
|
||||
if self.pending_actions:
|
||||
return self.pending_actions[0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def cancelable(self) -> bool:
|
||||
@@ -62,9 +51,17 @@ class InFlight(FlightState, ABC):
|
||||
return index <= self.waypoint_index
|
||||
|
||||
def travel_time_between_waypoints(self) -> timedelta:
|
||||
return self.flight.flight_plan.travel_time_between_waypoints(
|
||||
travel_time = self.flight.flight_plan.travel_time_between_waypoints(
|
||||
self.current_waypoint, self.next_waypoint
|
||||
)
|
||||
if self.current_waypoint.waypoint_type is FlightWaypointType.LOITER:
|
||||
# Loiter time is already built into travel_time_between_waypoints. If we're
|
||||
# at a loiter point but still a regular InFlight (Loiter overrides this
|
||||
# method) that means we're traveling from the loiter point but no longer
|
||||
# loitering.
|
||||
assert self.flight.flight_plan.is_loiter(self.flight.flight_plan)
|
||||
travel_time -= self.flight.flight_plan.hold_duration
|
||||
return travel_time
|
||||
|
||||
@abstractmethod
|
||||
def estimate_position(self) -> Point:
|
||||
@@ -91,6 +88,7 @@ class InFlight(FlightState, ABC):
|
||||
return initial_fuel
|
||||
|
||||
def next_waypoint_state(self) -> FlightState:
|
||||
from .loiter import Loiter
|
||||
from .racetrack import RaceTrack
|
||||
from .navigating import Navigating
|
||||
|
||||
@@ -99,6 +97,8 @@ class InFlight(FlightState, ABC):
|
||||
return Completed(self.flight, self.settings)
|
||||
if self.next_waypoint.waypoint_type is FlightWaypointType.PATROL_TRACK:
|
||||
return RaceTrack(self.flight, self.settings, new_index)
|
||||
if self.next_waypoint.waypoint_type is FlightWaypointType.LOITER:
|
||||
return Loiter(self.flight, self.settings, new_index)
|
||||
return Navigating(self.flight, self.settings, new_index)
|
||||
|
||||
def advance_to_next_waypoint(self) -> FlightState:
|
||||
@@ -110,13 +110,6 @@ class InFlight(FlightState, ABC):
|
||||
def on_game_tick(
|
||||
self, events: GameUpdateEvents, time: datetime, duration: timedelta
|
||||
) -> None:
|
||||
while (action := self.current_action) is not None:
|
||||
duration = action.on_game_tick(time, duration)
|
||||
if action.is_finished():
|
||||
self.pending_actions.popleft()
|
||||
if duration <= timedelta():
|
||||
return
|
||||
|
||||
self.elapsed_time += duration
|
||||
if self.elapsed_time > self.total_time_to_next_waypoint:
|
||||
new_state = self.advance_to_next_waypoint()
|
||||
@@ -167,3 +160,11 @@ class InFlight(FlightState, ABC):
|
||||
@property
|
||||
def spawn_type(self) -> StartType:
|
||||
return StartType.IN_FLIGHT
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
if self.has_aborted:
|
||||
abort = "(Aborted) "
|
||||
else:
|
||||
abort = ""
|
||||
return f"{abort}Flying to {self.next_waypoint.name}"
|
||||
|
||||
46
game/ato/flightstate/loiter.py
Normal file
46
game/ato/flightstate/loiter.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.ato.flightstate import FlightState, InFlight
|
||||
from game.ato.flightstate.navigating import Navigating
|
||||
from game.utils import Distance, Speed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato.flight import Flight
|
||||
from game.settings import Settings
|
||||
|
||||
|
||||
class Loiter(InFlight):
|
||||
def __init__(self, flight: Flight, settings: Settings, waypoint_index: int) -> None:
|
||||
assert flight.flight_plan.is_loiter(flight.flight_plan)
|
||||
self.hold_duration = flight.flight_plan.hold_duration
|
||||
super().__init__(flight, settings, waypoint_index)
|
||||
|
||||
def estimate_position(self) -> Point:
|
||||
return self.current_waypoint.position
|
||||
|
||||
def estimate_altitude(self) -> tuple[Distance, str]:
|
||||
return self.current_waypoint.alt, self.current_waypoint.alt_type
|
||||
|
||||
def estimate_speed(self) -> Speed:
|
||||
return self.flight.unit_type.preferred_patrol_speed(self.estimate_altitude()[0])
|
||||
|
||||
def estimate_fuel(self) -> float:
|
||||
# TODO: Estimate loiter consumption per minute?
|
||||
return self.estimate_fuel_at_current_waypoint()
|
||||
|
||||
def next_waypoint_state(self) -> FlightState:
|
||||
# Do not automatically advance to the next waypoint. Just proceed from the
|
||||
# current one with the normal flying state.
|
||||
return Navigating(self.flight, self.settings, self.waypoint_index)
|
||||
|
||||
def travel_time_between_waypoints(self) -> timedelta:
|
||||
return self.hold_duration
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return f"Loitering for {self.hold_duration - self.elapsed_time}"
|
||||
@@ -29,11 +29,6 @@ class Navigating(InFlight):
|
||||
events.update_flight_position(self.flight, self.estimate_position())
|
||||
|
||||
def progress(self) -> float:
|
||||
# if next waypoint is very close, assume we reach it immediately to avoid divide
|
||||
# by zero error
|
||||
if self.total_time_to_next_waypoint.total_seconds() < 1:
|
||||
return 1.0
|
||||
|
||||
return (
|
||||
self.elapsed_time.total_seconds()
|
||||
/ self.total_time_to_next_waypoint.total_seconds()
|
||||
@@ -85,14 +80,3 @@ class Navigating(InFlight):
|
||||
@property
|
||||
def spawn_type(self) -> StartType:
|
||||
return StartType.IN_FLIGHT
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
if (action := self.current_action) is not None:
|
||||
return action.describe()
|
||||
|
||||
if self.has_aborted:
|
||||
abort = "(Aborted) "
|
||||
else:
|
||||
abort = ""
|
||||
return f"{abort}Flying to {self.next_waypoint.name}"
|
||||
|
||||
@@ -7,8 +7,6 @@ from typing import Literal, TYPE_CHECKING
|
||||
from dcs import Point
|
||||
|
||||
from game.ato.flightwaypointtype import FlightWaypointType
|
||||
from game.flightplan.waypointactions.waypointaction import WaypointAction
|
||||
from game.flightplan.waypointoptions.waypointoption import WaypointOption
|
||||
from game.theater.theatergroup import TheaterUnit
|
||||
from game.utils import Distance, meters
|
||||
|
||||
@@ -41,11 +39,6 @@ class FlightWaypoint:
|
||||
# The minimum amount of fuel remaining at this waypoint in pounds.
|
||||
min_fuel: float | None = None
|
||||
|
||||
wants_escort: bool = False
|
||||
|
||||
actions: list[WaypointAction] = field(default_factory=list)
|
||||
options: dict[str, WaypointOption] = field(default_factory=dict)
|
||||
|
||||
# These are set very late by the air conflict generator (part of mission
|
||||
# generation). We do it late so that we don't need to propagate changes
|
||||
# to waypoint times whenever the player alters the package TOT or the
|
||||
@@ -53,12 +46,6 @@ class FlightWaypoint:
|
||||
tot: datetime | None = None
|
||||
departure_time: datetime | None = None
|
||||
|
||||
def add_action(self, action: WaypointAction) -> None:
|
||||
self.actions.append(action)
|
||||
|
||||
def set_option(self, option: WaypointOption) -> None:
|
||||
self.options[option.id()] = option
|
||||
|
||||
@property
|
||||
def x(self) -> float:
|
||||
return self.position.x
|
||||
|
||||
@@ -25,7 +25,7 @@ class FlightWaypointType(IntEnum):
|
||||
INGRESS_STRIKE = 5 # Ingress strike (For generator, means that this should have bombing on next TARGET_POINT points)
|
||||
INGRESS_SEAD = 6 # Ingress sead (For generator, means that this should attack groups on TARGET_GROUP_LOC points)
|
||||
INGRESS_CAS = 7 # Ingress cas (should start CAS task)
|
||||
CAS = 8 # Unused.
|
||||
CAS = 8 # Should do CAS there
|
||||
EGRESS = 9 # Should stop attack
|
||||
DESCENT_POINT = 10 # Should start descending to pattern alt
|
||||
LANDING_POINT = 11 # Should land there
|
||||
@@ -50,4 +50,3 @@ class FlightWaypointType(IntEnum):
|
||||
CARGO_STOP = 30 # Stopover landing point using the LandingReFuAr waypoint type
|
||||
INGRESS_AIR_ASSAULT = 31
|
||||
RECOVERY_TANKER = 32
|
||||
INGRESS_ANTI_SHIP = 33
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, TYPE_CHECKING, Iterator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.squadrons import Pilot
|
||||
|
||||
|
||||
class IFlightRoster(ABC):
|
||||
@abstractmethod
|
||||
def iter_pilots(self) -> Iterator[Pilot | None]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def pilot_at(self, idx: int) -> Pilot | None:
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def max_size(self) -> int:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def resize(self, new_size: int) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def set_pilot(self, index: int, pilot: Optional[Pilot]) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
...
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Iterator, Mapping, Optional, TYPE_CHECKING, Type, Any
|
||||
from typing import Iterator, Mapping, Optional, TYPE_CHECKING, Type
|
||||
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
@@ -36,11 +35,6 @@ class Loadout:
|
||||
def derive_custom(self, name: str) -> Loadout:
|
||||
return Loadout(name, self.pylons, self.date, is_custom=True)
|
||||
|
||||
def clone(self) -> Loadout:
|
||||
return Loadout(
|
||||
self.name, dict(self.pylons), copy.deepcopy(self.date), self.is_custom
|
||||
)
|
||||
|
||||
def has_weapon_of_type(self, weapon_type: WeaponType) -> bool:
|
||||
for weapon in self.pylons.values():
|
||||
if weapon is not None and weapon.weapon_group.type is weapon_type:
|
||||
@@ -114,24 +108,6 @@ class Loadout:
|
||||
new_pylons[pylon_number] = fallback
|
||||
self.pylons = new_pylons
|
||||
|
||||
@classmethod
|
||||
def convert_dcs_loadout_to_pylon_map(
|
||||
cls, pylons: dict[int, dict[str, Any]]
|
||||
) -> dict[int, Weapon | None]:
|
||||
return {
|
||||
p["num"]: Weapon.with_clsid(p["CLSID"])
|
||||
for p in pylons.values()
|
||||
# When unloading incompatible pylons (for example, some of the
|
||||
# Mosquito's pylons cannot be loaded when other pylons are carrying
|
||||
# rockets), DCS sometimes equips the empty string rather than
|
||||
# unsetting the pylon. An unset pylon and the empty string appear to
|
||||
# have identical behavior, and it's annoying to deal with weapons
|
||||
# that pydcs doesn't know about, so just clear those pylons rather
|
||||
# than explicitly handling "".
|
||||
# https://github.com/dcs-liberation/dcs_liberation/issues/3171
|
||||
if p["CLSID"] != ""
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
|
||||
return cls.iter_for_aircraft(flight.unit_type)
|
||||
@@ -149,15 +125,14 @@ class Loadout:
|
||||
payloads = aircraft.dcs_unit_type.load_payloads()
|
||||
for payload in payloads.values():
|
||||
name = payload["name"]
|
||||
pylons = payload["pylons"]
|
||||
try:
|
||||
pylon_assignments = cls.convert_dcs_loadout_to_pylon_map(
|
||||
payload["pylons"]
|
||||
)
|
||||
pylon_assignments = {
|
||||
p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()
|
||||
}
|
||||
except KeyError:
|
||||
logging.exception(
|
||||
"Ignoring %s loadout with invalid weapons: %s",
|
||||
aircraft.variant_id,
|
||||
name,
|
||||
"Ignoring %s loadout with invalid weapons: %s", aircraft.name, name
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -229,25 +204,7 @@ class Loadout:
|
||||
payload = dcs_unit_type.loadout_by_name(name)
|
||||
if payload is not None:
|
||||
try:
|
||||
# Pydcs returns the data in a different format for loadout_by_name()
|
||||
# than it does for load_payloads(), for some reason. Convert this
|
||||
# result to match the other so that we can reuse
|
||||
# convert_dcs_loadout_to_pylon_map.
|
||||
#
|
||||
# loadout_by_name() returns a list of pairs, with the first item
|
||||
# being the pylon index and the second being a dict with a single
|
||||
# clsid key.
|
||||
#
|
||||
# Each element of load_payloads() pylons is a dict of dicts with
|
||||
# both the CLSID key (yes, different case from the other API!) and a
|
||||
# num key for the pylon index. The outer dict is a mapping for a lua
|
||||
# table, so its keys are just indexes.
|
||||
pylons = cls.convert_dcs_loadout_to_pylon_map(
|
||||
{
|
||||
i: {"num": n, "CLSID": p["clsid"]}
|
||||
for i, (n, p) in enumerate(payload)
|
||||
}
|
||||
)
|
||||
pylons = {i: Weapon.with_clsid(d["clsid"]) for i, d in payload}
|
||||
except KeyError:
|
||||
logging.exception(
|
||||
"Ignoring %s loadout with invalid weapons: %s",
|
||||
|
||||
@@ -6,11 +6,8 @@ from typing import TYPE_CHECKING
|
||||
from dcs import Point
|
||||
|
||||
from game.ato.flightplans.waypointbuilder import WaypointBuilder
|
||||
from game.flightplan import JoinZoneGeometry
|
||||
from game.flightplan.ipsolver import IpSolver
|
||||
from game.flightplan import IpZoneGeometry, JoinZoneGeometry
|
||||
from game.flightplan.refuelzonegeometry import RefuelZoneGeometry
|
||||
from game.persistence.paths import waypoint_debug_directory
|
||||
from game.utils import dcs_to_shapely_point
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato import Package
|
||||
@@ -25,28 +22,15 @@ class PackageWaypoints:
|
||||
refuel: Point
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
package: Package, coalition: Coalition, dump_debug_info: bool
|
||||
) -> PackageWaypoints:
|
||||
def create(package: Package, coalition: Coalition) -> PackageWaypoints:
|
||||
origin = package.departure_closest_to_target()
|
||||
|
||||
# Start by picking the best IP for the attack.
|
||||
ip_solver = IpSolver(
|
||||
dcs_to_shapely_point(origin.position),
|
||||
dcs_to_shapely_point(package.target.position),
|
||||
coalition.doctrine,
|
||||
coalition.opponent.threat_zone.all,
|
||||
)
|
||||
ip_solver.set_debug_properties(
|
||||
waypoint_debug_directory() / "IP", coalition.game.theater.terrain
|
||||
)
|
||||
ingress_point_shapely = ip_solver.solve()
|
||||
if dump_debug_info:
|
||||
ip_solver.dump_debug_info()
|
||||
|
||||
ingress_point = origin.position.new_in_same_map(
|
||||
ingress_point_shapely.x, ingress_point_shapely.y
|
||||
)
|
||||
ingress_point = IpZoneGeometry(
|
||||
package.target.position,
|
||||
origin.position,
|
||||
coalition,
|
||||
).find_best_ip()
|
||||
|
||||
join_point = JoinZoneGeometry(
|
||||
package.target.position,
|
||||
|
||||
14
game/ato/task.py
Normal file
14
game/ato/task.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from game.ato import FlightType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
"""The main task of a flight or package."""
|
||||
|
||||
#: The type of task.
|
||||
task_type: FlightType
|
||||
|
||||
#: The location of the objective.
|
||||
location: str
|
||||
@@ -1,9 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from game.utils import Distance, SPEED_OF_SOUND_AT_SEA_LEVEL, Speed, mach
|
||||
from dcs.mapping import Point
|
||||
|
||||
from game.utils import (
|
||||
Distance,
|
||||
SPEED_OF_SOUND_AT_SEA_LEVEL,
|
||||
Speed,
|
||||
mach,
|
||||
meters,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flight import Flight
|
||||
@@ -34,6 +42,14 @@ class GroundSpeed:
|
||||
return mach(cruise_mach, altitude)
|
||||
|
||||
|
||||
class TravelTime:
|
||||
@staticmethod
|
||||
def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
|
||||
error_factor = 1.05
|
||||
distance = meters(a.distance_to_point(b))
|
||||
return timedelta(hours=distance.nautical_miles / speed.knots * error_factor)
|
||||
|
||||
|
||||
# TODO: Most if not all of this should move into FlightPlan.
|
||||
class TotEstimator:
|
||||
def __init__(self, package: Package) -> None:
|
||||
|
||||
@@ -17,7 +17,6 @@ from game.theater.iadsnetwork.iadsnetwork import IadsNetwork
|
||||
from game.theater.theaterloader import TheaterLoader
|
||||
from game.version import CAMPAIGN_FORMAT_VERSION
|
||||
from .campaignairwingconfig import CampaignAirWingConfig
|
||||
from .controlpointconfig import ControlPointConfig
|
||||
from .factionrecommendation import FactionRecommendation
|
||||
from .mizcampaignloader import MizCampaignLoader
|
||||
|
||||
@@ -124,15 +123,7 @@ class Campaign:
|
||||
) from ex
|
||||
|
||||
with logged_duration("Importing miz data"):
|
||||
MizCampaignLoader(
|
||||
self.path.parent / miz,
|
||||
t,
|
||||
dict(
|
||||
ControlPointConfig.iter_from_data(
|
||||
self.data.get("control_points", {})
|
||||
)
|
||||
),
|
||||
).populate_theater()
|
||||
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
|
||||
|
||||
# TODO: Move into MizCampaignLoader so this doesn't have unknown initialization
|
||||
# in ConflictTheater.
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
from dcs import Point
|
||||
from dcs.terrain import Airport
|
||||
|
||||
from game.campaignloader.controlpointconfig import ControlPointConfig
|
||||
from game.theater import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ConflictTheater,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
OffMapSpawn,
|
||||
)
|
||||
|
||||
|
||||
class ControlPointBuilder:
|
||||
def __init__(
|
||||
self, theater: ConflictTheater, configs: dict[str | int, ControlPointConfig]
|
||||
) -> None:
|
||||
self.theater = theater
|
||||
self.config = configs
|
||||
|
||||
def create_airfield(self, airport: Airport) -> Airfield:
|
||||
cp = Airfield(airport, self.theater, starts_blue=airport.is_blue())
|
||||
|
||||
# Use the unlimited aircraft option to determine if an airfield should
|
||||
# be owned by the player when the campaign is "inverted".
|
||||
cp.captured_invert = airport.unlimited_aircrafts
|
||||
|
||||
self._apply_config(airport.id, cp)
|
||||
return cp
|
||||
|
||||
def create_fob(
|
||||
self,
|
||||
name: str,
|
||||
position: Point,
|
||||
theater: ConflictTheater,
|
||||
starts_blue: bool,
|
||||
captured_invert: bool,
|
||||
) -> Fob:
|
||||
cp = Fob(name, position, theater, starts_blue)
|
||||
cp.captured_invert = captured_invert
|
||||
self._apply_config(name, cp)
|
||||
return cp
|
||||
|
||||
def create_carrier(
|
||||
self,
|
||||
name: str,
|
||||
position: Point,
|
||||
theater: ConflictTheater,
|
||||
starts_blue: bool,
|
||||
captured_invert: bool,
|
||||
) -> Carrier:
|
||||
cp = Carrier(name, position, theater, starts_blue)
|
||||
cp.captured_invert = captured_invert
|
||||
self._apply_config(name, cp)
|
||||
return cp
|
||||
|
||||
def create_lha(
|
||||
self,
|
||||
name: str,
|
||||
position: Point,
|
||||
theater: ConflictTheater,
|
||||
starts_blue: bool,
|
||||
captured_invert: bool,
|
||||
) -> Lha:
|
||||
cp = Lha(name, position, theater, starts_blue)
|
||||
cp.captured_invert = captured_invert
|
||||
self._apply_config(name, cp)
|
||||
return cp
|
||||
|
||||
def create_off_map(
|
||||
self,
|
||||
name: str,
|
||||
position: Point,
|
||||
theater: ConflictTheater,
|
||||
starts_blue: bool,
|
||||
captured_invert: bool,
|
||||
) -> OffMapSpawn:
|
||||
cp = OffMapSpawn(name, position, theater, starts_blue)
|
||||
cp.captured_invert = captured_invert
|
||||
self._apply_config(name, cp)
|
||||
return cp
|
||||
|
||||
def _apply_config(self, cp_id: str | int, control_point: ControlPoint) -> None:
|
||||
config = self.config.get(cp_id)
|
||||
if config is None:
|
||||
return
|
||||
|
||||
control_point.ferry_only = config.ferry_only
|
||||
@@ -1,21 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlPointConfig:
|
||||
ferry_only: bool
|
||||
|
||||
@staticmethod
|
||||
def from_data(data: dict[str, Any]) -> ControlPointConfig:
|
||||
return ControlPointConfig(ferry_only=data.get("ferry_only", False))
|
||||
|
||||
@staticmethod
|
||||
def iter_from_data(
|
||||
data: dict[str | int, Any]
|
||||
) -> Iterator[tuple[str | int, ControlPointConfig]]:
|
||||
for name_or_id, cp_data in data.items():
|
||||
yield name_or_id, ControlPointConfig.from_data(cp_data)
|
||||
@@ -12,14 +12,20 @@ from dcs.country import Country
|
||||
from dcs.planes import F_15C
|
||||
from dcs.ships import HandyWind, LHA_Tarawa, Stennis, USS_Arleigh_Burke_IIa
|
||||
from dcs.statics import Fortification, Warehouse
|
||||
from dcs.terrain import Airport
|
||||
from dcs.unitgroup import PlaneGroup, ShipGroup, StaticGroup, VehicleGroup
|
||||
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
|
||||
|
||||
from game.campaignloader.controlpointbuilder import ControlPointBuilder
|
||||
from game.campaignloader.controlpointconfig import ControlPointConfig
|
||||
from game.profiling import logged_duration
|
||||
from game.scenery_group import SceneryGroup
|
||||
from game.theater.controlpoint import ControlPoint
|
||||
from game.theater.controlpoint import (
|
||||
Airfield,
|
||||
Carrier,
|
||||
ControlPoint,
|
||||
Fob,
|
||||
Lha,
|
||||
OffMapSpawn,
|
||||
)
|
||||
from game.theater.presetlocation import PresetLocation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -86,14 +92,8 @@ class MizCampaignLoader:
|
||||
|
||||
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
miz: Path,
|
||||
theater: ConflictTheater,
|
||||
control_point_configs: dict[str | int, ControlPointConfig],
|
||||
) -> None:
|
||||
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
|
||||
self.theater = theater
|
||||
self.control_point_builder = ControlPointBuilder(theater, control_point_configs)
|
||||
self.mission = Mission()
|
||||
with logged_duration("Loading miz"):
|
||||
self.mission.load_file(str(miz))
|
||||
@@ -105,6 +105,15 @@ class MizCampaignLoader:
|
||||
if self.mission.country(self.RED_COUNTRY.name) is None:
|
||||
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
|
||||
|
||||
def control_point_from_airport(self, airport: Airport) -> ControlPoint:
|
||||
cp = Airfield(airport, self.theater, starts_blue=airport.is_blue())
|
||||
|
||||
# Use the unlimited aircraft option to determine if an airfield should
|
||||
# be owned by the player when the campaign is "inverted".
|
||||
cp.captured_invert = airport.unlimited_aircrafts
|
||||
|
||||
return cp
|
||||
|
||||
def country(self, blue: bool) -> Country:
|
||||
country = self.mission.country(
|
||||
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
|
||||
@@ -231,49 +240,36 @@ class MizCampaignLoader:
|
||||
|
||||
@cached_property
|
||||
def control_points(self) -> dict[UUID, ControlPoint]:
|
||||
control_points: dict[UUID, ControlPoint] = {}
|
||||
control_point: ControlPoint
|
||||
control_points = {}
|
||||
for airport in self.mission.terrain.airport_list():
|
||||
if airport.is_blue() or airport.is_red():
|
||||
control_point = self.control_point_builder.create_airfield(airport)
|
||||
control_point = self.control_point_from_airport(airport)
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
for blue in (False, True):
|
||||
for group in self.off_map_spawns(blue):
|
||||
control_point = self.control_point_builder.create_off_map(
|
||||
str(group.name),
|
||||
group.position,
|
||||
self.theater,
|
||||
starts_blue=blue,
|
||||
captured_invert=group.late_activation,
|
||||
control_point = OffMapSpawn(
|
||||
str(group.name), group.position, self.theater, starts_blue=blue
|
||||
)
|
||||
control_point.captured_invert = group.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.carriers(blue):
|
||||
control_point = self.control_point_builder.create_carrier(
|
||||
ship.name,
|
||||
ship.position,
|
||||
self.theater,
|
||||
starts_blue=blue,
|
||||
captured_invert=ship.late_activation,
|
||||
control_point = Carrier(
|
||||
ship.name, ship.position, self.theater, starts_blue=blue
|
||||
)
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for ship in self.lhas(blue):
|
||||
control_point = self.control_point_builder.create_lha(
|
||||
ship.name,
|
||||
ship.position,
|
||||
self.theater,
|
||||
starts_blue=blue,
|
||||
captured_invert=ship.late_activation,
|
||||
control_point = Lha(
|
||||
ship.name, ship.position, self.theater, starts_blue=blue
|
||||
)
|
||||
control_point.captured_invert = ship.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
for fob in self.fobs(blue):
|
||||
control_point = self.control_point_builder.create_fob(
|
||||
str(fob.name),
|
||||
fob.position,
|
||||
self.theater,
|
||||
starts_blue=blue,
|
||||
captured_invert=fob.late_activation,
|
||||
control_point = Fob(
|
||||
str(fob.name), fob.position, self.theater, starts_blue=blue
|
||||
)
|
||||
control_point.captured_invert = fob.late_activation
|
||||
control_points[control_point.id] = control_point
|
||||
|
||||
return control_points
|
||||
|
||||
@@ -26,7 +26,6 @@ if TYPE_CHECKING:
|
||||
from .data.doctrine import Doctrine
|
||||
from .factions.faction import Faction
|
||||
from .game import Game
|
||||
from .lasercodes import LaserCodeRegistry
|
||||
from .sim import GameUpdateEvents
|
||||
|
||||
|
||||
@@ -91,10 +90,6 @@ class Coalition:
|
||||
assert self._navmesh is not None
|
||||
return self._navmesh
|
||||
|
||||
@property
|
||||
def laser_code_registry(self) -> LaserCodeRegistry:
|
||||
return self.game.laser_code_registry
|
||||
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state = self.__dict__.copy()
|
||||
# Avoid persisting any volatile types that can be deterministically
|
||||
@@ -163,12 +158,12 @@ class Coalition:
|
||||
# is handled correctly.
|
||||
self.transfers.perform_transfers()
|
||||
|
||||
def preinit_turn_0(self) -> None:
|
||||
def preinit_turn_0(self, squadrons_start_full: bool) -> None:
|
||||
"""Runs final Coalition initialization.
|
||||
|
||||
Final initialization occurs before Game.initialize_turn runs for turn 0.
|
||||
"""
|
||||
self.air_wing.populate_for_turn_0()
|
||||
self.air_wing.populate_for_turn_0(squadrons_start_full)
|
||||
|
||||
def initialize_turn(self, is_turn_0: bool) -> None:
|
||||
"""Processes coalition-specific turn initialization.
|
||||
@@ -189,7 +184,7 @@ class Coalition:
|
||||
with logged_duration("Transport planning"):
|
||||
self.transfers.plan_transports(self.game.conditions.start_time)
|
||||
|
||||
if not is_turn_0:
|
||||
if not is_turn_0 or not self.game.settings.enable_squadron_aircraft_limits:
|
||||
self.plan_missions(self.game.conditions.start_time)
|
||||
self.plan_procurement()
|
||||
|
||||
|
||||
@@ -10,10 +10,9 @@ from ..ato.starttype import StartType
|
||||
from ..db.database import Database
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato.closestairfields import ClosestAirfields
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
from game.lasercodes import LaserCodeRegistry
|
||||
from game.squadrons.airwing import AirWing
|
||||
from game.ato.closestairfields import ClosestAirfields
|
||||
from .missionproposals import ProposedFlight
|
||||
|
||||
|
||||
@@ -25,7 +24,6 @@ class PackageBuilder:
|
||||
location: MissionTarget,
|
||||
closest_airfields: ClosestAirfields,
|
||||
air_wing: AirWing,
|
||||
laser_code_registry: LaserCodeRegistry,
|
||||
flight_db: Database[Flight],
|
||||
is_player: bool,
|
||||
package_country: str,
|
||||
@@ -37,7 +35,6 @@ class PackageBuilder:
|
||||
self.package_country = package_country
|
||||
self.package = Package(location, flight_db, auto_asap=asap)
|
||||
self.air_wing = air_wing
|
||||
self.laser_code_registry = laser_code_registry
|
||||
self.start_type = start_type
|
||||
|
||||
def plan_flight(self, plan: ProposedFlight) -> bool:
|
||||
@@ -66,11 +63,6 @@ class PackageBuilder:
|
||||
start_type,
|
||||
divert=self.find_divert_field(squadron.aircraft, squadron.location),
|
||||
)
|
||||
for member in flight.iter_members():
|
||||
if member.is_player:
|
||||
member.assign_tgp_laser_code(
|
||||
self.laser_code_registry.alloc_laser_code()
|
||||
)
|
||||
self.package.add_flight(flight)
|
||||
return True
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ class PackageFulfiller:
|
||||
mission.location,
|
||||
ObjectiveDistanceCache.get_closest_airfields(mission.location),
|
||||
self.air_wing,
|
||||
self.coalition.laser_code_registry,
|
||||
self.flight_db,
|
||||
self.is_player,
|
||||
self.coalition.country_name,
|
||||
|
||||
@@ -18,8 +18,6 @@ class GroundUnitProcurementRatios:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Doctrine:
|
||||
name: str
|
||||
|
||||
cas: bool
|
||||
cap: bool
|
||||
sead: bool
|
||||
@@ -81,7 +79,6 @@ class Doctrine:
|
||||
|
||||
|
||||
MODERN_DOCTRINE = Doctrine(
|
||||
"modern",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=True,
|
||||
@@ -119,7 +116,6 @@ MODERN_DOCTRINE = Doctrine(
|
||||
)
|
||||
|
||||
COLDWAR_DOCTRINE = Doctrine(
|
||||
name="coldwar",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=True,
|
||||
@@ -157,7 +153,6 @@ COLDWAR_DOCTRINE = Doctrine(
|
||||
)
|
||||
|
||||
WWII_DOCTRINE = Doctrine(
|
||||
name="ww2",
|
||||
cap=True,
|
||||
cas=True,
|
||||
sead=False,
|
||||
@@ -192,9 +187,3 @@ WWII_DOCTRINE = Doctrine(
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ALL_DOCTRINES = [
|
||||
COLDWAR_DOCTRINE,
|
||||
MODERN_DOCTRINE,
|
||||
WWII_DOCTRINE,
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Iterator, Optional, Any, ClassVar
|
||||
|
||||
import yaml
|
||||
from dcs.flyingunit import FlyingUnit
|
||||
from dcs.unitgroup import FlyingGroup
|
||||
from dcs.weapons_data import weapon_ids
|
||||
|
||||
from game.dcs.aircrafttype import AircraftType
|
||||
@@ -235,10 +235,10 @@ class Pylon:
|
||||
# configuration.
|
||||
return weapon in self.allowed or weapon.clsid == "<CLEAN>"
|
||||
|
||||
def equip(self, unit: FlyingUnit, weapon: Weapon) -> None:
|
||||
def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None:
|
||||
if not self.can_equip(weapon):
|
||||
logging.error(f"Pylon {self.number} cannot equip {weapon.name}")
|
||||
unit.load_pylon(self.make_pydcs_assignment(weapon), self.number)
|
||||
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
|
||||
|
||||
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
|
||||
return self.number, weapon.pydcs_data
|
||||
|
||||
@@ -7,13 +7,13 @@ from functools import cache, cached_property
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Dict, Iterator, Optional, TYPE_CHECKING, Type
|
||||
|
||||
import yaml
|
||||
from dcs.helicopters import helicopter_map
|
||||
from dcs.planes import plane_map
|
||||
from dcs.unitpropertydescription import UnitPropertyDescription
|
||||
from dcs.unittype import FlyingType
|
||||
|
||||
from game.data.units import UnitClass
|
||||
from game.dcs.lasercodeconfig import LaserCodeConfig
|
||||
from game.dcs.unitproperty import UnitProperty
|
||||
from game.dcs.unittype import UnitType
|
||||
from game.radio.channels import (
|
||||
ApacheChannelNamer,
|
||||
@@ -205,10 +205,6 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
# when no TGP is mounted on any station.
|
||||
has_built_in_target_pod: bool
|
||||
|
||||
laser_code_configs: list[LaserCodeConfig]
|
||||
|
||||
use_f15e_waypoint_names: bool
|
||||
|
||||
_by_name: ClassVar[dict[str, AircraftType]] = {}
|
||||
_by_unit_type: ClassVar[dict[type[FlyingType], list[AircraftType]]] = defaultdict(
|
||||
list
|
||||
@@ -216,7 +212,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: AircraftType) -> None:
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@property
|
||||
@@ -290,9 +286,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
else:
|
||||
# Slow like warbirds or helicopters
|
||||
# Use whichever is slowest - mach 0.35 or 70% of max speed
|
||||
logging.debug(
|
||||
f"{self.display_name} max_speed * 0.7 is {max_speed * 0.7}"
|
||||
)
|
||||
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
|
||||
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
|
||||
|
||||
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
|
||||
@@ -328,18 +322,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
def channel_name(self, radio_id: int, channel_id: int) -> str:
|
||||
return self.channel_namer.channel_name(radio_id, channel_id)
|
||||
|
||||
@cached_property
|
||||
def laser_code_prop_ids(self) -> set[str]:
|
||||
laser_code_props: set[str] = set()
|
||||
for laser_code_config in self.laser_code_configs:
|
||||
laser_code_props.update(laser_code_config.iter_prop_ids())
|
||||
return laser_code_props
|
||||
|
||||
def iter_props(self) -> Iterator[UnitPropertyDescription]:
|
||||
yield from self.dcs_unit_type.properties.values()
|
||||
|
||||
def should_show_prop(self, prop_id: str) -> bool:
|
||||
return prop_id not in self.laser_code_prop_ids
|
||||
def iter_props(self) -> Iterator[UnitProperty[Any]]:
|
||||
return UnitProperty.for_aircraft(self.dcs_unit_type)
|
||||
|
||||
def capable_of(self, task: FlightType) -> bool:
|
||||
return task in self.task_priorities
|
||||
@@ -349,7 +333,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Update any existing models with new data on load.
|
||||
updated = AircraftType.named(state["variant_id"])
|
||||
updated = AircraftType.named(state["name"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@@ -390,11 +374,11 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
@staticmethod
|
||||
def _set_props_overrides(
|
||||
config: Dict[str, Any], aircraft: Type[FlyingType]
|
||||
config: Dict[str, Any], aircraft: Type[FlyingType], data_path: Path
|
||||
) -> None:
|
||||
if aircraft.property_defaults is None:
|
||||
logging.warning(
|
||||
f"'{aircraft.id}' attempted to set default prop that does not exist."
|
||||
f"'{data_path.name}' attempted to set default prop that does not exist."
|
||||
)
|
||||
else:
|
||||
for k in config:
|
||||
@@ -402,23 +386,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
aircraft.property_defaults[k] = config[k]
|
||||
else:
|
||||
logging.warning(
|
||||
f"'{aircraft.id}' attempted to set default prop '{k}' that does not exist"
|
||||
f"'{data_path.name}' attempted to set default prop '{k}' that does not exist"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/aircraft")
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, aircraft: Type[FlyingType], variant_id: str, data: dict[str, Any]
|
||||
) -> AircraftType:
|
||||
def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
|
||||
from game.ato.flighttype import FlightType
|
||||
|
||||
data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {aircraft.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
try:
|
||||
price = data["price"]
|
||||
except KeyError as ex:
|
||||
raise KeyError(f"Missing required price field") from ex
|
||||
raise KeyError(f"Missing required price field: {data_path}") from ex
|
||||
|
||||
radio_config = RadioConfig.from_data(data.get("radios", {}))
|
||||
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
|
||||
@@ -461,51 +447,46 @@ class AircraftType(UnitType[Type[FlyingType]]):
|
||||
|
||||
prop_overrides = data.get("default_overrides")
|
||||
if prop_overrides is not None:
|
||||
cls._set_props_overrides(prop_overrides, aircraft)
|
||||
cls._set_props_overrides(prop_overrides, aircraft, data_path)
|
||||
|
||||
task_priorities: dict[FlightType, int] = {}
|
||||
for task_name, priority in data.get("tasks", {}).items():
|
||||
task_priorities[FlightType(task_name)] = priority
|
||||
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
variant_id=variant_id,
|
||||
display_name=display_name,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=price,
|
||||
carrier_capable=data.get("carrier_capable", False),
|
||||
lha_capable=data.get("lha_capable", False),
|
||||
always_keeps_gun=data.get("always_keeps_gun", False),
|
||||
gunfighter=data.get("gunfighter", False),
|
||||
max_group_size=data.get("max_group_size", aircraft.group_size_max),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
max_mission_range=mission_range,
|
||||
fuel_consumption=fuel_consumption,
|
||||
default_livery=data.get("default_livery"),
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
kneeboard_units=units,
|
||||
utc_kneeboard=data.get("utc_kneeboard", False),
|
||||
unit_class=unit_class,
|
||||
cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
|
||||
can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
|
||||
task_priorities=task_priorities,
|
||||
has_built_in_target_pod=data.get("has_built_in_target_pod", False),
|
||||
laser_code_configs=[
|
||||
LaserCodeConfig.from_yaml(d) for d in data.get("laser_codes", [])
|
||||
],
|
||||
use_f15e_waypoint_names=data.get("use_f15e_waypoint_names", False),
|
||||
)
|
||||
for variant in data.get("variants", [aircraft.id]):
|
||||
yield AircraftType(
|
||||
dcs_unit_type=aircraft,
|
||||
name=variant,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=price,
|
||||
carrier_capable=data.get("carrier_capable", False),
|
||||
lha_capable=data.get("lha_capable", False),
|
||||
always_keeps_gun=data.get("always_keeps_gun", False),
|
||||
gunfighter=data.get("gunfighter", False),
|
||||
max_group_size=data.get("max_group_size", aircraft.group_size_max),
|
||||
patrol_altitude=patrol_config.altitude,
|
||||
patrol_speed=patrol_config.speed,
|
||||
max_mission_range=mission_range,
|
||||
fuel_consumption=fuel_consumption,
|
||||
default_livery=data.get("default_livery"),
|
||||
intra_flight_radio=radio_config.intra_flight,
|
||||
channel_allocator=radio_config.channel_allocator,
|
||||
channel_namer=radio_config.channel_namer,
|
||||
kneeboard_units=units,
|
||||
utc_kneeboard=data.get("utc_kneeboard", False),
|
||||
unit_class=unit_class,
|
||||
cabin_size=data.get("cabin_size", 10 if aircraft.helicopter else 0),
|
||||
can_carry_crates=data.get("can_carry_crates", aircraft.helicopter),
|
||||
task_priorities=task_priorities,
|
||||
has_built_in_target_pod=data.get("has_built_in_target_pod", False),
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.variant_id)
|
||||
return hash(self.name)
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Iterator, Optional, Type
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import VehicleType
|
||||
from dcs.vehicles import vehicle_map
|
||||
|
||||
@@ -64,15 +65,9 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
dict[type[VehicleType], list[GroundUnitType]]
|
||||
] = defaultdict(list)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Update any existing models with new data on load.
|
||||
updated = GroundUnitType.named(state["variant_id"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: GroundUnitType) -> None:
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@classmethod
|
||||
@@ -92,13 +87,15 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
yield from vehicle_map.values()
|
||||
|
||||
@classmethod
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/ground_units")
|
||||
def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]:
|
||||
data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {vehicle.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, vehicle: Type[VehicleType], variant_id: str, data: dict[str, Any]
|
||||
) -> GroundUnitType:
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@@ -113,24 +110,23 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
|
||||
else:
|
||||
unit_class = UnitClass(class_name)
|
||||
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return GroundUnitType(
|
||||
dcs_unit_type=vehicle,
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
variant_id=variant_id,
|
||||
display_name=display_name,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price", 1),
|
||||
skynet_properties=SkynetProperties.from_data(
|
||||
data.get("skynet_properties", {})
|
||||
),
|
||||
reversed_heading=data.get("reversed_heading", False),
|
||||
)
|
||||
for variant in data.get("variants", [vehicle.id]):
|
||||
yield GroundUnitType(
|
||||
dcs_unit_type=vehicle,
|
||||
unit_class=unit_class,
|
||||
spawn_weight=data.get("spawn_weight", 0),
|
||||
name=variant,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price", 1),
|
||||
skynet_properties=SkynetProperties.from_data(
|
||||
data.get("skynet_properties", {})
|
||||
),
|
||||
reversed_heading=data.get("reversed_heading", False),
|
||||
)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LaserCodeConfig(ABC):
|
||||
@staticmethod
|
||||
def from_yaml(data: dict[str, Any]) -> LaserCodeConfig:
|
||||
if (property_def := data.get("property")) is not None:
|
||||
return SinglePropertyLaserCodeConfig(
|
||||
property_def["id"], int(property_def["digits"])
|
||||
)
|
||||
return MultiplePropertyLaserCodeConfig(
|
||||
[(d["id"], d["digit"]) for d in data["properties"]]
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
...
|
||||
|
||||
|
||||
class SinglePropertyLaserCodeConfig(LaserCodeConfig):
|
||||
def __init__(self, property_id: str, digits: int) -> None:
|
||||
self.property_id = property_id
|
||||
self.digits = digits
|
||||
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
yield self.property_id
|
||||
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
return {self.property_id: code % 10**self.digits}
|
||||
|
||||
|
||||
class MultiplePropertyLaserCodeConfig(LaserCodeConfig):
|
||||
def __init__(self, property_digit_mappings: list[tuple[str, int]]) -> None:
|
||||
self.property_digit_mappings = property_digit_mappings
|
||||
|
||||
def iter_prop_ids(self) -> Iterator[str]:
|
||||
yield from (i for i, p in self.property_digit_mappings)
|
||||
|
||||
def property_dict_for_code(self, code: int) -> dict[str, int]:
|
||||
d = {}
|
||||
for prop_id, idx in self.property_digit_mappings:
|
||||
d[prop_id] = code // 10**idx % 10
|
||||
return d
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Iterator, Type, Any
|
||||
from typing import ClassVar, Iterator, Type
|
||||
|
||||
import yaml
|
||||
from dcs.ships import ship_map
|
||||
from dcs.unittype import ShipType
|
||||
|
||||
@@ -19,15 +21,9 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
list
|
||||
)
|
||||
|
||||
def __setstate__(self, state: dict[str, Any]) -> None:
|
||||
# Update any existing models with new data on load.
|
||||
updated = ShipUnitType.named(state["variant_id"])
|
||||
state.update(updated.__dict__)
|
||||
self.__dict__.update(state)
|
||||
|
||||
@classmethod
|
||||
def register(cls, unit_type: ShipUnitType) -> None:
|
||||
cls._by_name[unit_type.variant_id] = unit_type
|
||||
cls._by_name[unit_type.name] = unit_type
|
||||
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
|
||||
|
||||
@classmethod
|
||||
@@ -47,13 +43,15 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
yield from ship_map.values()
|
||||
|
||||
@classmethod
|
||||
def _data_directory(cls) -> Path:
|
||||
return Path("resources/units/ships")
|
||||
def _each_variant_of(cls, ship: Type[ShipType]) -> Iterator[ShipUnitType]:
|
||||
data_path = Path("resources/units/ships") / f"{ship.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {ship.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, ship: Type[ShipType], variant_id: str, data: dict[str, Any]
|
||||
) -> ShipUnitType:
|
||||
try:
|
||||
introduction = data["introduced"]
|
||||
if introduction is None:
|
||||
@@ -64,19 +62,18 @@ class ShipUnitType(UnitType[Type[ShipType]]):
|
||||
class_name = data.get("class")
|
||||
unit_class = UnitClass(class_name)
|
||||
|
||||
display_name = data.get("display_name", variant_id)
|
||||
return ShipUnitType(
|
||||
dcs_unit_type=ship,
|
||||
unit_class=unit_class,
|
||||
variant_id=variant_id,
|
||||
display_name=data.get("display_name", variant_id),
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data["price"],
|
||||
)
|
||||
for variant in data.get("variants", [ship.id]):
|
||||
yield ShipUnitType(
|
||||
dcs_unit_type=ship,
|
||||
unit_class=unit_class,
|
||||
name=variant,
|
||||
description=data.get(
|
||||
"description",
|
||||
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
|
||||
),
|
||||
year_introduced=introduction,
|
||||
country_of_origin=data.get("origin", "No data."),
|
||||
manufacturer=data.get("manufacturer", "No data."),
|
||||
role=data.get("role", "No data."),
|
||||
price=data.get("price"),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar, Any
|
||||
from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar
|
||||
|
||||
import yaml
|
||||
from dcs.unittype import UnitType as DcsUnitType
|
||||
|
||||
from game.data.units import UnitClass
|
||||
@@ -18,8 +15,7 @@ DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
|
||||
@dataclass(frozen=True)
|
||||
class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
dcs_unit_type: DcsUnitTypeT
|
||||
variant_id: str
|
||||
display_name: str
|
||||
name: str
|
||||
description: str
|
||||
year_introduced: str
|
||||
country_of_origin: str
|
||||
@@ -31,7 +27,7 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
_loaded: ClassVar[bool] = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.display_name
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def dcs_id(self) -> str:
|
||||
@@ -53,29 +49,8 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
|
||||
def each_dcs_type() -> Iterator[DcsUnitTypeT]:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _data_directory(cls) -> Path:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _each_variant_of(cls, unit: DcsUnitTypeT) -> Iterator[Self]:
|
||||
data_path = cls._data_directory() / f"{unit.id}.yaml"
|
||||
if not data_path.exists():
|
||||
logging.warning(f"No data for {unit.id}; it will not be available")
|
||||
return
|
||||
|
||||
with data_path.open(encoding="utf-8") as data_file:
|
||||
data = yaml.safe_load(data_file)
|
||||
|
||||
for variant_id, variant_data in data.get("variants", {unit.id: {}}).items():
|
||||
if variant_data is None:
|
||||
variant_data = {}
|
||||
yield cls._variant_from_dict(unit, variant_id, data | variant_data)
|
||||
|
||||
@classmethod
|
||||
def _variant_from_dict(
|
||||
cls, dcs_unit_type: DcsUnitTypeT, variant_id: str, data: dict[str, Any]
|
||||
) -> Self:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -42,9 +42,6 @@ class Faction:
|
||||
#: choose the default locale.
|
||||
locales: Optional[List[str]]
|
||||
|
||||
# The unit type to spawn for cargo shipping.
|
||||
cargo_ship: ShipUnitType
|
||||
|
||||
# Country used by this faction
|
||||
country: str = field(default="")
|
||||
|
||||
@@ -159,7 +156,7 @@ class Faction:
|
||||
def air_defenses(self) -> list[str]:
|
||||
"""Returns the Air Defense types"""
|
||||
# This is used for the faction overview in NewGameWizard
|
||||
air_defenses = [a.display_name for a in self.air_defense_units]
|
||||
air_defenses = [a.name for a in self.air_defense_units]
|
||||
air_defenses.extend(
|
||||
[
|
||||
pg.name
|
||||
@@ -171,10 +168,7 @@ class Faction:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
|
||||
faction = Faction(
|
||||
locales=json.get("locales"),
|
||||
cargo_ship=ShipUnitType.named(json.get("cargo_ship", "Handy Wind")),
|
||||
)
|
||||
faction = Faction(locales=json.get("locales"))
|
||||
|
||||
faction.country = json.get("country", "/")
|
||||
if faction.country not in [c.name for c in country_dict.values()]:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .holdzonegeometry import HoldZoneGeometry
|
||||
from .ipzonegeometry import IpZoneGeometry
|
||||
from .joinzonegeometry import JoinZoneGeometry
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import MultiPolygon, Point
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
from game.data.doctrine import Doctrine
|
||||
from game.flightplan.waypointsolver import WaypointSolver
|
||||
from game.flightplan.waypointstrategy import WaypointStrategy
|
||||
from game.utils import meters, nautical_miles
|
||||
|
||||
MIN_DISTANCE_FROM_DEPARTURE = nautical_miles(5)
|
||||
|
||||
|
||||
class ThreatTolerantIpStrategy(WaypointStrategy):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__(threat_zones)
|
||||
self.prerequisite(target).min_distance_from(
|
||||
departure, doctrine.min_ingress_distance
|
||||
)
|
||||
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(departure)
|
||||
self.require().at_most(meters(departure.distance(target))).away_from(departure)
|
||||
self.require().at_least(doctrine.min_ingress_distance).away_from(target)
|
||||
max_ip_range = min(
|
||||
doctrine.max_ingress_distance, meters(departure.distance(target))
|
||||
)
|
||||
self.require().at_most(max_ip_range).away_from(target)
|
||||
self.threat_tolerance(target, max_ip_range, nautical_miles(5))
|
||||
self.nearest(departure)
|
||||
|
||||
|
||||
class UnsafeIpStrategy(WaypointStrategy):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__(threat_zones)
|
||||
self.prerequisite(target).min_distance_from(
|
||||
departure, doctrine.min_ingress_distance
|
||||
)
|
||||
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_most(meters(departure.distance(target))).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_least(doctrine.min_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
max_ip_range = min(
|
||||
doctrine.max_ingress_distance, meters(departure.distance(target))
|
||||
)
|
||||
self.require().at_most(max_ip_range).away_from(target, "target")
|
||||
self.nearest(departure)
|
||||
|
||||
|
||||
class SafeIpStrategy(WaypointStrategy):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__(threat_zones)
|
||||
self.prerequisite(departure).is_safe()
|
||||
self.prerequisite(target).min_distance_from(
|
||||
departure, doctrine.min_ingress_distance
|
||||
)
|
||||
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_most(meters(departure.distance(target))).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_least(doctrine.min_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
self.require().at_most(
|
||||
min(doctrine.max_ingress_distance, meters(departure.distance(target)))
|
||||
).away_from(target, "target")
|
||||
self.require().safe()
|
||||
self.nearest(departure)
|
||||
|
||||
|
||||
class SafeBackTrackingIpStrategy(WaypointStrategy):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__(threat_zones)
|
||||
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_least(doctrine.min_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
self.require().at_most(doctrine.max_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
self.require().safe()
|
||||
self.nearest(departure)
|
||||
|
||||
|
||||
class UnsafeBackTrackingIpStrategy(WaypointStrategy):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__(threat_zones)
|
||||
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
|
||||
departure, "departure"
|
||||
)
|
||||
self.require().at_least(doctrine.min_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
self.require().at_most(doctrine.max_ingress_distance).away_from(
|
||||
target, "target"
|
||||
)
|
||||
self.nearest(departure)
|
||||
|
||||
|
||||
class IpSolver(WaypointSolver):
|
||||
def __init__(
|
||||
self,
|
||||
departure: Point,
|
||||
target: Point,
|
||||
doctrine: Doctrine,
|
||||
threat_zones: MultiPolygon,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.departure = departure
|
||||
self.target = target
|
||||
self.doctrine = doctrine
|
||||
self.threat_zones = threat_zones
|
||||
|
||||
self.add_strategy(SafeIpStrategy(departure, target, doctrine, threat_zones))
|
||||
self.add_strategy(
|
||||
ThreatTolerantIpStrategy(departure, target, doctrine, threat_zones)
|
||||
)
|
||||
self.add_strategy(UnsafeIpStrategy(departure, target, doctrine, threat_zones))
|
||||
self.add_strategy(
|
||||
SafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
|
||||
)
|
||||
# TODO: The cases that require this are not covered by any tests.
|
||||
self.add_strategy(
|
||||
UnsafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
|
||||
)
|
||||
|
||||
def describe_metadata(self) -> dict[str, Any]:
|
||||
return {"doctrine": self.doctrine.name}
|
||||
|
||||
def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
|
||||
yield "departure", self.departure
|
||||
yield "target", self.target
|
||||
yield "threat_zones", self.threat_zones
|
||||
119
game/flightplan/ipzonegeometry.py
Normal file
119
game/flightplan/ipzonegeometry.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shapely.ops
|
||||
from dcs import Point
|
||||
from shapely.geometry import MultiPolygon, Point as ShapelyPoint
|
||||
|
||||
from game.utils import meters, nautical_miles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.coalition import Coalition
|
||||
|
||||
|
||||
class IpZoneGeometry:
|
||||
"""Defines the zones used for finding optimal IP placement.
|
||||
|
||||
The zones themselves are stored in the class rather than just the resulting IP so
|
||||
that the zones can be drawn in the map for debugging purposes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Point,
|
||||
home: Point,
|
||||
coalition: Coalition,
|
||||
) -> None:
|
||||
self._target = target
|
||||
self.threat_zone = coalition.opponent.threat_zone.all
|
||||
self.home = ShapelyPoint(home.x, home.y)
|
||||
|
||||
max_ip_distance = coalition.doctrine.max_ingress_distance
|
||||
min_ip_distance = coalition.doctrine.min_ingress_distance
|
||||
|
||||
# The minimum distance between the home location and the IP.
|
||||
min_distance_from_home = nautical_miles(5)
|
||||
|
||||
# The distance that is expected to be needed between the beginning of the attack
|
||||
# and weapon release. This buffers the threat zone to give a 5nm window between
|
||||
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
|
||||
# likely to end up with the attacker entering a threatened area.
|
||||
attack_distance_buffer = nautical_miles(5)
|
||||
|
||||
home_threatened = coalition.opponent.threat_zone.threatened(home)
|
||||
|
||||
shapely_target = ShapelyPoint(target.x, target.y)
|
||||
home_to_target_distance = meters(home.distance_to_point(target))
|
||||
|
||||
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
|
||||
self.home.buffer(min_distance_from_home.meters)
|
||||
)
|
||||
|
||||
# If the home zone is not threatened and home is within LAR, constrain the max
|
||||
# range to the home-to-target distance to prevent excessive backtracking.
|
||||
#
|
||||
# If the home zone *is* threatened, we need to back out of the zone to
|
||||
# rendezvous anyway.
|
||||
if not home_threatened and (
|
||||
min_ip_distance < home_to_target_distance < max_ip_distance
|
||||
):
|
||||
max_ip_distance = home_to_target_distance
|
||||
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
|
||||
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
|
||||
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
|
||||
|
||||
# The intersection of the home bubble and IP bubble will be all the points that
|
||||
# are within the valid IP range that are not farther from home than the target
|
||||
# is. However, if the origin airfield is threatened but there are safe
|
||||
# placements for the IP, we should not constrain to the home zone. In this case
|
||||
# we'll either end up with a safe zone outside the home zone and pick the
|
||||
# closest point in to to home (minimizing backtracking), or we'll have no safe
|
||||
# IP anywhere within range of the target, and we'll later pick the IP nearest
|
||||
# the edge of the threat zone.
|
||||
if home_threatened:
|
||||
self.permissible_zone = self.ip_bubble
|
||||
else:
|
||||
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
|
||||
|
||||
if self.permissible_zone.is_empty:
|
||||
# If home is closer to the target than the min range, there will not be an
|
||||
# IP solution that's close enough to home, in which case we need to ignore
|
||||
# the home bubble.
|
||||
self.permissible_zone = self.ip_bubble
|
||||
|
||||
safe_zones = self.permissible_zone.difference(
|
||||
self.threat_zone.buffer(attack_distance_buffer.meters)
|
||||
)
|
||||
|
||||
if not isinstance(safe_zones, MultiPolygon):
|
||||
safe_zones = MultiPolygon([safe_zones])
|
||||
self.safe_zones = safe_zones
|
||||
|
||||
def _unsafe_ip(self) -> ShapelyPoint:
|
||||
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
|
||||
if unthreatened_home_zone.is_empty:
|
||||
# Nowhere in our home zone is safe. The package will need to exit the
|
||||
# threatened area to hold and rendezvous. Pick the IP closest to the
|
||||
# edge of the threat zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, self.threat_zone.boundary
|
||||
)[0]
|
||||
|
||||
# No safe point in the IP zone, but the home zone is safe. Pick the max-
|
||||
# distance IP that's closest to the untreatened home zone.
|
||||
return shapely.ops.nearest_points(
|
||||
self.permissible_zone, unthreatened_home_zone
|
||||
)[0]
|
||||
|
||||
def _safe_ip(self) -> ShapelyPoint:
|
||||
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
|
||||
# the IP in the zone that's closest to the target.
|
||||
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
|
||||
|
||||
def find_best_ip(self) -> Point:
|
||||
if self.safe_zones.is_empty:
|
||||
ip = self._unsafe_ip()
|
||||
else:
|
||||
ip = self._safe_ip()
|
||||
return self._target.new_in_same_map(ip.x, ip.y)
|
||||
@@ -97,8 +97,6 @@ class JoinZoneGeometry:
|
||||
self.preferred_lines = preferred_lines
|
||||
|
||||
def find_best_join_point(self) -> Point:
|
||||
# TODO: afaict the permissible_lines case is entirely unnecessary. The two
|
||||
# definitions appear equivalent.
|
||||
if self.preferred_lines.is_empty:
|
||||
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
|
||||
else:
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import dcs.task
|
||||
from dcs.task import Task
|
||||
|
||||
from game.ato.flightstate.actionstate import ActionState
|
||||
from game.utils import Distance
|
||||
from .taskcontext import TaskContext
|
||||
from .waypointaction import WaypointAction
|
||||
|
||||
|
||||
class EngageTargets(WaypointAction):
|
||||
def __init__(
|
||||
self,
|
||||
max_distance_from_flight: Distance,
|
||||
target_types: list[type[dcs.task.TargetType]],
|
||||
) -> None:
|
||||
self._max_distance_from_flight = max_distance_from_flight
|
||||
self._target_types = target_types
|
||||
|
||||
def update_state(
|
||||
self, state: ActionState, time: datetime, duration: timedelta
|
||||
) -> timedelta:
|
||||
state.finish()
|
||||
return duration
|
||||
|
||||
def describe(self) -> str:
|
||||
return "Searching for targets"
|
||||
|
||||
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
|
||||
yield dcs.task.EngageTargets(
|
||||
max_distance=int(self._max_distance_from_flight.meters),
|
||||
targets=self._target_types,
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dcs.task import Task, OrbitAction, ControlledTask
|
||||
|
||||
from game.ato.flightstate.actionstate import ActionState
|
||||
from game.provider import Provider
|
||||
from game.utils import Distance, Speed
|
||||
from .taskcontext import TaskContext
|
||||
from .waypointaction import WaypointAction
|
||||
|
||||
|
||||
class Hold(WaypointAction):
|
||||
"""Loiter at a location until a push time to synchronize with other flights.
|
||||
|
||||
Taxi behavior is extremely unpredictable, so we cannot reliably predict ETAs for
|
||||
waypoints without first fixing a time for one waypoint by holding until a sync time.
|
||||
This is typically done with a dedicated hold point. If the flight reaches the hold
|
||||
point before their push time, they will loiter at that location rather than fly to
|
||||
their next waypoint as a speed that's often dangerously slow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, push_time_provider: Provider[datetime], altitude: Distance, speed: Speed
|
||||
) -> None:
|
||||
self._push_time_provider = push_time_provider
|
||||
self._altitude = altitude
|
||||
self._speed = speed
|
||||
|
||||
def describe(self) -> str:
|
||||
return self._push_time_provider().strftime("Holding until %H:%M:%S")
|
||||
|
||||
def update_state(
|
||||
self, state: ActionState, time: datetime, duration: timedelta
|
||||
) -> timedelta:
|
||||
push_time = self._push_time_provider()
|
||||
if push_time <= time:
|
||||
state.finish()
|
||||
return time - push_time
|
||||
return timedelta()
|
||||
|
||||
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
|
||||
remaining_time = self._push_time_provider() - ctx.mission_start_time
|
||||
if remaining_time <= timedelta():
|
||||
return
|
||||
|
||||
loiter = ControlledTask(
|
||||
OrbitAction(
|
||||
altitude=int(self._altitude.meters),
|
||||
pattern=OrbitAction.OrbitPattern.Circle,
|
||||
speed=self._speed.kph,
|
||||
)
|
||||
)
|
||||
# The DCS task is serialized using the time from mission start, not the actual
|
||||
# time.
|
||||
loiter.stop_after_time(int(remaining_time.total_seconds()))
|
||||
yield loiter
|
||||
@@ -1,7 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskContext:
|
||||
mission_start_time: datetime
|
||||
@@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs.task import Task
|
||||
|
||||
from .taskcontext import TaskContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato.flightstate.actionstate import ActionState
|
||||
|
||||
|
||||
class WaypointAction(ABC):
|
||||
@abstractmethod
|
||||
def describe(self) -> str:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def update_state(
|
||||
self, state: ActionState, time: datetime, duration: timedelta
|
||||
) -> timedelta:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
|
||||
...
|
||||
@@ -1,21 +0,0 @@
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
|
||||
from dcs.task import OptFormation, Task
|
||||
|
||||
from game.flightplan.waypointactions.taskcontext import TaskContext
|
||||
from game.flightplan.waypointoptions.waypointoption import WaypointOption
|
||||
|
||||
|
||||
class Formation(WaypointOption, Enum):
|
||||
FINGER_FOUR_CLOSE = OptFormation.finger_four_close()
|
||||
FINGER_FOUR_OPEN = OptFormation.finger_four_open()
|
||||
LINE_ABREAST_OPEN = OptFormation.line_abreast_open()
|
||||
SPREAD_FOUR_OPEN = OptFormation.spread_four_open()
|
||||
TRAIL_OPEN = OptFormation.trail_open()
|
||||
|
||||
def id(self) -> str:
|
||||
return "formation"
|
||||
|
||||
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
|
||||
yield self.value
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from dcs.task import Task
|
||||
|
||||
from game.flightplan.waypointactions.taskcontext import TaskContext
|
||||
|
||||
|
||||
# Not explicitly an ABC because that prevents subclasses from deriving Enum.
|
||||
class WaypointOption:
|
||||
def id(self) -> str:
|
||||
raise RuntimeError
|
||||
|
||||
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
|
||||
raise RuntimeError
|
||||
@@ -1,140 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from dcs import Point
|
||||
from dcs.mapping import Point as DcsPoint
|
||||
from dcs.terrain import Terrain
|
||||
from numpy import float64, array
|
||||
from numpy._typing import NDArray
|
||||
from shapely import transform, to_geojson
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .waypointstrategy import WaypointStrategy
|
||||
|
||||
|
||||
class NoSolutionsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WaypointSolver:
|
||||
def __init__(self) -> None:
|
||||
self.strategies: list[WaypointStrategy] = []
|
||||
self.debug_output_directory: Path | None = None
|
||||
self._terrain: Terrain | None = None
|
||||
|
||||
def add_strategy(self, strategy: WaypointStrategy) -> None:
|
||||
self.strategies.append(strategy)
|
||||
|
||||
def set_debug_properties(self, path: Path, terrain: Terrain) -> None:
|
||||
self.debug_output_directory = path
|
||||
self._terrain = terrain
|
||||
|
||||
def to_geojson(self, geometry: BaseGeometry) -> dict[str, Any]:
|
||||
if geometry.is_empty:
|
||||
return json.loads(to_geojson(geometry))
|
||||
|
||||
assert self._terrain is not None
|
||||
origin = DcsPoint(0, 0, self._terrain)
|
||||
|
||||
def xy_to_ll(points: NDArray[float64]) -> NDArray[float64]:
|
||||
ll_points = []
|
||||
for point in points:
|
||||
p = origin.new_in_same_map(point[0], point[1])
|
||||
latlng = p.latlng()
|
||||
# Longitude is unintuitively first because it's the "X" coordinate:
|
||||
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1
|
||||
ll_points.append([latlng.lng, latlng.lat])
|
||||
return array(ll_points)
|
||||
|
||||
transformed = transform(geometry, xy_to_ll)
|
||||
return json.loads(to_geojson(transformed))
|
||||
|
||||
def describe_metadata(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
|
||||
yield from []
|
||||
|
||||
def describe_debug(self) -> dict[str, Any]:
|
||||
assert self._terrain is not None
|
||||
metadata = {"name": self.__class__.__name__, "terrain": self._terrain.name}
|
||||
metadata.update(self.describe_metadata())
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
# The GeoJSON spec forbids us from adding a "properties" field to a feature
|
||||
# collection, but it doesn't restrict us from adding our own custom fields.
|
||||
# https://gis.stackexchange.com/a/209263
|
||||
#
|
||||
# It's possible that some consumers won't work with this, but we don't read
|
||||
# collections directly with shapely and geojson.io is happy with it, so it
|
||||
# works where we need it to.
|
||||
"metadata": metadata,
|
||||
"features": list(self.describe_features()),
|
||||
}
|
||||
|
||||
def describe_features(self) -> Iterator[dict[str, Any]]:
|
||||
for description, geometry in self.describe_inputs():
|
||||
yield {
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"description": description,
|
||||
},
|
||||
"geometry": self.to_geojson(geometry),
|
||||
}
|
||||
|
||||
def dump_debug_info(self) -> None:
|
||||
path = self.debug_output_directory
|
||||
if path is None:
|
||||
return
|
||||
|
||||
path.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
inputs_path = path / "solver.json"
|
||||
with inputs_path.open("w", encoding="utf-8") as inputs_file:
|
||||
json.dump(self.describe_debug(), inputs_file)
|
||||
|
||||
features = list(self.describe_features())
|
||||
for idx, strategy in enumerate(self.strategies):
|
||||
strategy_path = path / f"{idx}.json"
|
||||
with strategy_path.open("w", encoding="utf-8") as strategy_debug_file:
|
||||
json.dump(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"metadata": {
|
||||
"name": strategy.__class__.__name__,
|
||||
"prerequisites": [
|
||||
p.describe_debug_info(self.to_geojson)
|
||||
for p in strategy.prerequisites
|
||||
],
|
||||
},
|
||||
# Include the solver's features in the strategy feature
|
||||
# collection for easy copy/paste into geojson.io.
|
||||
"features": features
|
||||
+ [
|
||||
d.to_geojson(self.to_geojson)
|
||||
for d in strategy.iter_debug_info()
|
||||
],
|
||||
},
|
||||
strategy_debug_file,
|
||||
)
|
||||
|
||||
def solve(self) -> Point:
|
||||
if not self.strategies:
|
||||
raise ValueError(
|
||||
"WaypointSolver.solve() called before any strategies were added"
|
||||
)
|
||||
|
||||
for strategy in self.strategies:
|
||||
if (point := strategy.find()) is not None:
|
||||
return point
|
||||
|
||||
self.dump_debug_info()
|
||||
debug_details = "No debug output directory set"
|
||||
if (debug_path := self.debug_output_directory) is not None:
|
||||
debug_details = f"Debug details written to {debug_path}"
|
||||
raise NoSolutionsError(f"No solutions found for waypoint. {debug_details}")
|
||||
@@ -1,79 +0,0 @@
|
||||
import json
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dcs.mapping import Point as DcsPoint, LatLng
|
||||
from dcs.terrain import Terrain
|
||||
from numpy import float64, array
|
||||
from numpy._typing import NDArray
|
||||
from shapely import transform
|
||||
from shapely.geometry import shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
from game.data.doctrine import Doctrine, ALL_DOCTRINES
|
||||
from .ipsolver import IpSolver
|
||||
from .waypointsolver import WaypointSolver
|
||||
from ..theater.theaterloader import TERRAINS_BY_NAME
|
||||
|
||||
|
||||
def doctrine_from_name(name: str) -> Doctrine:
|
||||
for doctrine in ALL_DOCTRINES:
|
||||
if doctrine.name == name:
|
||||
return doctrine
|
||||
raise KeyError
|
||||
|
||||
|
||||
def geometry_ll_to_xy(geometry: BaseGeometry, terrain: Terrain) -> BaseGeometry:
|
||||
if geometry.is_empty:
|
||||
return geometry
|
||||
|
||||
def ll_to_xy(points: NDArray[float64]) -> NDArray[float64]:
|
||||
ll_points = []
|
||||
for point in points:
|
||||
# Longitude is unintuitively first because it's the "X" coordinate:
|
||||
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1
|
||||
p = DcsPoint.from_latlng(LatLng(point[1], point[0]), terrain)
|
||||
ll_points.append([p.x, p.y])
|
||||
return array(ll_points)
|
||||
|
||||
return transform(geometry, ll_to_xy)
|
||||
|
||||
|
||||
class WaypointSolverLoader:
|
||||
def __init__(self, debug_info_path: Path) -> None:
|
||||
self.debug_info_path = debug_info_path
|
||||
|
||||
def load_data(self) -> dict[str, Any]:
|
||||
with self.debug_info_path.open(encoding="utf-8") as debug_info_file:
|
||||
return json.load(debug_info_file)
|
||||
|
||||
@staticmethod
|
||||
def load_geometries(
|
||||
feature_collection: dict[str, Any], terrain: Terrain
|
||||
) -> dict[str, BaseGeometry]:
|
||||
geometries = {}
|
||||
for feature in feature_collection["features"]:
|
||||
description = feature["properties"]["description"]
|
||||
geometry = shape(feature["geometry"])
|
||||
geometries[description] = geometry_ll_to_xy(geometry, terrain)
|
||||
return geometries
|
||||
|
||||
@cached_property
|
||||
def terrain(self) -> Terrain:
|
||||
return TERRAINS_BY_NAME[self.load_data()["metadata"]["terrain"]]
|
||||
|
||||
def load(self) -> WaypointSolver:
|
||||
data = self.load_data()
|
||||
metadata = data["metadata"]
|
||||
name = metadata.pop("name")
|
||||
terrain_name = metadata.pop("terrain")
|
||||
terrain = TERRAINS_BY_NAME[terrain_name]
|
||||
if "doctrine" in metadata:
|
||||
metadata["doctrine"] = doctrine_from_name(metadata["doctrine"])
|
||||
geometries = self.load_geometries(data, terrain)
|
||||
builder: type[WaypointSolver] = {
|
||||
"IpSolver": IpSolver,
|
||||
}[name]
|
||||
metadata.update(geometries)
|
||||
return builder(**metadata)
|
||||
@@ -1,269 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from abc import abstractmethod, ABC
|
||||
from collections.abc import Iterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from dcs.mapping import heading_between_points
|
||||
from shapely.geometry import Point, MultiPolygon, Polygon
|
||||
from shapely.geometry.base import BaseGeometry as Geometry, BaseGeometry
|
||||
from shapely.ops import nearest_points
|
||||
|
||||
from game.utils import Distance, nautical_miles, Heading
|
||||
|
||||
|
||||
def angle_between_points(a: Point, b: Point) -> float:
|
||||
return heading_between_points(a.x, a.y, b.x, b.y)
|
||||
|
||||
|
||||
def point_at_heading(p: Point, heading: Heading, distance: Distance) -> Point:
|
||||
rad_heading = heading.radians
|
||||
return Point(
|
||||
p.x + math.cos(rad_heading) * distance.meters,
|
||||
p.y + math.sin(rad_heading) * distance.meters,
|
||||
)
|
||||
|
||||
|
||||
class Prerequisite(ABC):
|
||||
@abstractmethod
|
||||
def is_satisfied(self) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def describe_debug_info(
|
||||
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class DistancePrerequisite(Prerequisite):
|
||||
def __init__(self, a: Point, b: Point, min_range: Distance) -> None:
|
||||
self.a = a
|
||||
self.b = b
|
||||
self.min_range = min_range
|
||||
|
||||
def is_satisfied(self) -> bool:
|
||||
return self.a.distance(self.b) >= self.min_range.meters
|
||||
|
||||
def describe_debug_info(
|
||||
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"requirement": f"at least {self.min_range} between",
|
||||
"satisfied": self.is_satisfied(),
|
||||
"subject": to_geojson(self.a),
|
||||
"target": to_geojson(self.b),
|
||||
}
|
||||
|
||||
|
||||
class SafePrerequisite(Prerequisite):
|
||||
def __init__(self, point: Point, threat_zones: MultiPolygon) -> None:
|
||||
self.point = point
|
||||
self.threat_zones = threat_zones
|
||||
|
||||
def is_satisfied(self) -> bool:
|
||||
return not self.point.intersects(self.threat_zones)
|
||||
|
||||
def describe_debug_info(
|
||||
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"requirement": "is safe",
|
||||
"satisfied": self.is_satisfied(),
|
||||
"subject": to_geojson(self.point),
|
||||
}
|
||||
|
||||
|
||||
class PrerequisiteBuilder:
|
||||
def __init__(
|
||||
self, subject: Point, threat_zones: MultiPolygon, strategy: WaypointStrategy
|
||||
) -> None:
|
||||
self.subject = subject
|
||||
self.threat_zones = threat_zones
|
||||
self.strategy = strategy
|
||||
|
||||
def is_safe(self) -> None:
|
||||
self.strategy.add_prerequisite(
|
||||
SafePrerequisite(self.subject, self.threat_zones)
|
||||
)
|
||||
|
||||
def min_distance_from(self, target: Point, distance: Distance) -> None:
|
||||
self.strategy.add_prerequisite(
|
||||
DistancePrerequisite(self.subject, target, distance)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreatTolerance:
|
||||
target: Point
|
||||
target_buffer: Distance
|
||||
tolerance: Distance
|
||||
|
||||
|
||||
class RequirementBuilder:
|
||||
def __init__(self, threat_zones: MultiPolygon, strategy: WaypointStrategy) -> None:
|
||||
self.threat_zones = threat_zones
|
||||
self.strategy = strategy
|
||||
|
||||
def safe(self) -> None:
|
||||
self.strategy.exclude_threat_zone()
|
||||
|
||||
def at_least(self, distance: Distance) -> DistanceRequirementBuilder:
|
||||
return DistanceRequirementBuilder(self.strategy, min_distance=distance)
|
||||
|
||||
def at_most(self, distance: Distance) -> DistanceRequirementBuilder:
|
||||
return DistanceRequirementBuilder(self.strategy, max_distance=distance)
|
||||
|
||||
def maximum_turn_to(
|
||||
self, turn_point: Point, next_point: Point, turn_limit: Heading
|
||||
) -> None:
|
||||
|
||||
large_distance = nautical_miles(400)
|
||||
next_heading = Heading.from_degrees(
|
||||
angle_between_points(next_point, turn_point)
|
||||
)
|
||||
limit_ccw = point_at_heading(
|
||||
turn_point, next_heading - turn_limit, large_distance
|
||||
)
|
||||
limit_cw = point_at_heading(
|
||||
turn_point, next_heading + turn_limit, large_distance
|
||||
)
|
||||
|
||||
allowed_wedge = Polygon([turn_point, limit_ccw, limit_cw])
|
||||
self.strategy.exclude(
|
||||
f"restrict turn from {turn_point} to {next_point} to {turn_limit}",
|
||||
turn_point.buffer(large_distance.meters).difference(allowed_wedge),
|
||||
)
|
||||
|
||||
|
||||
class DistanceRequirementBuilder:
|
||||
def __init__(
|
||||
self,
|
||||
strategy: WaypointStrategy,
|
||||
min_distance: Distance | None = None,
|
||||
max_distance: Distance | None = None,
|
||||
) -> None:
|
||||
if min_distance is None and max_distance is None:
|
||||
raise ValueError
|
||||
self.strategy = strategy
|
||||
self.min_distance = min_distance
|
||||
self.max_distance = max_distance
|
||||
|
||||
def away_from(self, target: Point, description: str | None = None) -> None:
|
||||
if description is None:
|
||||
description = str(target)
|
||||
|
||||
if self.min_distance is not None:
|
||||
self.strategy.exclude(
|
||||
f"at least {self.min_distance} away from {description}",
|
||||
target.buffer(self.min_distance.meters),
|
||||
)
|
||||
if self.max_distance is not None:
|
||||
self.strategy.exclude_beyond(
|
||||
f"at most {self.max_distance} away from {description}",
|
||||
target.buffer(self.max_distance.meters),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WaypointDebugInfo:
|
||||
description: str
|
||||
geometry: BaseGeometry
|
||||
|
||||
def to_geojson(
|
||||
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"description": self.description,
|
||||
},
|
||||
"geometry": to_geojson(self.geometry),
|
||||
}
|
||||
|
||||
|
||||
class WaypointStrategy:
|
||||
def __init__(self, threat_zones: MultiPolygon) -> None:
|
||||
self.threat_zones = threat_zones
|
||||
self.prerequisites: list[Prerequisite] = []
|
||||
self._max_area = Point(0, 0).buffer(2_000_000)
|
||||
self.allowed_area = self._max_area.buffer(0)
|
||||
self.debug_infos: list[WaypointDebugInfo] = []
|
||||
self._threat_tolerance: ThreatTolerance | None = None
|
||||
self.point_for_nearest_solution: Point | None = None
|
||||
|
||||
def add_prerequisite(self, prerequisite: Prerequisite) -> None:
|
||||
self.prerequisites.append(prerequisite)
|
||||
|
||||
def prerequisite(self, subject: Point) -> PrerequisiteBuilder:
|
||||
return PrerequisiteBuilder(subject, self.threat_zones, self)
|
||||
|
||||
def exclude(self, description: str, geometry: Geometry) -> None:
|
||||
self.debug_infos.append(WaypointDebugInfo(description, geometry))
|
||||
self.allowed_area = self.allowed_area.difference(geometry)
|
||||
|
||||
def exclude_beyond(self, description: str, geometry: Geometry) -> None:
|
||||
self.exclude(description, self._max_area.difference(geometry))
|
||||
|
||||
def exclude_threat_zone(self) -> None:
|
||||
if (tolerance := self._threat_tolerance) is not None:
|
||||
description = (
|
||||
f"safe with a {tolerance.tolerance} tolerance to a "
|
||||
f"{tolerance.target_buffer} radius about {tolerance.target}"
|
||||
)
|
||||
else:
|
||||
description = "safe"
|
||||
self.exclude(description, self.threat_zones)
|
||||
|
||||
def prerequisites_are_satisfied(self) -> bool:
|
||||
for prereq in self.prerequisites:
|
||||
if not prereq.is_satisfied():
|
||||
return False
|
||||
return True
|
||||
|
||||
def require(self) -> RequirementBuilder:
|
||||
return RequirementBuilder(self.threat_zones, self)
|
||||
|
||||
def threat_tolerance(
|
||||
self, target: Point, target_size: Distance, wiggle: Distance
|
||||
) -> None:
|
||||
if self.threat_zones.is_empty:
|
||||
return
|
||||
|
||||
min_distance_from_threat_to_target_buffer = target.buffer(
|
||||
target_size.meters
|
||||
).distance(self.threat_zones.boundary)
|
||||
threat_mask = self.threat_zones.buffer(
|
||||
-min_distance_from_threat_to_target_buffer - wiggle.meters
|
||||
)
|
||||
self._threat_tolerance = ThreatTolerance(target, target_size, wiggle)
|
||||
self.threat_zones = self.threat_zones.difference(threat_mask)
|
||||
|
||||
def nearest(self, point: Point) -> None:
|
||||
if self.point_for_nearest_solution is not None:
|
||||
raise RuntimeError("WaypointStrategy.nearest() called more than once")
|
||||
self.point_for_nearest_solution = point
|
||||
|
||||
def find(self) -> Point | None:
|
||||
if self.point_for_nearest_solution is None:
|
||||
raise RuntimeError(
|
||||
"Must call WaypointStrategy.nearest() before WaypointStrategy.find()"
|
||||
)
|
||||
|
||||
if not self.prerequisites_are_satisfied():
|
||||
return None
|
||||
|
||||
try:
|
||||
return nearest_points(self.allowed_area, self.point_for_nearest_solution)[0]
|
||||
except ValueError:
|
||||
# No solutions.
|
||||
return None
|
||||
|
||||
def iter_debug_info(self) -> Iterator[WaypointDebugInfo]:
|
||||
yield from self.debug_infos
|
||||
solution = self.find()
|
||||
if solution is None:
|
||||
return
|
||||
yield WaypointDebugInfo("solution", solution)
|
||||
10
game/game.py
10
game/game.py
@@ -24,7 +24,6 @@ from .campaignloader import CampaignAirWingConfig
|
||||
from .coalition import Coalition
|
||||
from .db.gamedb import GameDb
|
||||
from .infos.information import Information
|
||||
from .lasercodes.lasercoderegistry import LaserCodeRegistry
|
||||
from .persistence import SaveManager
|
||||
from .profiling import logged_duration
|
||||
from .settings import Settings
|
||||
@@ -113,7 +112,6 @@ class Game:
|
||||
self.current_unit_id = 0
|
||||
self.current_group_id = 0
|
||||
self.name_generator = naming.namegen
|
||||
self.laser_code_registry = LaserCodeRegistry()
|
||||
|
||||
self.db = GameDb()
|
||||
|
||||
@@ -292,7 +290,7 @@ class Game:
|
||||
if self.turn > 1:
|
||||
self.conditions = self.generate_conditions()
|
||||
|
||||
def begin_turn_0(self) -> None:
|
||||
def begin_turn_0(self, squadrons_start_full: bool) -> None:
|
||||
"""Initialization for the first turn of the game."""
|
||||
from .sim import GameUpdateEvents
|
||||
|
||||
@@ -301,7 +299,7 @@ class Game:
|
||||
self.theater.iads_network.initialize_network(self.theater.ground_objects)
|
||||
|
||||
for control_point in self.theater.controlpoints:
|
||||
control_point.initialize_turn_0(self.laser_code_registry)
|
||||
control_point.initialize_turn_0()
|
||||
for tgo in control_point.connected_objectives:
|
||||
self.db.tgos.add(tgo.id, tgo)
|
||||
|
||||
@@ -317,8 +315,8 @@ class Game:
|
||||
# Rotate the whole TGO with the new heading
|
||||
tgo.rotate(heading or tgo.heading)
|
||||
|
||||
self.blue.preinit_turn_0()
|
||||
self.red.preinit_turn_0()
|
||||
self.blue.preinit_turn_0(squadrons_start_full)
|
||||
self.red.preinit_turn_0(squadrons_start_full)
|
||||
# TODO: Check for overfull bases.
|
||||
# We don't need to actually stream events for turn zero because we haven't given
|
||||
# *any* state to the UI yet, so it will need to do a full draw once we do.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .ilasercoderegistry import ILaserCodeRegistry
|
||||
from .lasercode import LaserCode
|
||||
from .lasercoderegistry import LaserCodeRegistry
|
||||
@@ -1,17 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .lasercode import LaserCode
|
||||
|
||||
|
||||
class ILaserCodeRegistry(ABC):
|
||||
@abstractmethod
|
||||
def alloc_laser_code(self) -> LaserCode:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def release_code(self, code: LaserCode) -> None:
|
||||
...
|
||||
@@ -1,56 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ilasercoderegistry import ILaserCodeRegistry
|
||||
|
||||
|
||||
class LaserCode:
|
||||
def __init__(self, code: int, registry: ILaserCodeRegistry) -> None:
|
||||
self.verify_laser_code(code)
|
||||
self.code = code
|
||||
self.registry = registry
|
||||
|
||||
def release(self) -> None:
|
||||
self.registry.release_code(self)
|
||||
|
||||
@staticmethod
|
||||
def verify_laser_code(code: int) -> None:
|
||||
# https://forum.dcs.world/topic/211574-valid-laser-codes/
|
||||
# Valid laser codes are as follows
|
||||
# First digit is always 1
|
||||
# Second digit is 5-7
|
||||
# Third and fourth digits are 1 - 8
|
||||
# We iterate backward (reversed()) so that 1687 follows 1688
|
||||
|
||||
# Special case used by FC3 aircraft like the A-10A that is not valid for other
|
||||
# aircraft.
|
||||
if code == 1113:
|
||||
return
|
||||
|
||||
# Must be 4 digits with no leading 0
|
||||
if code < 1000 or code >= 2000:
|
||||
raise ValueError
|
||||
|
||||
# The first digit was already verified above. Isolate the remaining three
|
||||
# digits. The resulting list is ordered by significance, not printed position.
|
||||
digits = [code // 10**i % 10 for i in range(3)]
|
||||
|
||||
if digits[0] < 1 or digits[0] > 8:
|
||||
raise ValueError
|
||||
if digits[1] < 1 or digits[1] > 8:
|
||||
raise ValueError
|
||||
if digits[2] < 5 or digits[2] > 7:
|
||||
raise ValueError
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.code}"
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, LaserCode):
|
||||
return False
|
||||
return self.code == other.code
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.code)
|
||||
@@ -1,42 +0,0 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
from .ilasercoderegistry import ILaserCodeRegistry
|
||||
from .lasercode import LaserCode
|
||||
|
||||
|
||||
class LaserCodeRegistry(ILaserCodeRegistry):
|
||||
def __init__(self) -> None:
|
||||
self.allocated_codes: set[int] = set()
|
||||
self.available_codes = LaserCodeRegistry._all_valid_laser_codes()
|
||||
self.fc3_code = LaserCode(1113, self)
|
||||
|
||||
def alloc_laser_code(self) -> LaserCode:
|
||||
try:
|
||||
code = self.available_codes.popleft()
|
||||
self.allocated_codes.add(code)
|
||||
return LaserCode(code, self)
|
||||
except IndexError:
|
||||
raise RuntimeError("All laser codes have been allocated")
|
||||
|
||||
def release_code(self, code: LaserCode) -> None:
|
||||
if code.code in self.allocated_codes:
|
||||
self.allocated_codes.remove(code.code)
|
||||
self.available_codes.appendleft(code.code)
|
||||
else:
|
||||
logging.error(
|
||||
"attempted to release laser code %d which was not allocated", code.code
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _all_valid_laser_codes() -> deque[int]:
|
||||
# Valid laser codes are as follows
|
||||
# First digit is always 1
|
||||
# Second digit is 5-7
|
||||
# Third and fourth digits are 1 - 8
|
||||
# We iterate backward (reversed()) so that 1687 follows 1688
|
||||
q = deque(int(oct(code)[2:]) + 11 for code in reversed(range(0o1500, 0o2000)))
|
||||
|
||||
# We start with the default of 1688 and wrap around when we reach the end
|
||||
q.rotate(-q.index(1688))
|
||||
return q
|
||||
@@ -18,9 +18,7 @@ from dcs.task import (
|
||||
OptRestrictJettison,
|
||||
Refueling,
|
||||
RunwayAttack,
|
||||
SEAD,
|
||||
Transport,
|
||||
SetUnlimitedFuelCommand,
|
||||
)
|
||||
from dcs.unitgroup import FlyingGroup
|
||||
|
||||
@@ -28,13 +26,11 @@ from game.ato import Flight, FlightType
|
||||
from game.ato.flightplans.aewc import AewcFlightPlan
|
||||
from game.ato.flightplans.shiprecoverytanker import RecoveryTankerFlightPlan
|
||||
from game.ato.flightplans.theaterrefueling import TheaterRefuelingFlightPlan
|
||||
from game.settings import Settings
|
||||
|
||||
|
||||
class AircraftBehavior:
|
||||
def __init__(self, task: FlightType, settings: Settings) -> None:
|
||||
def __init__(self, task: FlightType) -> None:
|
||||
self.task = task
|
||||
self.settings = settings
|
||||
|
||||
def apply_to(self, flight: Flight, group: FlyingGroup[Any]) -> None:
|
||||
if self.task in [
|
||||
@@ -77,7 +73,6 @@ class AircraftBehavior:
|
||||
else:
|
||||
self.configure_unknown_task(group, flight)
|
||||
|
||||
self.configure_unlimited_fuel(group, flight)
|
||||
self.configure_eplrs(group, flight)
|
||||
|
||||
def configure_behavior(
|
||||
@@ -119,17 +114,6 @@ class AircraftBehavior:
|
||||
if flight.unit_type.eplrs_capable:
|
||||
group.points[0].tasks.append(EPLRS(group.id))
|
||||
|
||||
def configure_unlimited_fuel(self, group: FlyingGroup[Any], flight: Flight) -> None:
|
||||
if not flight.client_count and self.settings.ai_has_unlimited_fuel:
|
||||
# This task is prepended according to the notes from the DCS changelog:
|
||||
#
|
||||
# NEW: Advanced Waypoint Action for Unlimited Fuel Option for AI. Please
|
||||
# note the task must be at the top of Advanced Waypoint Actions list to
|
||||
# make sure it works properly.
|
||||
#
|
||||
# https://www.digitalcombatsimulator.com/en/news/changelog/openbeta/2.9.0.46801/
|
||||
group.points[0].tasks.insert(0, SetUnlimitedFuelCommand(True))
|
||||
|
||||
def configure_cap(self, group: FlyingGroup[Any], flight: Flight) -> None:
|
||||
group.task = CAP.name
|
||||
|
||||
@@ -180,7 +164,10 @@ class AircraftBehavior:
|
||||
)
|
||||
|
||||
def configure_sead(self, group: FlyingGroup[Any], flight: Flight) -> None:
|
||||
group.task = SEAD.name
|
||||
# CAS is able to perform all the same tasks as SEAD using a superset of the
|
||||
# available aircraft, and F-14s are not able to be SEAD despite having TALDs.
|
||||
# https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/
|
||||
group.task = CAS.name
|
||||
self.configure_behavior(
|
||||
flight,
|
||||
group,
|
||||
@@ -288,7 +275,10 @@ class AircraftBehavior:
|
||||
)
|
||||
|
||||
def configure_sead_escort(self, group: FlyingGroup[Any], flight: Flight) -> None:
|
||||
group.task = SEAD.name
|
||||
# CAS is able to perform all the same tasks as SEAD using a superset of the
|
||||
# available aircraft, and F-14s are not able to be SEAD despite having TALDs.
|
||||
# https://forums.eagle.ru/topic/272112-cannot-assign-f-14-to-sead/
|
||||
group.task = CAS.name
|
||||
self.configure_behavior(
|
||||
flight,
|
||||
group,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
from typing import Any, Dict, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, TYPE_CHECKING
|
||||
|
||||
from dcs.country import Country
|
||||
from dcs.mission import Mission
|
||||
@@ -18,6 +18,7 @@ from game.ato.package import Package
|
||||
from game.ato.starttype import StartType
|
||||
from game.factions.faction import Faction
|
||||
from game.missiongenerator.missiondata import MissionData
|
||||
from game.missiongenerator.lasercoderegistry import LaserCodeRegistry
|
||||
from game.radio.radios import RadioRegistry
|
||||
from game.radio.tacan import TacanRegistry
|
||||
from game.runways import RunwayData
|
||||
@@ -47,6 +48,7 @@ class AircraftGenerator:
|
||||
time: datetime,
|
||||
radio_registry: RadioRegistry,
|
||||
tacan_registry: TacanRegistry,
|
||||
laser_code_registry: LaserCodeRegistry,
|
||||
unit_map: UnitMap,
|
||||
mission_data: MissionData,
|
||||
helipads: dict[ControlPoint, StaticGroup],
|
||||
@@ -57,10 +59,9 @@ class AircraftGenerator:
|
||||
self.time = time
|
||||
self.radio_registry = radio_registry
|
||||
self.tacan_registy = tacan_registry
|
||||
self.laser_code_registry = laser_code_registry
|
||||
self.unit_map = unit_map
|
||||
# A list of per-package briefing data, which is in turn a list of per-flight
|
||||
# briefing data.
|
||||
self.briefing_data: list[list[FlightData]] = []
|
||||
self.flights: List[FlightData] = []
|
||||
self.mission_data = mission_data
|
||||
self.helipads = helipads
|
||||
|
||||
@@ -104,16 +105,13 @@ class AircraftGenerator:
|
||||
for package in ato.packages:
|
||||
if not package.flights:
|
||||
continue
|
||||
package_briefing_data: list[FlightData] = []
|
||||
for flight in package.flights:
|
||||
if flight.alive:
|
||||
logging.info(f"Generating flight: {flight.unit_type}")
|
||||
group, briefing_data = self.create_and_configure_flight(
|
||||
group = self.create_and_configure_flight(
|
||||
flight, country, dynamic_runways
|
||||
)
|
||||
package_briefing_data.append(briefing_data)
|
||||
self.unit_map.add_aircraft(group, flight)
|
||||
self.briefing_data.append(package_briefing_data)
|
||||
|
||||
def spawn_unused_aircraft(
|
||||
self, player_country: Country, enemy_country: Country
|
||||
@@ -162,25 +160,27 @@ class AircraftGenerator:
|
||||
|
||||
def create_and_configure_flight(
|
||||
self, flight: Flight, country: Country, dynamic_runways: Dict[str, RunwayData]
|
||||
) -> tuple[FlyingGroup[Any], FlightData]:
|
||||
) -> FlyingGroup[Any]:
|
||||
"""Creates and configures the flight group in the mission."""
|
||||
group = FlightGroupSpawner(
|
||||
flight, country, self.mission, self.helipads
|
||||
).create_flight_group()
|
||||
|
||||
briefing_data = FlightGroupConfigurator(
|
||||
flight,
|
||||
group,
|
||||
self.game,
|
||||
self.mission,
|
||||
self.time,
|
||||
self.radio_registry,
|
||||
self.tacan_registy,
|
||||
self.mission_data,
|
||||
dynamic_runways,
|
||||
self.use_client,
|
||||
self.unit_map,
|
||||
).configure()
|
||||
self.flights.append(
|
||||
FlightGroupConfigurator(
|
||||
flight,
|
||||
group,
|
||||
self.game,
|
||||
self.mission,
|
||||
self.time,
|
||||
self.radio_registry,
|
||||
self.tacan_registy,
|
||||
self.laser_code_registry,
|
||||
self.mission_data,
|
||||
dynamic_runways,
|
||||
self.use_client,
|
||||
self.unit_map,
|
||||
).configure()
|
||||
)
|
||||
|
||||
wpt = group.waypoint("LANDING")
|
||||
if flight.is_helo and isinstance(flight.arrival, Fob) and wpt:
|
||||
@@ -189,4 +189,4 @@ class AircraftGenerator:
|
||||
wpt.link_unit = hpad.id
|
||||
self.helipads[flight.arrival].units.append(hpad)
|
||||
|
||||
return group, briefing_data
|
||||
return group
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dcs import Point
|
||||
|
||||
from game.utils import Distance, meters
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game.ato.flightwaypoint import FlightWaypoint
|
||||
from game.dcs.aircrafttype import FuelConsumption
|
||||
|
||||
|
||||
class BingoEstimator:
|
||||
"""Estimates bingo/joker fuel values for a flight plan.
|
||||
|
||||
The results returned by this class are bogus for most airframes. Only the few
|
||||
airframes which have fuel consumption data available can provide even moderately
|
||||
reliable estimates. **Do not use this for flight planning.** This should only be
|
||||
used in briefing context where it's okay to be wrong.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fuel_consumption: FuelConsumption | None,
|
||||
arrival: Point,
|
||||
divert: Point | None,
|
||||
waypoints: list[FlightWaypoint],
|
||||
) -> None:
|
||||
self.fuel_consumption = fuel_consumption
|
||||
self.arrival = arrival
|
||||
self.divert = divert
|
||||
self.waypoints = waypoints
|
||||
|
||||
def estimate_bingo(self) -> int:
|
||||
"""Bingo fuel value for the FlightPlan"""
|
||||
if (fuel := self.fuel_consumption) is not None:
|
||||
return self._fuel_consumption_based_estimate(fuel)
|
||||
return self._legacy_bingo_estimate()
|
||||
|
||||
def estimate_joker(self) -> int:
|
||||
"""Joker fuel value for the FlightPlan"""
|
||||
return self.estimate_bingo() + 1000
|
||||
|
||||
def _fuel_consumption_based_estimate(self, fuel: FuelConsumption) -> int:
|
||||
distance_to_arrival = self._max_distance_from(self.arrival)
|
||||
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
|
||||
bingo = fuel_consumed + fuel.min_safe
|
||||
return math.ceil(bingo / 100) * 100
|
||||
|
||||
def _legacy_bingo_estimate(self) -> int:
|
||||
distance_to_arrival = self._max_distance_from(self.arrival)
|
||||
|
||||
bingo = 1000.0 # Minimum Emergency Fuel
|
||||
bingo += 500 # Visual Traffic
|
||||
bingo += 15 * distance_to_arrival.nautical_miles
|
||||
|
||||
if self.divert is not None:
|
||||
max_divert_distance = self._max_distance_from(self.divert)
|
||||
bingo += 10 * max_divert_distance.nautical_miles
|
||||
|
||||
return round(bingo / 100) * 100
|
||||
|
||||
def _max_distance_from(self, point: Point) -> Distance:
|
||||
return max(meters(point.distance_to_point(w.position)) for w in self.waypoints)
|
||||
@@ -4,14 +4,15 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from dcs import Mission, Point
|
||||
from dcs import Mission
|
||||
from dcs.flyingunit import FlyingUnit
|
||||
from dcs.unit import Skill
|
||||
from dcs.unitgroup import FlyingGroup
|
||||
|
||||
from game.ato import Flight, FlightType
|
||||
from game.callsigns import callsign_for_support_unit
|
||||
from game.data.weapons import Pylon
|
||||
from game.data.weapons import Pylon, WeaponType as WeaponTypeEnum
|
||||
from game.missiongenerator.lasercoderegistry import LaserCodeRegistry
|
||||
from game.missiongenerator.logisticsgenerator import LogisticsGenerator
|
||||
from game.missiongenerator.missiondata import AwacsInfo, MissionData, TankerInfo
|
||||
from game.radio.radios import RadioFrequency, RadioRegistry
|
||||
@@ -21,10 +22,8 @@ from game.squadrons import Pilot
|
||||
from game.unitmap import UnitMap
|
||||
from .aircraftbehavior import AircraftBehavior
|
||||
from .aircraftpainter import AircraftPainter
|
||||
from .bingoestimator import BingoEstimator
|
||||
from .flightdata import FlightData
|
||||
from .waypoints import WaypointGenerator
|
||||
from ...ato.flightmember import FlightMember
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from game import Game
|
||||
@@ -40,6 +39,7 @@ class FlightGroupConfigurator:
|
||||
time: datetime,
|
||||
radio_registry: RadioRegistry,
|
||||
tacan_registry: TacanRegistry,
|
||||
laser_code_registry: LaserCodeRegistry,
|
||||
mission_data: MissionData,
|
||||
dynamic_runways: dict[str, RunwayData],
|
||||
use_client: bool,
|
||||
@@ -52,24 +52,23 @@ class FlightGroupConfigurator:
|
||||
self.time = time
|
||||
self.radio_registry = radio_registry
|
||||
self.tacan_registry = tacan_registry
|
||||
self.laser_code_registry = laser_code_registry
|
||||
self.mission_data = mission_data
|
||||
self.dynamic_runways = dynamic_runways
|
||||
self.use_client = use_client
|
||||
self.unit_map = unit_map
|
||||
|
||||
def configure(self) -> FlightData:
|
||||
AircraftBehavior(self.flight.flight_type, self.game.settings).apply_to(
|
||||
self.flight, self.group
|
||||
)
|
||||
AircraftBehavior(self.flight.flight_type).apply_to(self.flight, self.group)
|
||||
AircraftPainter(self.flight, self.group).apply_livery()
|
||||
self.setup_props()
|
||||
self.setup_payloads()
|
||||
self.setup_payload()
|
||||
self.setup_fuel()
|
||||
flight_channel = self.setup_radios()
|
||||
|
||||
laser_codes: list[Optional[int]] = []
|
||||
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
||||
self.configure_flight_member(unit, member, laser_codes)
|
||||
for unit, pilot in zip(self.group.units, self.flight.roster.pilots):
|
||||
self.configure_flight_member(unit, pilot, laser_codes)
|
||||
|
||||
divert = None
|
||||
if self.flight.divert is not None:
|
||||
@@ -105,23 +104,13 @@ class FlightGroupConfigurator:
|
||||
self.unit_map,
|
||||
).create_waypoints()
|
||||
|
||||
divert_position: Point | None = None
|
||||
if self.flight.divert is not None:
|
||||
divert_position = self.flight.divert.position
|
||||
bingo_estimator = BingoEstimator(
|
||||
self.flight.unit_type.fuel_consumption,
|
||||
self.flight.arrival.position,
|
||||
divert_position,
|
||||
self.flight.flight_plan.waypoints,
|
||||
)
|
||||
|
||||
return FlightData(
|
||||
package=self.flight.package,
|
||||
aircraft_type=self.flight.unit_type,
|
||||
flight_type=self.flight.flight_type,
|
||||
units=self.group.units,
|
||||
size=len(self.group.units),
|
||||
friendly=self.flight.departure.captured,
|
||||
friendly=self.flight.from_cp.captured,
|
||||
departure_delay=mission_start_time,
|
||||
departure=self.flight.departure.active_runway(
|
||||
self.game.theater, self.game.conditions, self.dynamic_runways
|
||||
@@ -132,18 +121,19 @@ class FlightGroupConfigurator:
|
||||
divert=divert,
|
||||
waypoints=waypoints,
|
||||
intra_flight_channel=flight_channel,
|
||||
bingo_fuel=bingo_estimator.estimate_bingo(),
|
||||
joker_fuel=bingo_estimator.estimate_joker(),
|
||||
bingo_fuel=self.flight.flight_plan.bingo_fuel,
|
||||
joker_fuel=self.flight.flight_plan.joker_fuel,
|
||||
custom_name=self.flight.custom_name,
|
||||
laser_codes=laser_codes,
|
||||
)
|
||||
|
||||
def configure_flight_member(
|
||||
self, unit: FlyingUnit, member: FlightMember, laser_codes: list[Optional[int]]
|
||||
self, unit: FlyingUnit, pilot: Optional[Pilot], laser_codes: list[Optional[int]]
|
||||
) -> None:
|
||||
self.set_skill(unit, member)
|
||||
if (code := member.tgp_laser_code) is not None:
|
||||
laser_codes.append(code.code)
|
||||
player = pilot is not None and pilot.player
|
||||
self.set_skill(unit, pilot)
|
||||
if self.flight.loadout.has_weapon_of_type(WeaponTypeEnum.TGP) and player:
|
||||
laser_codes.append(self.laser_code_registry.get_next_laser_code())
|
||||
else:
|
||||
laser_codes.append(None)
|
||||
|
||||
@@ -177,7 +167,7 @@ class FlightGroupConfigurator:
|
||||
TankerInfo(
|
||||
group_name=str(self.group.name),
|
||||
callsign=callsign,
|
||||
variant=self.flight.unit_type.display_name,
|
||||
variant=self.flight.unit_type.name,
|
||||
freq=channel,
|
||||
tacan=tacan,
|
||||
start_time=self.flight.flight_plan.mission_begin_on_station_time,
|
||||
@@ -186,9 +176,9 @@ class FlightGroupConfigurator:
|
||||
)
|
||||
)
|
||||
|
||||
def set_skill(self, unit: FlyingUnit, member: FlightMember) -> None:
|
||||
if not member.is_player:
|
||||
unit.skill = self.skill_level_for(unit, member.pilot)
|
||||
def set_skill(self, unit: FlyingUnit, pilot: Optional[Pilot]) -> None:
|
||||
if pilot is None or not pilot.player:
|
||||
unit.skill = self.skill_level_for(unit, pilot)
|
||||
return
|
||||
|
||||
if self.use_client:
|
||||
@@ -225,22 +215,15 @@ class FlightGroupConfigurator:
|
||||
return levels[new_level]
|
||||
|
||||
def setup_props(self) -> None:
|
||||
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
||||
props = dict(member.properties)
|
||||
if (code := member.weapon_laser_code) is not None:
|
||||
for laser_code_config in self.flight.unit_type.laser_code_configs:
|
||||
props.update(laser_code_config.property_dict_for_code(code.code))
|
||||
for prop_id, value in props.items():
|
||||
for prop_id, value in self.flight.props.items():
|
||||
for unit in self.group.units:
|
||||
unit.set_property(prop_id, value)
|
||||
|
||||
def setup_payloads(self) -> None:
|
||||
for unit, member in zip(self.group.units, self.flight.iter_members()):
|
||||
self.setup_payload(unit, member)
|
||||
def setup_payload(self) -> None:
|
||||
for p in self.group.units:
|
||||
p.pylons.clear()
|
||||
|
||||
def setup_payload(self, unit: FlyingUnit, member: FlightMember) -> None:
|
||||
unit.pylons.clear()
|
||||
|
||||
loadout = member.loadout
|
||||
loadout = self.flight.loadout
|
||||
if self.game.settings.restrict_weapons_by_date:
|
||||
loadout = loadout.degrade_for_date(self.flight.unit_type, self.game.date)
|
||||
|
||||
@@ -248,7 +231,7 @@ class FlightGroupConfigurator:
|
||||
if weapon is None:
|
||||
continue
|
||||
pylon = Pylon.for_aircraft(self.flight.unit_type, pylon_number)
|
||||
pylon.equip(unit, weapon)
|
||||
pylon.equip(self.group, weapon)
|
||||
|
||||
def setup_fuel(self) -> None:
|
||||
fuel = self.flight.state.estimate_fuel()
|
||||
@@ -259,7 +242,7 @@ class FlightGroupConfigurator:
|
||||
"starting fuel to 100kg."
|
||||
)
|
||||
fuel = 100
|
||||
for unit, pilot in zip(self.group.units, self.flight.roster.iter_pilots()):
|
||||
for unit, pilot in zip(self.group.units, self.flight.roster.pilots):
|
||||
if pilot is not None and pilot.player:
|
||||
unit.fuel = fuel
|
||||
elif (max_takeoff_fuel := self.flight.max_takeoff_fuel()) is not None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user