Compare commits

..

17 Commits

Author SHA1 Message Date
Dan Albert
4da4956df8 Fix waypoint drag and drop.
The fix for https://github.com/dcs-liberation/dcs_liberation/issues/3037
wasn't complete. It seems this `- 1` was here to work around the UI
wrongly having two takeoff points... Now that we fixed that, this also
needs to go.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3059.

(cherry picked from commit 02c9fe93c5)
2023-06-27 23:50:29 -07:00
Starfire13
618159c1fa Add F-15E Suite 4+ squadrons.
(cherry picked from commit 427df21da5)
2023-06-27 18:49:57 -07:00
Dan Albert
d8c662e7f8 Test SupplyRoute.
(cherry picked from commit f1e9abd157)
2023-06-27 18:49:57 -07:00
Dan Albert
12c41b57c9 Add test for SplitLines.
(cherry picked from commit eeacc79cb6)
2023-06-27 18:49:57 -07:00
Dan Albert
85a27845bc Make loadout/properties tab scrollable.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3044.

(cherry picked from commit d54d906593)
2023-06-26 23:06:23 -07:00
Dan Albert
e3f6347e16 Fix off-by-one error in livery selector.
(cherry picked from commit cc2dfa5d35)
2023-06-26 22:02:53 -07:00
Dan Albert
fffe1b6e94 Fix UI waypoint numbering.
The flight plan used to not include a waypoint for departure, so a few
places would create one for the sake of the UI, or were built to assume
there was a missing waypoint that was okay to ignore. At some point we
added them to the flight plan, but never updated the UI, so the waypoint
list in the flight dialog started counting from 1 instead of 0, and the
openapi endpoint wrongly reported two departure waypoints to the front-
end.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3037.

(cherry picked from commit f7b0dfc3a5)
2023-06-26 22:02:53 -07:00
Dan Albert
5a7a730e23 Undo addition of "(AI)" F-15E variant.
This interacts badly with the built-in squadrons:
https://github.com/dcs-liberation/dcs_liberation/issues/3033. Better to
split the display name and "ID" (which is effectively how the key here
is treated), but that's a more invasive change than I'd like to tackle
in this release.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3033.

(cherry picked from commit 4e90c724bf)
2023-06-26 22:02:53 -07:00
Starfire13
576f777320 Update Starfire's campaigns.
* Added Razbam Strike Eagle options.

(cherry picked from commit fc90b6f2df)
2023-06-26 22:02:53 -07:00
Starfire13
87f7fe5307 Fix F-15E Suite 4+ loadouts for the DCS AI.
DCS AI cannot yet use LGBs.

A2G loadouts for anti-unit have been switched to CBU-97s, which appear
to be the most effective weapon type.
A2G loadouts against static targets (OCA/aircraft, OCA/runway, strike)
have been change to Mk82s and Mk84s.

(cherry picked from commit 266c453c99)
2023-06-26 22:02:53 -07:00
Dan Albert
e1434378a8 Add radio config for the new F-15E.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3028.

(cherry picked from commit 658a86dff5)
2023-06-26 22:02:53 -07:00
Dan Albert
e03b0d99d8 Razbam F-15E banner and icon.
Just reusing the old one.

https://github.com/dcs-liberation/dcs_liberation/issues/3028
(cherry picked from commit d31644c46a)
2023-06-26 22:02:53 -07:00
Dan Albert
e4eb3dec1b Add Razbam F-15E to factions with the old F-15E.
https://github.com/dcs-liberation/dcs_liberation/issues/3028
(cherry picked from commit f27c9f5a3d)
2023-06-26 22:02:53 -07:00
Dan Albert
b365016496 Add YAML file for Razbam Strike Eagle.
The old DCS AI F-15E is sticking around because the two have very
different weapon sets for now, so it's probably better to use the AI-
only one for squadrons that don't expect players.

I've avoided renaming the old one (we probably should name it "... (AI)"
for clarity) because the rename will break save compat. I have added a
_new_ name that new campaigns can use though.

https://github.com/dcs-liberation/dcs_liberation/issues/3028
(cherry picked from commit f805febd43)
2023-06-26 22:02:53 -07:00
Dan Albert
c359b3f7fc Update pydcs (Strike Eagle).
https://github.com/dcs-liberation/dcs_liberation/issues/3028
(cherry picked from commit dca02fea31)
2023-06-26 22:02:53 -07:00
Starfire13
302613069e Add loadouts for Razbam F-15E Strike Eagle.
(cherry picked from commit f97cd5d28f)
2023-06-26 22:02:53 -07:00
Dan Albert
5a22b62e3b Update version to 8.1.0. 2023-06-26 22:02:53 -07:00
950 changed files with 6893 additions and 30555 deletions

View File

@@ -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:
- 14.0.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.

View File

@@ -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:
- 14.0.0
- 7.1.0
- Development build
- type: textarea
attributes:

View File

@@ -4,7 +4,7 @@ runs:
using: composite
steps:
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: "16"
cache: npm

View File

@@ -4,9 +4,9 @@ runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v2
with:
python-version: "3.11.4"
python-version: "3.11"
cache: pip
- name: Install environment

View File

@@ -12,7 +12,7 @@ jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
submodules: true
@@ -35,7 +35,7 @@ jobs:
Compress-Archive -Path .\dist\dcs_liberation\ -DestinationPath
dist\dcs_liberation.zip
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v2
with:
name: dcs_liberation
path: dist/dcs_liberation.zip

View File

@@ -7,11 +7,11 @@ jobs:
name: Black
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable
with:
version: ~=24.3.0
version: ~=22.12
src: "."
options: "--check"
@@ -19,7 +19,7 @@ jobs:
name: Type checking
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
submodules: true

View File

@@ -14,7 +14,7 @@ jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
submodules: true
@@ -33,7 +33,7 @@ jobs:
with:
release: true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v2
with:
name: dcs_liberation
path: dist/
@@ -42,7 +42,7 @@ jobs:
needs: [build]
runs-on: windows-latest
steps:
- uses: actions/download-artifact@v4.1.7
- uses: actions/download-artifact@v2
with:
name: dcs_liberation

View File

@@ -5,7 +5,7 @@ jobs:
name: Python tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
with:
submodules: true
@@ -24,7 +24,7 @@ jobs:
name: Typescript tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up JS environment
uses: ./.github/actions/setup-liberation-js

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/psf/black
rev: 23.11.0
rev: 22.12.0
hooks:
- id: black
language_version: python3

View File

@@ -2,6 +2,8 @@
(Github Readme Banner and Splash screen Artwork by Andriy Dankovych, CC BY-SA 4.0)
[![Patreon](https://img.shields.io/badge/patreon-become%20a%20patron-orange?logo=patreon)](https://patreon.com/khopa)
[![Download](https://img.shields.io/github/downloads/dcs-liberation/dcs_liberation/total?label=Download)](https://github.com/dcs-liberation/dcs_liberation/releases)
[![Discord](https://img.shields.io/discord/595702951800995872?label=Discord&logo=discord)](https://discord.gg/bKrtrkJ)
@@ -15,6 +17,11 @@
DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player or co-op dynamic campaign.
It is an external program that generates full and complex DCS missions and manage a persistent combat environment.
**Note that DCS Liberation does not support the stable release of DCS. We can
only guarantee compatibility with either the open beta or the stable release,
and more people play the open beta. DCS stable _might_ work sometimes, but it's
untested, and we will be unable to fix any bugs unique to stable DCS.**
![Screenshot](https://user-images.githubusercontent.com/315852/120939254-0b4a9f80-c6cc-11eb-82f5-ce3f8d714bfe.png)
## Downloads

View File

@@ -1,164 +1,3 @@
# 14.0.0
Saves from 13.x are not compatible with 14.0.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.16.10973
* **[UI]** Allow saving after fast forwarding manually with sim speed controls (--show-sim-speed-controls option).
## Fixes
* **[Campaign]** Units are restored to full health when repaired.
* **[UI]** Air Wing and Transfers buttons disabled when no game is loaded as pressing them without a game loaded resulted in a crash.
* **[UI]** A package is cancelled (deleted) when the last flight in the package is cancelled instead of showing "No mission".
# 13.0.0
Saves from 12.x are not compatible with 13.0.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.12.5536.
* **[Data]** Support for CH-47 Chinook.
## Fixes
* **[Data]** Added/updated CBU weapons data.
* **[Data]** Added Strike mission type for KA-50 (all playable variants).
* **[Mission Generation]** Fixed crash when using factions that have Eastern callsign conventions.
* **[Mission Generation]** Fixed issues when spawning on carriers and FARPs in multiplayer sessions.
# 12.0.0
Saves from 11.x are not compatible with 12.0.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.9.2280.
* **[Campaign]** Flights are assigned different callsigns appropriate to the faction.
* **[Campaign]** Removed deprecated settings for generating persistent and invulnerable AWACs and tankers.
* **[Data]** Added ability to restrict weapons usage for a faction to a different year from the nominal weapon introduction year. Updated faction data to restrict more advanced missiles from Soviet client states during the cold war. Updated Egypt 2000 faction to restrict AIM-120 usage.
* **[Mission Generation]** Added option to skip combat when fast forwarding, which progresses fast forward as if the combat did not occur. Simplified fast forward settings by consolidating "Fast forward mission to first contact" and "Player missions interrupt fast forward" into a single setting and expanding options for "Auto-resolve combat during fast-forward (WIP)".
* **[Mods]** F/A-18 E/F/G Super Hornet mod version updated to 2.3.
## Fixes
* **[Data]** Added/updated weapons data for F4E weapons such as AIM-7, AIM-9, AGM-12, AGM-45 and AGM-65.
* **[Campaign]** Do not allow aircraft from a captured control point to retreat if the captured control point has a damaged runway.
* **[Campaign]** Do not allow ground units to be transferred to LHAs, CVNs or off map spawns.
* **[Mission Generation]** Fixed aircraft not spawning correctly on CVNs, LHAs and FARPs.
# 11.1.1
Saves from 11.0.0 are compatible with 11.1.1. See Known Issues section for exceptions.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.5.55918 including Heatblur F-4E and Polychop OH-58D Kiowa Warrior support.
## Fixes
* **[Campaign]** Fixed double counting of parked aircraft kills when DCS reports multiple kill events.
* **[Campaign]** Fixed error where frontline units are not re-deployed when multiple control points were captured in one turn or when control points are captured "out of order" using air-assault missions.
* **[Cheat Menu]** Re-deploy frontline units when using cheats to capture control points, so that cheats behave the same way as capturing a control point in-mission.
* **[Data]** Added FuSe-65 Early Warning Radar.
* **[Data]** Updated Peru 1995 and Germany 1944 factions.
* **[Flight Planning]** Theater refuelling flight plans (those not tied to a particular package) will remain on station for a longer period, specifically the desired mission duration + 30 minutes. By default, this increases the on-station time from 1 hour to 1.5 hours.
* **[Mission Generation]** Patched bug where Liberation crashed when aborting a turn when Fighter Sweep missions were planned.
* **[Radios]** Added radio setup for F-5E, F-86, Mi-8 and Mi-24.
* **[UI]** Naval control points (carriers, LHAs) can no longer be moved onto land.
## Known Issues
* When loading saves from 11.0, loadouts with AGM-45B (Imp), typically on A-4E-C mod, will have the AGM-45B replaced with an empty pylon due to changes in DCS for this weapon. The AGM-45A is not affected by this issue.
# 11.0.0
Saves from 10.x are not compatible with 11.0.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.3.51704.
* **[Campaign]** Improved tracking of parked aircraft deaths. Parked aircraft are now considered dead once sufficient damage is done, meaning guns, rockets and AGMs are viable weapons for OCA/Aircraft missions. Previously Liberation relied on DCS death tracking which required parked aircraft to be hit with more powerful weapons e.g. 2000lb bombs as they were uncontrolled.
* **[Campaign]** Track damage to theater ground objects across turns. Damage can accumulate across turns leading to death of the unit. This behavior only applies to SAMs, ships and other units that appear on the Liberation map. Frontline units and buildings are not tracked (yet).
* **[Mods]** F/A-18 E/F/G Super Hornet mod (v2.2.5) support added.
## Fixes
* **[Mission Generation]** When planning anti-ship missions against carriers or LHAs, target escorts (if present) if the carrier/LHA is sunk.
* **[UI]** Identify that a carrier or LHA is sunk instead of "damaged".
# 10.0.0
Saves from 9.x are not compatible with 10.0.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.9.2.49629 Open Beta. (F-15E JDAM and JSOW, F-16 AIM-9P, updated Falklands and Normandy airfields).
* **[UI]** Improved the description of "runway" state for FARPs, FOBs, carriers, and off-map spawns.
## Fixes
* **[Flight Planning]** Aircraft from even numbered flights will no longer become inaccessible when canceling a draft package.
* **[UI]** Flight members in the loadout menu are now numbered starting from 1 instead of 0.
* **[UI]** Flight plan paths are now drawn behind all other map elements, fixing rare cases where they could prevent other UI elements from being clickable.
# 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 package" 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

4182
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/react-redux": "^7.1.24",
"axios": "^1.8.3",
"axios": "^1.1.2",
"electron-window-state": "^5.0.3",
"esri-leaflet": "^3.0.8",
"leaflet": "^1.9.2",
@@ -62,26 +62,25 @@
},
"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": "^28.3.2",
"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": "^2.10.4",
"react-scripts": "5.0.1",
"ts-node": "^10.9.1",
"wait-on": "^8.0.0"
"wait-on": "^6.0.1"
},
"jest": {
"transformIgnorePatterns": [
"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"
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 },

View File

@@ -1,8 +1,7 @@
import { Flight } from "../../api/liberationApi";
import { useGetCommitBoundaryForFlightQuery } from "../../api/liberationApi";
import WaypointMarker from "../waypointmarker";
import { Polyline as LPolyline } from "leaflet";
import { ReactElement, useEffect, useRef } from "react";
import { ReactElement } from "react";
import { Polyline } from "react-leaflet";
const BLUE_PATH = "#0084ff";
@@ -28,41 +27,16 @@ const pathColor = (props: FlightPlanProps) => {
function FlightPlanPath(props: FlightPlanProps) {
const color = pathColor(props);
const waypoints = props.flight.waypoints;
const polylineRef = useRef<LPolyline | null>(null);
// Flight paths should be drawn under everything else. There seems to be an
// issue where `interactive: false` doesn't do as its told (there's nuance,
// see the bug for details). It looks better if we draw the other elements on
// top of the flight plans anyway, so just push the flight plan to the back.
//
// https://github.com/dcs-liberation/dcs_liberation/issues/3295
//
// It's not possible to z-index a polyline (and leaflet says it never will be,
// because this is a limitation of SVG, not leaflet:
// https://github.com/Leaflet/Leaflet/issues/185), so we need to use
// bringToBack() to push the flight paths to the back of the drawing once
// they've been added to the map. They'll still draw on top of the map, but
// behind everything than was added before them. Anything added after always
// goes on top.
useEffect(() => {
if (!props.selected) {
polylineRef.current?.bringToBack();
}
});
if (waypoints == null) {
return <></>;
}
const points = waypoints
.filter((waypoint) => waypoint.include_in_path)
.map((waypoint) => waypoint.position);
return (
<Polyline
positions={points}
pathOptions={{ color: color, interactive: false }}
ref={polylineRef}
/>
);
}

View File

@@ -95,12 +95,8 @@ describe("FlightPlansLayer", () => {
},
},
});
// For some reason passing ref to PolyLine causes it and its group to be
// redrawn, so these numbers don't match what you'd expect from the test.
// It probably needs to be rewritten without mocks.
expect(mockPolyline).toHaveBeenCalledTimes(3);
expect(mockLayerGroup).toBeCalledTimes(2);
expect(mockPolyline).toHaveBeenCalledTimes(2);
expect(mockLayerGroup).toBeCalledTimes(1);
});
it("are not drawn if wrong coalition", () => {
renderWithProviders(<FlightPlansLayer blue={true} />, {

View 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>
);
}

View File

@@ -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>

View File

@@ -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 });
});
});

View File

@@ -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;

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -7,9 +7,9 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "DCS Liberation"
copyright = "2025, DCS Liberation Team"
copyright = "2023, DCS Liberation Team"
author = "DCS Liberation Team"
release = "14.0.0"
release = "8.1.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@@ -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,

View File

@@ -1,5 +1,4 @@
"""Objective adjacency lists."""
from __future__ import annotations
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING

View File

@@ -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,
@@ -21,15 +20,12 @@ from ..sidc import (
)
if TYPE_CHECKING:
from game.callsigns.callsigngenerator import Callsign
from game.dcs.aircrafttype import AircraftType
from game.sim.gameupdateevents import GameUpdateEvents
from game.sim.simulationresults import SimulationResults
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
@@ -50,29 +46,27 @@ class Flight(SidcDescribable):
custom_name: Optional[str] = None,
cargo: Optional[TransferOrder] = None,
roster: Optional[FlightRoster] = None,
callsign: Optional[Callsign] = None,
) -> None:
self.id = uuid.uuid4()
self.package = package
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
self.callsign = callsign
# Flight properties that can be set in the mission editor. This is used for
# things like HMD selection, ripple quantity, etc. Any values set here will take
# the place of the defaults defined by DCS.
@@ -101,13 +95,19 @@ 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()
# Avoid persisting the flight state since that's not (currently) used outside
# mission generation. This is a bit of a hack for the moment and in the future
# we will need to persist the flight state, but for now keep it out of save
# compat (it also contains a generator that cannot be pickled).
del state["state"]
return state
def __setstate__(self, state: dict[str, Any]) -> None:
state["state"] = Uninitialized(self, state["squadron"].settings)
self.__dict__.update(state)
@property
def blue(self) -> bool:
@@ -149,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:]
@@ -167,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
@@ -195,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}"
@@ -260,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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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())

View File

@@ -88,7 +88,7 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
raise PlanningError("Air assault is only usable by helicopters")
assert self.package.waypoints is not None
altitude = self.doctrine.helicopter.air_assault_nav_altitude
altitude = feet(1500) if self.flight.is_helo else self.doctrine.ingress_altitude
altitude_is_agl = self.flight.is_helo
builder = WaypointBuilder(self.flight, self.coalition)
@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -19,7 +19,7 @@ class BarCapFlightPlan(PatrollingFlightPlan[PatrollingLayout]):
@property
def patrol_duration(self) -> timedelta:
return self.flight.coalition.doctrine.cap.duration
return self.flight.coalition.doctrine.cap_duration
@property
def patrol_speed(self) -> Speed:
@@ -29,7 +29,7 @@ class BarCapFlightPlan(PatrollingFlightPlan[PatrollingLayout]):
@property
def engagement_distance(self) -> Distance:
return self.flight.coalition.doctrine.cap.engagement_range
return self.flight.coalition.doctrine.cap_engagement_range
class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
@@ -44,8 +44,8 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.cap.min_patrol_altitude,
min(self.doctrine.cap.max_patrol_altitude, randomized_alt),
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
builder = WaypointBuilder(self.flight, self.coalition)
@@ -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())

View File

@@ -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)
@@ -90,10 +51,10 @@ class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
# buffer.
distance_to_no_fly = (
meters(position.distance(self.threat_zones.all))
- self.doctrine.cap.engagement_range
- self.doctrine.cap_engagement_range
- nautical_miles(5)
)
max_track_length = self.doctrine.cap.max_track_length
max_track_length = self.doctrine.cap_max_track_length
else:
# Other race tracks (TARCAPs, currently) just try to keep some
# distance from the nearest enemy airbase, but since they are by
@@ -108,15 +69,15 @@ class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
distance_to_no_fly = distance_to_airfield - min_distance_from_enemy
# TARCAPs fly short racetracks because they need to react faster.
max_track_length = self.doctrine.cap.min_track_length + 0.3 * (
self.doctrine.cap.max_track_length - self.doctrine.cap.min_track_length
max_track_length = self.doctrine.cap_min_track_length + 0.3 * (
self.doctrine.cap_max_track_length - self.doctrine.cap_min_track_length
)
min_cap_distance = min(
self.doctrine.cap.min_distance_from_cp, distance_to_no_fly
self.doctrine.cap_min_distance_from_cp, distance_to_no_fly
)
max_cap_distance = min(
self.doctrine.cap.max_distance_from_cp, distance_to_no_fly
self.doctrine.cap_max_distance_from_cp, distance_to_no_fly
)
end = location.position.point_from_heading(
@@ -125,7 +86,7 @@ class CapBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
)
track_length = random.randint(
int(self.doctrine.cap.min_track_length.meters),
int(self.doctrine.cap_min_track_length.meters),
int(max_track_length.meters),
)
start = end.point_from_heading(heading.opposite.degrees, track_length)

View File

@@ -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
@@ -44,7 +42,7 @@ class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
@property
def patrol_duration(self) -> timedelta:
return self.flight.coalition.doctrine.cas.duration
return self.flight.coalition.doctrine.cas_duration
@property
def patrol_speed(self) -> Speed:
@@ -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.resolve_combat_altitude(is_helo)
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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -33,7 +33,7 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
departure=builder.takeoff(self.flight.departure),
hold=hold,
nav_to=builder.nav_path(
hold.position, join.position, self.doctrine.combat_altitude
hold.position, join.position, self.doctrine.ingress_altitude
),
join=join,
ingress=ingress,
@@ -43,12 +43,12 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
nav_from=builder.nav_path(
refuel.position,
self.flight.arrival.position,
self.doctrine.combat_altitude,
self.doctrine.ingress_altitude,
),
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
)
def build(self, dump_debug_info: bool = False) -> EscortFlightPlan:
def build(self) -> EscortFlightPlan:
return EscortFlightPlan(self.flight, self.layout())

View File

@@ -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())

View File

@@ -5,7 +5,6 @@ MissionPlanner. Those only plan basic information like the objective, aircraft
type, and the size of the flight. The FlightPlanBuilder is responsible for
generating the waypoints for the mission.
"""
from __future__ import annotations
import math
@@ -13,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
@@ -20,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
@@ -31,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):
@@ -55,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
@@ -144,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.
@@ -172,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
@@ -181,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
@@ -206,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)
@@ -249,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)
@@ -270,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
@@ -284,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

View File

@@ -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,18 +25,22 @@ 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]: ...
def package_speed_waypoints(self) -> set[FlightWaypoint]:
...
@property
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.
@@ -68,11 +73,13 @@ class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
@property
@abstractmethod
def join_time(self) -> datetime: ...
def join_time(self) -> datetime:
...
@property
@abstractmethod
def split_time(self) -> datetime: ...
def split_time(self) -> datetime:
...
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
if waypoint == self.layout.join:
@@ -83,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
@@ -98,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

View File

@@ -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,35 +169,26 @@ 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(
departure=builder.takeoff(self.flight.departure),
hold=hold,
nav_to=builder.nav_path(
hold.position, join.position, self.doctrine.combat_altitude
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,
nav_from=builder.nav_path(
refuel.position,
self.flight.arrival.position,
self.doctrine.combat_altitude,
self.doctrine.ingress_altitude,
),
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),

View File

@@ -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,12 @@ 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
def package(self) -> Package:

View File

@@ -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,43 +18,29 @@ 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)
@property
@abstractmethod
def push_time(self) -> datetime: ...
def push_time(self) -> datetime:
...
def depart_time_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
if waypoint == self.layout.hold:
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))

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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:

View File

@@ -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())

View File

@@ -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())

View File

@@ -65,6 +65,7 @@ class RecoveryTankerFlightPlan(StandardFlightPlan[RecoveryTankerLayout]):
class Builder(IBuilder[RecoveryTankerFlightPlan, RecoveryTankerLayout]):
def layout(self) -> RecoveryTankerLayout:
builder = WaypointBuilder(self.flight, self.coalition)
# TODO: Propagate the ship position to the Tanker's TOT,
@@ -90,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())

View File

@@ -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())

View File

@@ -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:
@@ -114,11 +101,11 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
self.package.waypoints.join.heading_between_point(target)
)
start_pos = target.point_from_heading(
heading.degrees, -self.doctrine.sweep.distance.meters
heading.degrees, -self.doctrine.sweep_distance.meters
)
builder = WaypointBuilder(self.flight, self.coalition)
start, end = builder.sweep(start_pos, target, self.doctrine.combat_altitude)
start, end = builder.sweep(start_pos, target, self.doctrine.ingress_altitude)
hold = builder.hold(self._hold_point())
@@ -126,12 +113,12 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
departure=builder.takeoff(self.flight.departure),
hold=hold,
nav_to=builder.nav_path(
hold.position, start.position, self.doctrine.combat_altitude
hold.position, start.position, self.doctrine.ingress_altitude
),
nav_from=builder.nav_path(
end.position,
self.flight.arrival.position,
self.doctrine.combat_altitude,
self.doctrine.ingress_altitude,
),
sweep_start=start,
sweep_end=end,
@@ -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())

View File

@@ -40,7 +40,7 @@ class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]):
# flights in the package that have requested escort. If the package
# requests an escort the CAP self.flight will remain on station for the
# duration of the escorted mission, or until it is winchester/bingo.
return self.flight.coalition.doctrine.cap.duration
return self.flight.coalition.doctrine.cap_duration
@property
def patrol_speed(self) -> Speed:
@@ -50,7 +50,7 @@ class TarCapFlightPlan(PatrollingFlightPlan[TarCapLayout]):
@property
def engagement_distance(self) -> Distance:
return self.flight.coalition.doctrine.cap.engagement_range
return self.flight.coalition.doctrine.cap_engagement_range
@staticmethod
def builder_type() -> Type[Builder]:
@@ -90,8 +90,8 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
preferred_alt = self.flight.unit_type.preferred_patrol_altitude
randomized_alt = preferred_alt + feet(random.randint(-2, 1) * 1000)
patrol_alt = max(
self.doctrine.cap.min_patrol_altitude,
min(self.doctrine.cap.max_patrol_altitude, randomized_alt),
self.doctrine.min_patrol_altitude,
min(self.doctrine.max_patrol_altitude, randomized_alt),
)
builder = WaypointBuilder(self.flight, self.coalition)
@@ -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())

View File

@@ -17,13 +17,7 @@ class TheaterRefuelingFlightPlan(RefuelingFlightPlan):
@property
def patrol_duration(self) -> timedelta:
# Add 30 minutes to desired_player_mission_duration as TOTs for flights
# can sit up to this time. This extension means the tanker remains on
# station for the flights' return.
return (
self.flight.coalition.game.settings.desired_player_mission_duration
+ timedelta(minutes=30)
)
return timedelta(hours=1)
class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
@@ -85,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())

View File

@@ -14,4 +14,5 @@ class UiZone:
class UiZoneDisplay(abc.ABC):
@abc.abstractmethod
def ui_zone(self) -> UiZone: ...
def ui_zone(self) -> UiZone:
...

View File

@@ -72,7 +72,7 @@ class WaypointBuilder:
"NAV",
FlightWaypointType.NAV,
position,
self.doctrine.resolve_rendezvous_altitude(self.is_helo),
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
description="Enter theater",
pretty_name="Enter theater",
)
@@ -99,7 +99,7 @@ class WaypointBuilder:
"NAV",
FlightWaypointType.NAV,
position,
self.doctrine.resolve_rendezvous_altitude(self.is_helo),
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
description="Exit theater",
pretty_name="Exit theater",
)
@@ -127,7 +127,10 @@ class WaypointBuilder:
position = divert.position
altitude_type: AltitudeReference
if isinstance(divert, OffMapSpawn):
altitude = self.doctrine.resolve_rendezvous_altitude(self.is_helo)
if self.is_helo:
altitude = meters(500)
else:
altitude = self.doctrine.rendezvous_altitude
altitude_type = "BARO"
else:
altitude = meters(0)
@@ -165,7 +168,7 @@ class WaypointBuilder:
"HOLD",
FlightWaypointType.LOITER,
position,
self.doctrine.resolve_rendezvous_altitude(self.is_helo),
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
alt_type,
description="Wait until push time",
pretty_name="Hold",
@@ -180,7 +183,7 @@ class WaypointBuilder:
"JOIN",
FlightWaypointType.JOIN,
position,
self.doctrine.resolve_combat_altitude(self.is_helo),
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Rendezvous with package",
pretty_name="Join",
@@ -195,7 +198,7 @@ class WaypointBuilder:
"REFUEL",
FlightWaypointType.REFUEL,
position,
self.doctrine.resolve_combat_altitude(self.is_helo),
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Refuel from tanker",
pretty_name="Refuel",
@@ -223,7 +226,7 @@ class WaypointBuilder:
"SPLIT",
FlightWaypointType.SPLIT,
position,
self.doctrine.resolve_combat_altitude(self.is_helo),
meters(80) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Depart from package",
pretty_name="Split",
@@ -243,13 +246,28 @@ class WaypointBuilder:
"INGRESS",
ingress_type,
position,
self.doctrine.resolve_combat_altitude(self.is_helo),
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description=f"INGRESS on {objective.name}",
pretty_name=f"INGRESS on {objective.name}",
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}")
@@ -288,7 +306,7 @@ class WaypointBuilder:
f"SEAD on {target.name}",
target,
flyover=True,
altitude=self.doctrine.combat_altitude,
altitude=self.doctrine.ingress_altitude,
alt_type="BARO",
)
@@ -339,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.
@@ -478,7 +507,7 @@ class WaypointBuilder:
"TARGET",
FlightWaypointType.TARGET_GROUP_LOC,
target.position,
self.doctrine.resolve_combat_altitude(self.is_helo),
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description="Escort the package",
pretty_name="Target area",

View File

@@ -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(

View File

@@ -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)

View File

@@ -21,14 +21,8 @@ class FlightState(ABC):
self.settings = settings
self.avoid_further_combat = False
def initialize(self, now: datetime) -> None:
from game.ato.flightstate import Uninitialized, WaitingForStart
# Flight objects are created with Uninitialized state. However when the simulation runs
# the flight state changes and may be serialized. We only want to initialize the state
# for newly created flights and not ones deserialized from a save file.
if type(self.flight.state) != Uninitialized:
return
def reinitialize(self, now: datetime) -> None:
from game.ato.flightstate import WaitingForStart
if self.flight.flight_plan.startup_time() <= now:
self._set_active_flight_state(now)
@@ -63,12 +57,14 @@ class FlightState(ABC):
@property
@abstractmethod
def cancelable(self) -> bool: ...
def cancelable(self) -> bool:
...
@abstractmethod
def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None: ...
) -> None:
...
@property
def in_flight(self) -> bool:
@@ -99,14 +95,17 @@ class FlightState(ABC):
@property
@abstractmethod
def is_waiting_for_start(self) -> bool: ...
def is_waiting_for_start(self) -> bool:
...
@abstractmethod
def estimate_position(self) -> Point: ...
def estimate_position(self) -> Point:
...
@property
@abstractmethod
def spawn_type(self) -> StartType: ...
def spawn_type(self) -> StartType:
...
def a2a_commit_region(self) -> Optional[ThreatPoly]:
return None

View File

@@ -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,18 +51,29 @@ 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: ...
def estimate_position(self) -> Point:
...
@abstractmethod
def estimate_altitude(self) -> tuple[Distance, str]: ...
def estimate_altitude(self) -> tuple[Distance, str]:
...
@abstractmethod
def estimate_speed(self) -> Speed: ...
def estimate_speed(self) -> Speed:
...
def estimate_fuel_at_current_waypoint(self) -> float:
initial_fuel = super().estimate_fuel()
@@ -88,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
@@ -96,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:
@@ -107,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()
@@ -164,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}"

View 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}"

View File

@@ -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}"

View File

@@ -8,9 +8,6 @@ from .atdeparture import AtDeparture
from .taxi import Taxi
from ..starttype import StartType
from game.settings.settings import FastForwardStopCondition
if TYPE_CHECKING:
from game.ato.flight import Flight
from game.settings import Settings
@@ -40,8 +37,7 @@ class StartUp(AtDeparture):
def should_halt_sim(self) -> bool:
if (
self.flight.client_count > 0
and self.settings.fast_forward_stop_condition
== FastForwardStopCondition.PLAYER_STARTUP
and self.settings.player_mission_interrupts_sim_at is StartType.COLD
):
logging.info(
f"Interrupting simulation because {self.flight} has players and has "

View File

@@ -9,8 +9,6 @@ from .navigating import Navigating
from ..starttype import StartType
from ...utils import LBS_TO_KG
from game.settings.settings import FastForwardStopCondition
if TYPE_CHECKING:
from game.ato.flight import Flight
from game.settings import Settings
@@ -47,8 +45,7 @@ class Takeoff(AtDeparture):
def should_halt_sim(self) -> bool:
if (
self.flight.client_count > 0
and self.settings.fast_forward_stop_condition
== FastForwardStopCondition.PLAYER_TAKEOFF
and self.settings.player_mission_interrupts_sim_at is StartType.RUNWAY
):
logging.info(
f"Interrupting simulation because {self.flight} has players and has "

View File

@@ -8,8 +8,6 @@ from .atdeparture import AtDeparture
from .takeoff import Takeoff
from ..starttype import StartType
from game.settings.settings import FastForwardStopCondition
if TYPE_CHECKING:
from game.ato.flight import Flight
from game.settings import Settings
@@ -39,8 +37,7 @@ class Taxi(AtDeparture):
def should_halt_sim(self) -> bool:
if (
self.flight.client_count > 0
and self.settings.fast_forward_stop_condition
== FastForwardStopCondition.PLAYER_TAXI
and self.settings.player_mission_interrupts_sim_at is StartType.WARM
):
logging.info(
f"Interrupting simulation because {self.flight} has players and has "

View File

@@ -20,7 +20,7 @@ class Uninitialized(FlightState):
def on_game_tick(
self, events: GameUpdateEvents, time: datetime, duration: timedelta
) -> None:
self.initialize(time)
self.reinitialize(time)
self.flight.state.on_game_tick(events, time, duration)
@property

View File

@@ -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

View File

@@ -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

View File

@@ -1,28 +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: ...

View File

@@ -1,17 +1,14 @@
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
from game.data.weapons import Pylon, Weapon, WeaponType
from game.dcs.aircrafttype import AircraftType
from game.factions.faction import Faction
from .flighttype import FlightType
if TYPE_CHECKING:
@@ -38,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:
@@ -54,7 +46,6 @@ class Loadout:
weapon: Weapon,
pylon: Pylon,
date: datetime.date,
faction: Faction,
skip_types: Optional[Iterable[WeaponType]] = None,
) -> Optional[Weapon]:
if skip_types is None:
@@ -62,16 +53,14 @@ class Loadout:
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date, faction):
if not fallback.available_on(date):
continue
if fallback.weapon_group.type in skip_types:
continue
return fallback
return None
def degrade_for_date(
self, unit_type: AircraftType, date: datetime.date, faction: Faction
) -> Loadout:
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
if self.date is not None and self.date <= date:
return Loadout(self.name, self.pylons, self.date, self.is_custom)
@@ -80,9 +69,9 @@ class Loadout:
if weapon is None:
del new_pylons[pylon_number]
continue
if not weapon.available_on(date, faction):
if not weapon.available_on(date):
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(weapon, pylon, date, faction)
fallback = self._fallback_for(weapon, pylon, date)
if fallback is None:
del new_pylons[pylon_number]
else:
@@ -94,11 +83,11 @@ class Loadout:
# If the loadout was chosen explicitly by the user, assume they know what
# they're doing. They may be coordinating buddy-lase.
if not loadout.is_custom:
loadout.replace_lgbs_if_no_tgp(unit_type, date, faction)
loadout.replace_lgbs_if_no_tgp(unit_type, date)
return loadout
def replace_lgbs_if_no_tgp(
self, unit_type: AircraftType, date: datetime.date, faction: Faction
self, unit_type: AircraftType, date: datetime.date
) -> None:
if self.has_weapon_of_type(WeaponType.TGP):
return
@@ -111,7 +100,7 @@ class Loadout:
if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(
weapon, pylon, date, faction, skip_types={WeaponType.LGB}
weapon, pylon, date, skip_types={WeaponType.LGB}
)
if fallback is None:
del new_pylons[pylon_number]
@@ -119,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)
@@ -154,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
@@ -234,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",

View File

@@ -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
View 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

View File

@@ -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
@@ -18,9 +26,6 @@ class GroundSpeed:
# on fuel, but mission speed will be fast enough to keep the flight
# safer.
if flight.squadron.aircraft.cruise_speed is not None:
return mach(flight.squadron.aircraft.cruise_speed.mach(), altitude)
# DCS's max speed is in kph at 0 MSL.
max_speed = flight.unit_type.max_speed
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL:
@@ -37,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:

View File

@@ -1,5 +1,4 @@
"""Support for working with DCS group callsigns."""
import logging
import re
from typing import Any

View File

@@ -1,253 +0,0 @@
from __future__ import annotations
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum
from collections import deque
from typing import Any, List, Optional
from dcs.country import Country
from dcs.countries import countries_by_name
from game.ato.flight import Flight
from game.ato.flighttype import FlightType
MAX_GROUP_ID = 99
class CallsignCategory(StrEnum):
AIR = "Air"
TANKERS = "Tankers"
AWACS = "AWACS"
GROUND_UNITS = "GroundUnits"
HELIPADS = "Helipad"
GRASS_AIRFIELDS = "GrassAirfield"
@dataclass(frozen=True)
class Callsign:
name: Optional[
str
] # Callsign name e.g. "Enfield" for western callsigns. None for eastern callsigns.
group_id: int # ID of the group e.g. 2 in Enfield-2-3 for western callsigns. First two digits of eastern callsigns.
unit_id: int # ID of the unit e.g. 3 in Enfield-2-3 for western callsigns. Last digit of eastern callsigns.
def __post_init__(self) -> None:
if self.group_id < 1 or self.group_id > MAX_GROUP_ID:
raise ValueError(
f"Invalid group ID {self.group_id}. Group IDs have to be between 1 and {MAX_GROUP_ID}."
)
if self.unit_id < 1 or self.unit_id > 9:
raise ValueError(
f"Invalid unit ID {self.unit_id}. Unit IDs have to be between 1 and 9."
)
def __str__(self) -> str:
if self.name is not None:
return f"{self.name}{self.group_id}{self.unit_id}"
else:
return str(self.group_id * 10 + self.unit_id)
def lead_callsign(self) -> Callsign:
return Callsign(self.name, self.group_id, 1)
def unit_callsign(self, unit_id: int) -> Callsign:
return Callsign(self.name, self.group_id, unit_id)
def group_name(self) -> str:
if self.name is not None:
return f"{self.name}-{self.group_id}"
else:
return str(self.lead_callsign())
def pydcs_dict(self, country: str) -> dict[Any, Any]:
country_obj = countries_by_name[country]()
for category in CallsignCategory:
if category in country_obj.callsign:
for index, name in enumerate(country_obj.callsign[category]):
if name == self.name:
return {
"name": str(self),
1: index + 1,
2: self.group_id,
3: self.unit_id,
}
raise ValueError(f"Could not find callsign {name} in {country}.")
class WesternGroupIdRegistry:
def __init__(self, country: Country, max_group_id: int = MAX_GROUP_ID):
self._names: dict[str, deque[int]] = {}
for category in CallsignCategory:
if category in country.callsign:
for name in country.callsign[category]:
self._names[name] = deque()
self._max_group_id = max_group_id
self.reset()
def reset(self) -> None:
for name in self._names:
self._names[name] = deque()
for i in range(
self._max_group_id, 0, -1
): # Put group IDs on FIFO queue so 1 gets popped first
self._names[name].appendleft(i)
def alloc_group_id(self, name: str) -> int:
return self._names[name].popleft()
def release_group_id(self, callsign: Callsign) -> None:
if callsign.name is None:
raise ValueError("Releasing eastern callsign")
self._names[callsign.name].appendleft(callsign.group_id)
class EasternGroupIdRegistry:
def __init__(self, max_group_id: int = MAX_GROUP_ID):
self._max_group_id = max_group_id
self._queue: deque[int] = deque()
self.reset()
def reset(self) -> None:
self._queue = deque()
for i in range(
self._max_group_id, 0, -1
): # Put group IDs on FIFO queue so 1 gets popped first
self._queue.appendleft(i)
def alloc_group_id(self) -> int:
return self._queue.popleft()
def release_group_id(self, callsign: Callsign) -> None:
self._queue.appendleft(callsign.group_id)
class RoundRobinNameAllocator:
def __init__(self, names: List[str]):
self.names = names
self._index = 0
def allocate(self) -> str:
this_index = self._index
if this_index == len(self.names) - 1:
self._index = 0
else:
self._index += 1
return self.names[this_index]
class FlightTypeNameAllocator:
def __init__(self, names: List[str]):
self.names = names
def allocate(self, flight: Flight) -> str:
index = self.FLIGHT_TYPE_LOOKUP.get(flight.flight_type, 0)
return self.names[index]
FLIGHT_TYPE_LOOKUP: dict[FlightType, int] = {
FlightType.TARCAP: 1,
FlightType.BARCAP: 1,
FlightType.INTERCEPTION: 1,
FlightType.SWEEP: 1,
FlightType.CAS: 2,
FlightType.ANTISHIP: 2,
FlightType.BAI: 2,
FlightType.STRIKE: 3,
FlightType.OCA_RUNWAY: 3,
FlightType.OCA_AIRCRAFT: 3,
FlightType.SEAD: 4,
FlightType.DEAD: 4,
FlightType.ESCORT: 5,
FlightType.AIR_ASSAULT: 6,
FlightType.TRANSPORT: 7,
FlightType.FERRY: 7,
}
class WesternFlightCallsignGenerator:
"""Generate western callsign for lead unit in a group"""
def __init__(self, country: str) -> None:
self._country = countries_by_name[country]()
self._group_id_registry = WesternGroupIdRegistry(self._country)
self._awacs_name_allocator = None
self._tankers_name_allocator = None
if CallsignCategory.AWACS in self._country.callsign:
self._awacs_name_allocator = RoundRobinNameAllocator(
self._country.callsign[CallsignCategory.AWACS]
)
if CallsignCategory.TANKERS in self._country.callsign:
self._tankers_name_allocator = RoundRobinNameAllocator(
self._country.callsign[CallsignCategory.TANKERS]
)
self._air_name_allocator = FlightTypeNameAllocator(
self._country.callsign[CallsignCategory.AIR]
)
def reset(self) -> None:
self._group_id_registry.reset()
def alloc_callsign(self, flight: Flight) -> Callsign:
if flight.flight_type == FlightType.AEWC:
if self._awacs_name_allocator is None:
raise ValueError(f"{self._country.name} does not have AWACs callsigns")
name = self._awacs_name_allocator.allocate()
elif flight.flight_type == FlightType.REFUELING:
if self._tankers_name_allocator is None:
raise ValueError(f"{self._country.name} does not have tanker callsigns")
name = self._tankers_name_allocator.allocate()
else:
name = self._air_name_allocator.allocate(flight)
group_id = self._group_id_registry.alloc_group_id(name)
return Callsign(name, group_id, 1)
def release_callsign(self, callsign: Callsign) -> None:
self._group_id_registry.release_group_id(callsign)
class EasternFlightCallsignGenerator:
"""Generate eastern callsign for lead unit in a group"""
def __init__(self) -> None:
self._group_id_registry = EasternGroupIdRegistry()
def reset(self) -> None:
self._group_id_registry.reset()
def alloc_callsign(self, flight: Flight) -> Callsign:
group_id = self._group_id_registry.alloc_group_id()
return Callsign(None, group_id, 1)
def release_callsign(self, callsign: Callsign) -> None:
self._group_id_registry.release_group_id(callsign)
class FlightCallsignGenerator:
def __init__(self, country: str):
self._use_western_callsigns = countries_by_name[country]().use_western_callsigns
self._generators: dict[
bool, WesternFlightCallsignGenerator | EasternFlightCallsignGenerator
] = {}
if self._use_western_callsigns:
self._generators[self._use_western_callsigns] = (
WesternFlightCallsignGenerator(country)
)
else:
self._generators[self._use_western_callsigns] = (
EasternFlightCallsignGenerator()
)
def reset(self) -> None:
self._generators[self._use_western_callsigns].reset()
def alloc_callsign(self, flight: Flight) -> Callsign:
return self._generators[self._use_western_callsigns].alloc_callsign(flight)
def release_callsign(self, callsign: Callsign) -> None:
self._generators[self._use_western_callsigns].release_callsign(callsign)

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -29,6 +29,7 @@ class DefaultSquadronAssigner:
self.coalition.player
):
for squadron_config in self.config.by_location[control_point]:
squadron_def = self.override_squadron_defaults(
self.find_squadron_for(squadron_config, control_point),
squadron_config,
@@ -161,6 +162,7 @@ class DefaultSquadronAssigner:
def override_squadron_defaults(
squadron_def: Optional[SquadronDef], config: SquadronConfig
) -> Optional[SquadronDef]:
if squadron_def is None:
return None

View File

@@ -14,10 +14,12 @@ class FactionRecommendation(ABC):
self.name = name
@abstractmethod
def register_campaign_specific_faction(self, factions: Factions) -> None: ...
def register_campaign_specific_faction(self, factions: Factions) -> None:
...
@abstractmethod
def get_faction(self, factions: Factions) -> Faction: ...
def get_faction(self, factions: Factions) -> Faction:
...
@staticmethod
def from_field(

View File

@@ -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:
@@ -61,8 +67,6 @@ class MizCampaignLoader:
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence.X_5p73_s_125_ln.id,
AirDefence.NASAMS_LN_B.id,
AirDefence.NASAMS_LN_C.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
@@ -88,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))
@@ -107,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
@@ -233,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

View File

@@ -7,7 +7,6 @@ from faker import Faker
from game.armedforces.armedforces import ArmedForces
from game.ato.airtaaskingorder import AirTaskingOrder
from game.callsigns.callsigngenerator import FlightCallsignGenerator
from game.campaignloader.defaultsquadronassigner import DefaultSquadronAssigner
from game.commander import TheaterCommander
from game.commander.missionscheduler import MissionScheduler
@@ -27,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
@@ -47,7 +45,6 @@ class Coalition:
self.air_wing = AirWing(player, game, self.faction)
self.armed_forces = ArmedForces(self.faction)
self.transfers = PendingTransfers(game, player)
self.callsign_generator = FlightCallsignGenerator(faction.country)
# Late initialized because the two coalitions in the game are mutually
# dependent, so must be both constructed before this property can be set.
@@ -93,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
@@ -165,14 +158,12 @@ class Coalition:
# is handled correctly.
self.transfers.perform_transfers()
self.callsign_generator.reset()
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.
@@ -193,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()

View File

@@ -25,7 +25,6 @@ from game.utils import meters, nautical_miles
if TYPE_CHECKING:
from game import Game
from game.transfers import CargoShip, Convoy
from game.threatzones import ThreatZones
MissionTargetType = TypeVar("MissionTargetType", bound=MissionTarget)
@@ -194,36 +193,17 @@ class ObjectiveFinder:
def farthest_friendly_control_point(self) -> ControlPoint:
"""Finds the friendly control point that is farthest from any threats."""
def find_farthest(
control_points: Iterator[ControlPoint],
threat_zones: ThreatZones,
consider_off_map_spawn: bool,
) -> ControlPoint | None:
farthest = None
max_distance = meters(0)
for cp in control_points:
if isinstance(cp, OffMapSpawn) and not consider_off_map_spawn:
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance > max_distance:
farthest = cp
max_distance = distance
return farthest
threat_zones = self.game.threat_zone_for(not self.is_player)
farthest = find_farthest(
self.friendly_control_points(), threat_zones, consider_off_map_spawn=False
)
# If there are only off-map spawn control points, fall back to the farthest amongst off map spawn points
if farthest is None:
farthest = find_farthest(
self.friendly_control_points(),
threat_zones,
consider_off_map_spawn=True,
)
farthest = None
max_distance = meters(0)
for cp in self.friendly_control_points():
if isinstance(cp, OffMapSpawn):
continue
distance = threat_zones.distance_to_threat(cp.position)
if distance > max_distance:
farthest = cp
max_distance = distance
if farthest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from game.callsigns.callsigngenerator import FlightCallsignGenerator
from game.theater import ControlPoint, MissionTarget, OffMapSpawn
from game.utils import nautical_miles
from ..ato.flight import Flight
@@ -11,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
@@ -26,8 +24,6 @@ class PackageBuilder:
location: MissionTarget,
closest_airfields: ClosestAirfields,
air_wing: AirWing,
laser_code_registry: LaserCodeRegistry,
callsign_generator: FlightCallsignGenerator,
flight_db: Database[Flight],
is_player: bool,
package_country: str,
@@ -39,8 +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.callsign_generator = callsign_generator
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
@@ -69,12 +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()
)
flight.callsign = self.callsign_generator.alloc_callsign(flight)
self.package.add_flight(flight)
return True
@@ -104,6 +92,4 @@ class PackageBuilder:
"""Returns any planned flights to the inventory."""
flights = list(self.package.flights)
for flight in flights:
if flight.callsign is not None:
self.callsign_generator.release_callsign(flight.callsign)
self.package.remove_flight(flight)

View File

@@ -141,8 +141,6 @@ class PackageFulfiller:
mission.location,
ObjectiveDistanceCache.get_closest_airfields(mission.location),
self.air_wing,
self.coalition.laser_code_registry,
self.coalition.callsign_generator,
self.flight_db,
self.is_player,
self.coalition.country_name,

View File

@@ -21,7 +21,8 @@ class FrontLineStanceTask(TheaterCommanderTask, ABC):
@property
@abstractmethod
def stance(self) -> CombatStance: ...
def stance(self) -> CombatStance:
...
@staticmethod
def management_allowed(state: TheaterState) -> bool:
@@ -48,7 +49,8 @@ class FrontLineStanceTask(TheaterCommanderTask, ABC):
@property
@abstractmethod
def have_sufficient_front_line_advantage(self) -> bool: ...
def have_sufficient_front_line_advantage(self) -> bool:
...
@property
def ground_force_balance(self) -> float:

View File

@@ -54,7 +54,8 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
coalition.ato.add_package(self.package)
@abstractmethod
def propose_flights(self) -> None: ...
def propose_flights(self) -> None:
...
def propose_flight(
self,
@@ -117,9 +118,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
target_ranges: list[
tuple[Union[IadsGroundObject, NavalGroundObject], Distance]
] = []
all_iads: Iterator[Union[IadsGroundObject, NavalGroundObject]] = (
itertools.chain(state.enemy_air_defenses, state.enemy_ships)
)
all_iads: Iterator[
Union[IadsGroundObject, NavalGroundObject]
] = itertools.chain(state.enemy_air_defenses, state.enemy_ships)
for target in all_iads:
distance = meters(target.distance_to(self.target))
if range_type is RangeType.Detection:

View File

@@ -12,4 +12,5 @@ if TYPE_CHECKING:
class TheaterCommanderTask(PrimitiveTask[TheaterState]):
@abstractmethod
def execute(self, coalition: Coalition) -> None: ...
def execute(self, coalition: Coalition) -> None:
...

View File

@@ -52,7 +52,6 @@ even though it is a primitive task used by many other tasks.
https://en.wikipedia.org/wiki/Hierarchical_task_network
"""
from __future__ import annotations
from datetime import datetime

View File

@@ -158,7 +158,7 @@ class TheaterState(WorldState["TheaterState"]):
# Plan enough rounds of CAP that the target has coverage over the expected
# mission duration.
mission_duration = game.settings.desired_player_mission_duration.total_seconds()
barcap_duration = coalition.doctrine.cap.duration.total_seconds()
barcap_duration = coalition.doctrine.cap_duration.total_seconds()
barcap_rounds = math.ceil(mission_duration / barcap_duration)
refueling_targets: list[MissionTarget] = []

View File

@@ -1,12 +1,6 @@
from __future__ import annotations
from pathlib import Path
import yaml
from typing import Any, ClassVar, Optional
from dataclasses import dataclass
from datetime import timedelta
from dcs.task import OptAAMissileAttackRange
from game.data.units import UnitClass
from game.utils import Distance, feet, nautical_miles
@@ -21,123 +15,16 @@ class GroundUnitProcurementRatios:
except KeyError:
return 0.0
@staticmethod
def from_dict(data: dict[str, float]) -> GroundUnitProcurementRatios:
unit_class_enum_from_name = {unit.value: unit for unit in UnitClass}
r = {}
for unit_class in data:
if unit_class not in unit_class_enum_from_name:
raise ValueError(f"Could not find unit type {unit_class}")
r[unit_class_enum_from_name[unit_class]] = float(data[unit_class])
return GroundUnitProcurementRatios(r)
@dataclass
class Helicopter:
#: The altitude used for combat section of a flight, overrides the base combat_altitude parameter for helos
combat_altitude: Distance
#: The altitude used for forming up a pacakge. Overrides the base rendezvous_altitude parameter for helos
rendezvous_altitude: Distance
#: Altitude of the nav points (cruise section) of air assault missions.
air_assault_nav_altitude: Distance
@staticmethod
def from_dict(data: dict[str, Any]) -> Helicopter:
return Helicopter(
combat_altitude=feet(data["combat_altitude_ft_agl"]),
rendezvous_altitude=feet(data["rendezvous_altitude_ft_agl"]),
air_assault_nav_altitude=feet(data["air_assault_nav_altitude_ft_agl"]),
)
@dataclass
class Cas:
#: The duration that CAP flights will remain on-station.
duration: timedelta
@staticmethod
def from_dict(data: dict[str, Any]) -> Cas:
return Cas(duration=timedelta(minutes=data["duration_minutes"]))
@dataclass
class Sweep:
#: Length of the sweep / patrol leg
distance: Distance
@staticmethod
def from_dict(data: dict[str, Any]) -> Sweep:
return Sweep(
distance=nautical_miles(data["distance_nm"]),
)
@dataclass
class Cap:
#: The duration that CAP flights will remain on-station.
duration: timedelta
#: The minimum length of the CAP race track.
min_track_length: Distance
#: The maximum length of the CAP race track.
max_track_length: Distance
#: The minimum distance between the defended position and the *end* of the
#: CAP race track.
min_distance_from_cp: Distance
#: The maximum distance between the defended position and the *end* of the
#: CAP race track.
max_distance_from_cp: Distance
#: The engagement range of CAP flights. Any enemy aircraft within this range
#: of the CAP's current position will be engaged by the CAP.
engagement_range: Distance
#: Defines the range of altitudes CAP racetracks are planned at.
min_patrol_altitude: Distance
max_patrol_altitude: Distance
@staticmethod
def from_dict(data: dict[str, Any]) -> Cap:
return Cap(
duration=timedelta(minutes=data["duration_minutes"]),
min_track_length=nautical_miles(data["min_track_length_nm"]),
max_track_length=nautical_miles(data["max_track_length_nm"]),
min_distance_from_cp=nautical_miles(data["min_distance_from_cp_nm"]),
max_distance_from_cp=nautical_miles(data["max_distance_from_cp_nm"]),
engagement_range=nautical_miles(data["engagement_range_nm"]),
min_patrol_altitude=feet(data["min_patrol_altitude_ft_msl"]),
max_patrol_altitude=feet(data["max_patrol_altitude_ft_msl"]),
)
@dataclass(frozen=True)
class Tactics:
#: Air-to-air missile attack range options
air_to_air_missile_attack_range: Optional[OptAAMissileAttackRange.Values]
#: Air defence units evade ARMs
air_defence_evades_anti_radiation_missiles: bool
@staticmethod
def from_dict(data: dict[str, Any]) -> Tactics:
return Tactics(
air_to_air_missile_attack_range=None,
air_defence_evades_anti_radiation_missiles=data.get(
"air_defence_evades_anti_radiation_missiles", False
),
)
@dataclass(frozen=True)
class Doctrine:
#: Name of the doctrine, used to assign a doctrine in a faction.
name: str
cas: bool
cap: bool
sead: bool
strike: bool
antiship: bool
rendezvous_altitude: Distance
#: The minimum distance between the departure airfield and the hold point.
hold_distance: Distance
@@ -157,91 +44,146 @@ class Doctrine:
#: target.
min_ingress_distance: Distance
#: The altitude used for combat section of a flight.
combat_altitude: Distance
ingress_altitude: Distance
#: The altitude used for forming up a pacakge.
rendezvous_altitude: Distance
min_patrol_altitude: Distance
max_patrol_altitude: Distance
pattern_altitude: Distance
#: The duration that CAP flights will remain on-station.
cap_duration: timedelta
#: The minimum length of the CAP race track.
cap_min_track_length: Distance
#: The maximum length of the CAP race track.
cap_max_track_length: Distance
#: The minimum distance between the defended position and the *end* of the
#: CAP race track.
cap_min_distance_from_cp: Distance
#: The maximum distance between the defended position and the *end* of the
#: CAP race track.
cap_max_distance_from_cp: Distance
#: The engagement range of CAP flights. Any enemy aircraft within this range
#: of the CAP's current position will be engaged by the CAP.
cap_engagement_range: Distance
cas_duration: timedelta
sweep_distance: Distance
#: Defines prioritization of ground unit purchases.
ground_unit_procurement_ratios: GroundUnitProcurementRatios
#: Helicopter specific doctrines.
helicopter: Helicopter
#: Doctrine for CAS missions.
cas: Cas
MODERN_DOCTRINE = Doctrine(
cap=True,
cas=True,
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(25),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
max_ingress_distance=nautical_miles(45),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(15),
cap_max_track_length=nautical_miles(40),
cap_min_distance_from_cp=nautical_miles(10),
cap_max_distance_from_cp=nautical_miles(40),
cap_engagement_range=nautical_miles(50),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(60),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
UnitClass.TANK: 3,
UnitClass.ATGM: 2,
UnitClass.APC: 2,
UnitClass.IFV: 3,
UnitClass.ARTILLERY: 1,
UnitClass.SHORAD: 2,
UnitClass.RECON: 1,
}
),
)
#: Doctrine for CAP missions.
cap: Cap
COLDWAR_DOCTRINE = Doctrine(
cap=True,
cas=True,
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(12),
cap_max_track_length=nautical_miles(24),
cap_min_distance_from_cp=nautical_miles(8),
cap_max_distance_from_cp=nautical_miles(25),
cap_engagement_range=nautical_miles(35),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(40),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
UnitClass.TANK: 4,
UnitClass.ATGM: 2,
UnitClass.APC: 3,
UnitClass.IFV: 2,
UnitClass.ARTILLERY: 1,
UnitClass.SHORAD: 2,
UnitClass.RECON: 1,
}
),
)
#: Doctrine for Fighter Sweep missions.
sweep: Sweep
#: Tactics options
tactics: Tactics
_by_name: ClassVar[dict[str, Doctrine]] = {}
_loaded: ClassVar[bool] = False
def resolve_combat_altitude(self, is_helo: bool = False) -> Distance:
if is_helo:
return self.helicopter.combat_altitude
return self.combat_altitude
def resolve_rendezvous_altitude(self, is_helo: bool = False) -> Distance:
if is_helo:
return self.helicopter.rendezvous_altitude
return self.rendezvous_altitude
@classmethod
def register(cls, doctrine: Doctrine) -> None:
if doctrine.name in cls._by_name:
duplicate = cls._by_name[doctrine.name]
raise ValueError(f"Doctrine {doctrine.name} is already loaded")
cls._by_name[doctrine.name] = doctrine
@classmethod
def named(cls, name: str) -> Doctrine:
if not cls._loaded:
cls.load_all()
return cls._by_name[name]
@classmethod
def all_doctrines(cls) -> list[Doctrine]:
if not cls._loaded:
cls.load_all()
return list(cls._by_name.values())
@classmethod
def load_all(cls) -> None:
if cls._loaded:
return
for doctrine_file_path in Path("resources/doctrines").glob("**/*.yaml"):
with doctrine_file_path.open(encoding="utf8") as doctrine_file:
data = yaml.safe_load(doctrine_file)
cls.register(
Doctrine(
name=data["name"],
rendezvous_altitude=feet(data["rendezvous_altitude_ft_msl"]),
hold_distance=nautical_miles(data["hold_distance_nm"]),
push_distance=nautical_miles(data["push_distance_nm"]),
join_distance=nautical_miles(data["join_distance_nm"]),
max_ingress_distance=nautical_miles(
data["max_ingress_distance_nm"]
),
min_ingress_distance=nautical_miles(
data["min_ingress_distance_nm"]
),
combat_altitude=feet(data["combat_altitude_ft_msl"]),
ground_unit_procurement_ratios=GroundUnitProcurementRatios.from_dict(
data["ground_unit_procurement_ratios"]
),
helicopter=Helicopter.from_dict(data["helicopter"]),
cas=Cas.from_dict(data["cas"]),
cap=Cap.from_dict(data["cap"]),
sweep=Sweep.from_dict(data["sweep"]),
tactics=Tactics.from_dict(data.get("tactics", {})),
)
)
cls._loaded = True
WWII_DOCTRINE = Doctrine(
cap=True,
cas=True,
sead=False,
strike=True,
antiship=True,
hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(8),
cap_max_track_length=nautical_miles(18),
cap_min_distance_from_cp=nautical_miles(0),
cap_max_distance_from_cp=nautical_miles(5),
cap_engagement_range=nautical_miles(20),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(10),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
UnitClass.TANK: 3,
UnitClass.ATGM: 3,
UnitClass.APC: 3,
UnitClass.ARTILLERY: 1,
UnitClass.SHORAD: 3,
UnitClass.RECON: 1,
}
),
)

View File

@@ -10,12 +10,10 @@ 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
from game.factions.faction import Faction
PydcsWeapon = Any
PydcsWeaponAssignment = tuple[int, PydcsWeapon]
@@ -79,12 +77,8 @@ class Weapon:
WeaponGroup.load_all()
cls._loaded = True
def available_on(self, date: datetime.date, faction: Faction) -> bool:
def available_on(self, date: datetime.date) -> bool:
introduction_year = self.weapon_group.introduction_year
if self.weapon_group.name in faction.weapons_introduction_year_overrides:
introduction_year = faction.weapons_introduction_year_overrides[
self.weapon_group.name
]
if introduction_year is None:
return True
return date >= datetime.date(introduction_year, 1, 1)
@@ -241,17 +235,17 @@ 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
def available_on(self, date: datetime.date, faction: Faction) -> Iterator[Weapon]:
def available_on(self, date: datetime.date) -> Iterator[Weapon]:
for weapon in self.allowed:
if weapon.available_on(date, faction):
if weapon.available_on(date):
yield weapon
@classmethod

View File

@@ -2,18 +2,18 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, replace as dataclasses_replace
from dataclasses import dataclass
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,
@@ -34,10 +34,6 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
ViperChannelNamer,
WarthogChannelNamer,
PhantomChannelNamer,
HindChannelNamer,
HipChannelNamer,
KiowaChannelNamer,
)
from game.utils import (
Distance,
@@ -114,10 +110,6 @@ class RadioConfig:
"apache": ApacheChannelNamer,
"a10c-legacy": LegacyWarthogChannelNamer,
"a10c-ii": WarthogChannelNamer,
"phantom": PhantomChannelNamer,
"hind": HindChannelNamer,
"hip": HipChannelNamer,
"kiowa": KiowaChannelNamer,
}[config.get("namer", "default")]
@@ -190,9 +182,6 @@ class AircraftType(UnitType[Type[FlyingType]]):
#: planner will consider this aircraft usable for a mission.
max_mission_range: Distance
#: Speed used for TOT calculations
cruise_speed: Optional[Speed]
fuel_consumption: Optional[FuelConsumption]
default_livery: Optional[str]
@@ -216,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
@@ -227,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
@@ -301,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:
@@ -339,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
@@ -360,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)
@@ -401,41 +374,37 @@ 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:
if k in aircraft.property_defaults:
aircraft.property_defaults[k] = config[k]
# In addition to setting the property_defaults, we have to set the "default" property in the
# value of aircraft.properties for the key, as this is used in parts of the codebase to get
# the default value.
aircraft.properties[k] = dataclasses_replace(
aircraft.properties[k], default=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", {}))
@@ -478,57 +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,
cruise_speed=(
knots(data["cruise_speed_kt_indicated"])
if "cruise_speed_kt_indicated" in data
else None
),
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),
hit_points=data.get("hit_points", 1),
)
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)

View File

@@ -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
@@ -60,19 +61,13 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
reversed_heading: bool = False
_by_name: ClassVar[dict[str, GroundUnitType]] = {}
_by_unit_type: ClassVar[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)
_by_unit_type: ClassVar[
dict[type[VehicleType], list[GroundUnitType]]
] = defaultdict(list)
@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,25 +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),
hit_points=data.get("hit_points", 1),
)
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),
)

View File

@@ -1,49 +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

Some files were not shown because too many files have changed in this diff Show More