Compare commits

...

217 Commits

Author SHA1 Message Date
Astro-739
ce073c24bc Update of Campaign Falcon Went over the Mountain.
Added secondary mission types to all air-to-air squadrons for more
flexibility.
2023-12-30 20:33:03 +00:00
Starfire13
8de053cc7d Add Operation Aegean Aegis.
Adds a new Apache and Harrier campaign to the Syria map.
2023-12-28 20:01:34 -08:00
Astro-739
fe6e49b22b Update of Campaign Falcon Went over the Mountain.
Campaign update of Falcon Went over the Mountain (Syria map) to v11.0

1. Added squadron sizes
2. Rebalanced scenario
3. Added (non zero) heading to all SAM and EWR sites
2023-12-28 23:03:46 +00:00
Starfire13
3653dc8cbd Campaign inversion support for Battle for No Man's Land. 2023-12-27 14:06:46 -08:00
Starfire13
d2b5eea0de Update Harrier loadout (#3316)
I had a look and the default Harrier loadouts were very outdated and
quite sub-optimal. So I fixed it up.
2023-12-27 14:05:43 -08:00
zhexu14
211ec86e2e Apache speed fix (#3315)
This PR 1) introduces a cruise_speed parameter to the AircraftType class
and uses it as an override for default TOT/Ground Speed calculations and
2) sets this for the AH64.

The reason for this change is that air starts with the Apache at a speed
>130kt seems to completely break the FCR, even if you subsequently slow
down. In the development branch, Liberation sets the Apache to travel at
168kt, so any player air starting won't be able to use their FCR and it
wouldn't be readily apparent as to why.

In the longer run this parameter may also be useful for other aircraft
e.g. to override the cruise speed to the most efficient etc.
2023-12-27 14:04:16 -08:00
Starfire13
03caddc1b4 Update F-15E Suite 4+ loadouts to add the fixed GBU31v3 (#3314) 2023-12-21 22:15:16 -08:00
Dan Albert
3f7618d75d Update pydcs.
Has the __eq__ implementation for Task which fixes inconsistent save
loading issues.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3288.
2023-12-21 21:48:17 -08:00
Dan Albert
dcf23c655d Describe non-airport "runways" better.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3290.
2023-12-21 16:46:06 -08:00
Dan Albert
ef69275f34 Don't send the selected flight plan to the back.
We want the selected flight plan to show on top of all the other flight
plans, and because we can't properly z-order with the other elements of
the map (see the code comment), this is probably the best we can do.

This means that the selected flight will be drawn on top of the front
line again, and will in some cases intercept mouse clicks meant for the
front line, but it's much less of a problem than when all the paths were
drawn on top.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3305.
2023-12-21 15:43:12 -08:00
Dan Albert
167cea08f6 Update pydcs.
Normandy terrain update.
2023-12-21 15:34:47 -08:00
zhexu14
48ae55bdc2 Default overrides fix (#3307)
This PR makes sure that the Payload tab of the Edit Flight window shows
the correct property values (with `default_overrides` applied in the
aircraft YAML). It looks like the issue only affects an obscure
parameter on F14s (INS reference alignment stored) so may not have been
noticed until now.
2023-12-21 02:51:34 -08:00
zhexu14
ff2bd3f815 Enable AH-64 FCR by default. 2023-12-20 21:42:18 -08:00
Starfire13
ba5d0bed4d Add Battle for No Man's Land campaign. 2023-12-20 18:40:31 -08:00
Dan Albert
4a07b8a2d8 Update pydcs. 2023-12-20 18:01:39 -08:00
Dan Albert
1efce862fb Send flight plan paths to the back of the map.
This fixes the unusual case where the `interactive: false` property had
no effect, which would make it impossible to plan missions against UI
elements that were overflown by many flights (such as the front line).

As an added bonus, it looks a bit nicer.

This impacts the test in an odd way, but the cure for that is probably
rewriting the test to not use a mock now that we've figured out how to
do that.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3295.
2023-12-18 20:16:08 -08:00
Starfire13
80cb440e7d Adds Private Military Company - Russian (Hard) faction.
This is a new faction that expands on the current PMC Russian faction by
adding in a substantial number of new units for additional variety and
challenge. It'll be the default faction for my helicopter campaign (I'll
open a PR for that tomorrow. It's all done apart from the campaign
description).
2023-12-18 17:49:12 -08:00
Starfire13
e970c281e8 Add DEAD loadouts to AH-64D, KA-50, and KA-50 BS3 (#3301) 2023-12-18 17:41:19 -08:00
dependabot[bot]
b863e2fb83 Bump pyinstaller from 5.13.0 to 5.13.1
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 5.13.0 to 5.13.1.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v5.13.0...v5.13.1)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-17 18:45:43 -08:00
Starfire13
3007a96343 Add DEAD capability to AH-64D Blk II 2023-12-17 18:43:16 -08:00
Starfire13
463981f4bf Add DEAD mission capability to Blackshark 3 2023-12-17 18:43:05 -08:00
Starfire13
816d1cd787 Add DEAD mission capability to KA-50 2023-12-17 18:42:54 -08:00
zhexu14
4631ee0d74 Doctrine load from YAML (#3291)
This PR refactors the Doctrine class to load from YAML files in the
resources folder instead of being hardcoded as a step towards making
doctrines moddable (Issue #829).

I haven't added anything to the changelog as a couple of things should
get cleaned up first:
- As far as I can tell, the flags in the Doctrine class (cap, cas, sead
etc.) aren't used anywhere. Need to test further, and if they're truly
not used, will remove them.
- Probably need to update the Wiki
2023-12-17 18:42:31 -08:00
zhexu14
a213215c3f Fix exception when campaign has only off map CPs.
This PR fixes an exception in custom campaigns that only contain off map
spawns.
2023-12-15 14:25:11 -08:00
Starfire13
b014f2e543 Improve F-15E S4+ loadouts (#3286)
I've come to realise that two external tanks is overkill for pretty much
all A2G mission types. The AI no longer have a problem with fuel, and
player flights will essentially never run out of fuel with the 2 CFTs
and a single external tank. I have done 700+ mile trips at mil power the
whole way with fuel to spare. I have therefore switched all A2G mission
loadouts to a single tank. A2A loadouts still carry 2 tanks, as players
may require the extra fuel if they make very extensive use of
afterburner in air combat.

It turns out the GBU31v3 JDAMs are bugged in more than one way. We've
known that they vanish if carried in pairs on the CFT pylons, but now it
turns out their penetration doesn't actually work. This means they are
no better in any way than the GBU31v1s, which are not bugged on CFT
pylons.

I have therefore removed the penetrator JDAMs from all loadouts,
replacing them with regular JDAMs.
2023-12-06 16:46:16 -08:00
Nosajthedevil
f3d3c5f43a Aim-9 Updates (#3287)
Adds the Aim-9P3 between the Aim-9P5 and the Aim-9P. Also adds fallback
support for sidewinder versions for the VSN 104 mod, the JAS39 mod, and
the AJS-37.
2023-12-06 16:45:38 -08:00
Dan Albert
5ee3afeddb Disconnect log signals on exit.
If we don't do this, the uvicorn server may log its shutdown after the
Qt application has closed, and the signal this attempts to emit may not
be valid. Disconnect the log signals when the application exits to
prevent that.

There's actually another solution that I thought would be better, but I
couldn't get it to work:
https://www.pyinstaller.org/en/stable/feature-notes.html#automatic-hiding-and-minimization-of-console-window-under-windows
describes a way to have pyinstaller hide or minimize the console rather
than disabling it entirely. I was never really fond of getting rid of
the console window in the first place, but it did bother some users. If
we could get the hide or minimize option working, that'd probably avoid
bothering users, but also make the logs much easier to find, get us out
of the trouble of maintaining our own log viewer, and fix the problem
mentioned in the comment I add here (the log window only works if
there's only one in memory log handler).

Another option would be ditching our log window and instead just having
that menu item open the log file or directory in whatever program the OS
defaults to (probably notepad). It would still have the quirk of maybe
needing to open more than one location, since logging is use
configurable.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3278.
2023-12-02 15:59:00 -08:00
Dan Albert
88591fd18c Downgrade Qt.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3276.
2023-12-02 15:01:07 -08:00
Dan Albert
f5573cfc19 Revert "Update to Python 3.12."
Might fix https://github.com/dcs-liberation/dcs_liberation/issues/3276.
If not, we need to revert the Qt upgrade too, and if we downgrade Qt we
can't use Python 3.12 anyway.

This reverts commit 65eb10639b.
2023-12-02 15:01:07 -08:00
Dan Albert
f7141a9882 Fix a few more Pydantic conversions.
One of the newer versions got a lot more strict. It now only expects
dicts that match the model, or objects of the model. Previously it also
accepted objects which had the same properties as the model. Convert a
few more LatLngs to LeafletPoints.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3279.
2023-12-02 12:25:01 -08:00
dependabot[bot]
a599b503f8 Bump @adobe/css-tools from 4.3.1 to 4.3.2 in /client
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 16:15:04 -08:00
Dan Albert
6c4b8c81ee Update mypy.
Needed so mypy can recognize the new Python 3.12 generic syntax.
2023-12-01 16:14:54 -08:00
Dan Albert
2447cc156d Update black.
Required for the new syntax in Python 3.12.
2023-11-30 21:10:14 -08:00
Dan Albert
28954d05eb Downgrade pyinstaller.
I forgot to test the pyinstaller binary when I upgraded all the other
dependencies, and there's a breaking change in 6.0.0:

https://pyinstaller.org/en/latest/CHANGES.html#incompatible-changes

> All of onedir build's contents except for the executable are now moved
> into a sub-directory (called _internal by default). sys._MEIPASS is
> adjusted to point to this _internal directory. The breaking
> implications for this are:
>
> * Assumptions that os.path.dirname(sys.executable) == sys._MEIPASS
>   will break. Code locating application resources using
>   os.path.dirname(sys.executable) should be adjusted to use __file__
>   or sys._MEIPASS and any code locating the original executable using
>   sys._MEIPASS should use sys.executable directly.
> * Any custom post processing steps (either in the .spec file or
>   externally) which modify the bundle will likely need adjusting to
>   accommodate the new directory.

This is actually great because it declutters the top level directory to
just `dcs_liberation.exe` and a directory named `_internal` that has all
the guts, but the CWD is no longer the directory that has `resources/`
in it, so we can't find any of our resources. There are a few options
for fixing that (cd into that directory probably being the easiest, or
we could stop relying on CWD relative paths), but for now just downgrade
to unbreak the build.
2023-11-30 21:01:13 -08:00
Dan Albert
65eb10639b Update to Python 3.12. 2023-11-30 20:45:19 -08:00
Dan Albert
7bc35ef7f4 Update most Python dependencies.
A lot of the dependency versions we have pinned don't have wheels for
Python 3.12. Update almost all of them so we can upgrade Python.

The few that weren't upgraded here are black and mypy, since those will
be a bit invasive, and Pillow, which has an API change I don't want to
deal with right now (I've got a commit on another machine that has
already done the migration, so I'll do it later).
2023-11-30 20:24:28 -08:00
Starfire13
46766ecbd4 Remove AI F-15E from Starfire's campaigns.
Now that the F-15E Suite 4+ has JDAMs and the bug preventing AI from
using LGBs has been fixed, I have removed the AI-only F-15Es from my
campaigns. Also minor tweaks to a couple of squadron types/sizes based
on play-testing results.
2023-11-30 19:12:45 -08:00
Dan Albert
3469d08461 Remove recommendation to dedicated server.
DCS multithreading made this unnecessary.
2023-11-30 19:10:26 -08:00
Dan Albert
28d959bba0 Fix disappearing aircraft when deleting packages.
There are a few different code paths for deleting packages depending on
the state of the package, and two of them were deleting items from a
list they were iterating over without first making a copy, causing each
iteration of the loop to skip over a flight, making it still used since
the flight was never deleted, but unreachable since the package that
owned it was deleted.

This only happened when the package was canceled in its draft state
(the user clicked the X without ever saving the package), or if the user
canceled a package mid fast forward (the controls for which aren't even
visible to users) while only a portion of the package was active. In
both cases, only even numbered flights were lost.

There's a better fix lurking in here somewhere but the interaction with
the Qt model complicates it. Fortunately that mess would be cleaned up
automatically by moving this dialog into React, which we'll do some day.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3257.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3258.
2023-11-30 19:00:58 -08:00
Dan Albert
b99eb49dcf Renumber flight members for meatbags.
Puny humans count wrong, but we ought to match DCS.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3244.
2023-11-30 18:47:43 -08:00
Starfire13
c6f812238c Update Exercise Vegas Nerve.
Updated Exercise Vegas Nerve with off map spawns for B-52 and B-1.
2023-12-01 02:02:21 +00:00
Starfire13
cc5b5fa3bb Add new Mariana Islands campaign - Operation Velvet Thunder.
Vietnam War era campaign for the Mariana Islands map, utilising the two
new Vietnam War factions.
2023-12-01 01:57:50 +00:00
Starfire13
5271b3d32c Switch F-15E S4+ loadouts from LGBs to JDAMs (AI still won't use LGBs) (#3259)
It appears the AI is still incapable of using LGBs (and laser JDAMs),
even though Razbam had said the issues was fixed. I have switched
loadouts to JDAMs because the AI will use those.
2023-11-30 17:56:34 -08:00
Starfire13
8f4192edc3 Fix Operation Grabthar's Hammer map object strike target.
This fixes a map object strike target (yes, just one. Fortunately!) that
was broken by the latest DCS open beta update that removed the buildings
the strike target was using.
2023-11-30 17:51:55 -08:00
Starfire13
183d6df8bf Add guided bombs to F-15E Suite 4+ loadouts.
GBUs have been added to the F-15E Suite 4+ loadouts

Also switched CAP loadouts to 2 external tanks instead of 3 to improve
aircraft performance, as 2 tanks is plenty for players and the AI has
infinite fuel now.
2023-11-18 18:51:42 -08:00
Dan Albert
a825651330 Update pydcs.
Terrain updates for Normandy and South Atlantic.
2023-11-18 15:00:21 -08:00
Dan Albert
f3c02816fc Update pydcs.
Includes F-15E JDAM support.
2023-11-18 14:54:14 -08:00
Starfire13
c4e2e45650 Add Vietnam War factions for USA and NVA.
This adds factions for Vietnam War for both the US and North Vietnamese
Army.
2023-11-18 14:20:14 -08:00
Starfire13
6613642517 Add LARC-V and Speedboat.
This adds LARC-V to Liberation (required by USA 1970)
Also adds Speedboat to Liberation (required by NVA 1970)

Both units are available via pydcs but have not been added to Liberation
thus far as no factions used them (until now).
2023-11-18 22:12:40 +00:00
Dan Albert
b73ca2c62e Update bug templates. 2023-11-11 13:32:10 -08:00
Dan Albert
8abd3c7cf9 Fix typo in changelog. 2023-11-11 13:28:57 -08:00
Dan Albert
f8a72d8f22 Bump version to 10.0.0. 2023-11-10 19:15:41 -08:00
dependabot[bot]
a29fd7a14c Bump axios from 1.1.2 to 1.6.0 in /client
Bumps [axios](https://github.com/axios/axios) from 1.1.2 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.1.2...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-10 19:03:42 -08:00
Dan Albert
f2a879fc6f Add Linebacker to bluefor modern. 2023-11-10 15:07:49 -08:00
Dan Albert
fdea746323 Add C-RAM LPWS.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3233.
2023-11-10 15:07:49 -08:00
Starfire13
7f8c2f073c Updates and fixes to Operation Grabthar's Hammer 2023-11-10 12:43:53 -08:00
dependabot[bot]
7db4d438ce Bump @babel/traverse and @trivago/prettier-plugin-sort-imports
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) to 7.23.2 and updates ancestor dependencies [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) and [@trivago/prettier-plugin-sort-imports](https://github.com/trivago/prettier-plugin-sort-imports). These dependencies need to be updated together.


Updates `@babel/traverse` from 7.19.3 to 7.23.2
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

Updates `@babel/traverse` from 7.17.3 to 7.23.2
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

Updates `@trivago/prettier-plugin-sort-imports` from 3.3.0 to 4.2.1
- [Release notes](https://github.com/trivago/prettier-plugin-sort-imports/releases)
- [Changelog](https://github.com/trivago/prettier-plugin-sort-imports/blob/main/CHANGELOG.md)
- [Commits](https://github.com/trivago/prettier-plugin-sort-imports/compare/v3.3.0...v4.2.1)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
- dependency-name: "@babel/traverse"
  dependency-type: indirect
- dependency-name: "@trivago/prettier-plugin-sort-imports"
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 19:12:10 -07:00
Dan Albert
efa47e1550 Fix allocation range for Link 4.
Prior to DCS 2.9 Jester was able to use datalink frequencies outside the
range the aircraft was capable of, but this has presumably always been
broken for human RIOs.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3222.
2023-11-01 18:47:00 -07:00
Dan Albert
c010ef9994 Add support for DCS 2.9's AI unlimited fuel.
This is enabled by default because I can't think of a time where it's
ever been more fun to watch the AI run out of fuel after cycling between
afterburner and speedbrake for twenty minutes.

This is only lightly tested (I verified that the task shows up
appropriately in the ME when set, not when unset, and never for
players), since the interesting part of the implementation is up to ED.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3201.
2023-11-01 18:08:04 -07:00
Dan Albert
82a200c53a Update pydcs.
The new version has support for the unlimited fuel option for AI
flights.
2023-11-01 18:08:04 -07:00
Starfire13
c2c0119132 Add naval vessels to Egypt faction 2023-11-01 17:03:21 -07:00
Starfire13
8dca91f533 Starfire's campaign updates 2023-11-01 16:59:34 -07:00
zhexu14
ac2fbc2940 Support planning TARCAP at last airfield.
This PR addresses #771 by adding special handling for the scenario where
there is only one remaining enemy airfield. An example of the race track
generated using this logic is shown below.

![Screenshot 2023-10-31
230938](https://github.com/dcs-liberation/dcs_liberation/assets/64713351/3fb2027e-f496-4325-a3c5-2abe2a45b58f)

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/771.
2023-11-01 16:52:35 -07:00
MetalStormGhost
da22b8ba43 Update A-4E-C mod support to version 2.2.0.
Updated Community A-4E-C mod version pydcs extension to 2.2.0 release.
2023-11-01 16:47:31 -07:00
Starfire13
7bcd669e6e Updated YAMLs for some of Starfire's campaigns
Updates for:
Final Countdown 2
Operation Vectron's Claw
Exercise Bright Star
Operation Grabthar's Hammer
2023-10-24 21:53:42 -07:00
Starfire13
66f175fd65 Add F-4E to Egypt 2000 faction
Add F-4E Phantom II to Egypt 2000 faction as they were in active service
until 2014.
2023-10-24 21:45:12 -07:00
Starfire13
2af5d8ae01 Update S-3 DEAD task priority.
Bumping from 100 to 200 so it's preferred over WW2 aircraft but less
preferred than B-52.
2023-10-23 20:36:45 -07:00
Starfire13
30a4110c57 B1 loadout updates.
Updates B1 loadout, replacing iron bombs with JDAMs. Also switches DEAD
loadout to JSOW-C.
2023-10-23 20:36:08 -07:00
Starfire13
61f6184f9b Update B52 loadout.
Updates anti-ship loadout as harpoons are now mounted on wing pylons on
the new model rather than in the bomb bay
2023-10-23 20:35:38 -07:00
Dan Albert
b42a8c78d1 Fix task priority script for display name split.
`name` was split into an ID and a display name a while back, but this
was never updated to account for that.
2023-10-23 17:26:25 -07:00
Starfire13
bf8a07fe15 S3 Viking loadout and mission type update
Updates the S3 Viking so they can now do DEAD (since the new model can carry SLAMs). Also updates the loadouts for all the other mission types as they are outdated (2000 pounders for anti-runway, 500 pounders for strike, Mavericks for BAI/CAS, etc).
2023-10-23 17:19:14 -07:00
Starfire13
6122c8c42d Add DEAD task capability to the S-3. 2023-10-23 17:19:14 -07:00
Thomas MONZIE
dce4206130 Tripoint update : carrier + compatibility 2023-10-22 13:02:45 -07:00
Thomas MONZIE
eada2ba9ae Bump campaign version number up to 11 2023-10-22 13:02:45 -07:00
Dan Albert
376c1137d7 Fix stale waypoint tasks for custom flight plans.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3189.
2023-10-21 15:35:05 -07:00
Dan Albert
884993dd46 Fix empty string CLSID issue for default loadouts.
We had two different paths for converting pydcs loadouts because pydcs's
APIs for some reason return the loadouts in different shapes which made
it difficult to share the code for converting them. Rather than fix the
bug in both places, extract the common code and adapt the result of one
API to match the other.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3171.
2023-10-20 14:47:40 -07:00
Dan Albert
c5d5ea81de Ensure speed lock for waypoint 0.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3195.
2023-10-20 14:28:19 -07:00
Dan Albert
0e01aaf9cd Filter out empty string CLSIDs.
There's more detail in the comment, but this fixes an issue where some
Mosquito loadouts could not be loaded.

Might fix https://github.com/dcs-liberation/dcs_liberation/issues/3171.
There aren't enough instructions in the bug for me to be sure, but it
sounds like a similar problem, although I came across it with bombs
rather than rockets.
2023-10-20 14:22:36 -07:00
Dan Albert
f9e7370b35 Update pydcs for 2.9.0.46801 Open Beta.
F-22 mod data is out of date. Removed the broken bits, but someone
should probably update that mod.
2023-10-19 19:48:27 -07:00
Dan Albert
dfb74cfd48 Fix kneeboard generation following Pillow update.
We don't have tests for this, so dependabot broke it and we didn't
notice.
2023-10-19 19:47:36 -07:00
zhexu14
32b3793082 Fix issues with waypoint editing.
fix a number of regressions in the flight waypoint list by changing the
indexing and finding a work-around to blocking of signals

This PR addresses some (but not all) recently reported issues with the
waypoints screen reported in Issue #3188 . This PR was tested by:

- Changing the waypoint altitude and confirming it shows up correctly
when reloading the waypoint list window and on the map
- Adding a waypoint and confirming that it shows up immediately and
persists on reload
- Deleting a waypoint (except the first waypoint) and confirming that it
is removed immediately and persists on reload,

Known issues: first waypoint (typically hold) cannot be deleted -- still
looking into this one.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3188.
2023-10-10 18:05:57 -07:00
dependabot[bot]
990f1c37b8 Bump electron from 21.1.0 to 22.3.25 in /client
Bumps [electron](https://github.com/electron/electron) from 21.1.0 to 22.3.25.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v21.1.0...v22.3.25)

---
updated-dependencies:
- dependency-name: electron
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-05 20:12:14 -07:00
dependabot[bot]
4d7e1e1946 Bump pillow from 9.3.0 to 10.0.1
Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.3.0 to 10.0.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/9.3.0...10.0.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-05 20:11:55 -07:00
Dan Albert
ca268a2252 Add changelog note for finance menu UI fix. 2023-10-03 22:05:37 -07:00
zhexu14
2686a1ea77 Fix odd whitespace in finance menu.
Moves the stretch to the bottom of the page to avoid awkward whitespace
in the middle. Presumably the totals used to be at the bottom (since
that's a normal place for a total), but it was moved to the top,
probably since that was the most interesting data and we didn't want to
scroll though all the details to find that one point.

This also removes the unused code path where the total would be shown at
the bottom.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1288.
2023-10-03 21:55:59 -07:00
Dan Albert
256c9ce73d Add a package kneeboard page.
The package page shows each flight member in the whole package. The data
shown for now is the callsign, task, radio frequency, and laser code.
The STN for each flight will be added once that's done.

This does generate one package page per flight. That means that packages
where multiple flights have players and use the same aircraft type will
have some duplicated pages, but the alternative is that some players
would need to skip past all their flight members' pages to find their
package page instead of having it grouped with their own.
2023-10-03 21:50:29 -07:00
Dan Albert
e9133bffab Group briefing data by package.
This is just the refactor to make way for the real change: adding a
package page to the kneeboard so players can get package-level
information like other radio, laser, and STNs.
2023-10-03 21:50:29 -07:00
zhexu14
f9916e47d8 address issue 3175 by introducing special divide by zero handling 2023-10-01 23:08:05 -07:00
tmz42
b1b88c4335 Updates for tmz's campaigns.
Updated Fuzzle's campaigns for new squadron rules compatibility.
2023-10-01 23:16:41 +00:00
zhexu14
20574e3fbb address issue 3162 by applying threshold to CVN position check 2023-10-01 16:10:10 -07:00
Starfire13
1ed37ff75e Final Countdown 2 update for campaign inversion
Fixes incorrect airfield assignment when campaign is inverted
2023-10-01 16:09:35 -07:00
Dan Albert
69ec9adec7 Remove code for old squadron rules. 2023-10-01 12:21:37 -07:00
Dan Albert
63584321c6 Bump campaign version for squadron sizes.
The old mode where squadrons started empty and had no size limit is
going away.

I bumped the campaign versions for the ones that claimed support and
tested a handful. All of Fuzzle's campaigns actually had the wrong
version in the yaml. They claim support for this but none that I tested
actually fit within the limits (despite having sizes defined). Either it
was supposed to be 10.7, or maybe the airports lost some parking.
2023-10-01 12:21:37 -07:00
Dan Albert
2344fc0b5c Add campaign support for ferry-only bases.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3170.
2023-09-21 21:34:31 -07:00
Dan Albert
e43874e553 Increase max distance for waypoint solver.
1000km isn't large enough in the case where there's an off-map spawn
that's a long way from the target, but still in range for aircraft like
the B-1. Double it, which for now is enough to fix the one pathological
case we know.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3156.
2023-09-20 22:02:01 -07:00
Dan Albert
3862ec1b2e Convert escort request to a waypoint property.
Another step in reducing the rigidity of FlightPlan and making it
testable.

There is one intentional behavior change here: escort flights no longer
request escorts. That actually has a very minimal effect because these
properties are only used for two things: determining if a package needs
escorts or not, and determining when the TARCAP should show up and
leave. Since escorts won't have been in the package when the first part
happens anyway, that has no effect. The only change is that TARCAP won't
show up earlier or stay later just because of a TOT offset for an escort
flight.
2023-09-11 22:25:58 -07:00
Dan Albert
502d37058c Remove dead code. 2023-09-11 20:51:16 -07:00
Dan Albert
b7723843c6 Migrate sweep ingress's tasks to waypoint actions. 2023-08-29 21:57:17 -07:00
Dan Albert
c00f853f34 Roll-over excess time from tasks. 2023-08-29 21:57:17 -07:00
dependabot[bot]
8c6b360e65 Bump @adobe/css-tools from 4.0.1 to 4.3.1 in /client
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.0.1 to 4.3.1.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-29 21:43:02 -07:00
Dan Albert
cb9063b5be Remove bingo estimates from FlightPlan.
This doesn't need to be a part of FlightPlan, and it's easier to test if
it isn't. Move it out and add the tests.

It's pretty misleading to allow this in the core of the flight plan code
anything. This is an extremely unreliable estimate for most aircraft so
it should be more clearly just for briefing purposes.
2023-08-23 20:14:16 -07:00
Dan Albert
99eed33241 Remove useless TravelTime class.
This is only called for real in one spot. The other callers should have
been deferring to the one real caller.
2023-08-22 20:31:20 -07:00
Dan Albert
1902618f45 Remove dead code. 2023-08-21 22:42:00 -07:00
Starfire13
782389bd89 Update up_the_coast enemy faction
Switch default enemy faction to Russia 1975 from the now-removed Russia 1975 (Mi-24).
2023-08-19 19:14:50 -07:00
Starfire13
5025fe9e34 Update russia_1975.yaml
Adds Mi-24P flyable Hind
2023-08-19 11:31:07 -07:00
Starfire13
27689b675e Delete russia_1975 (Mi-24P).yaml
Rather than having a separate faction file that is identical to the Russia 1975 faction file but includes the Hind, might as well just add the Hind to the standard Russia 1975 faction.
2023-08-19 11:31:07 -07:00
Starfire13
6791af16d1 Delete final_countdown_2.yaml
No longer needed as this custom faction is already included in the campaign yaml
2023-08-19 11:31:07 -07:00
Starfire13
53580b2088 Update Sweden 1970
Change the Mirage 2000 (a stand in for the Draken) to the Mirage F1 as that is a closer match in terms of capabilities and era.
2023-08-19 11:31:07 -07:00
Starfire13
42bffa06ae Delete russia_1970_limited_air.yaml 2023-08-19 11:31:07 -07:00
Starfire13
626e73d641 Delete normandy_small.miz 2023-08-18 16:26:25 -07:00
Starfire13
667d6e3b8a Delete normandy_full.miz 2023-08-18 16:26:25 -07:00
Dan Albert
eebb172333 Remove flight-size-specific formations.
No reason to do this, and it's making the task rework annoying.
2023-08-17 20:27:34 -07:00
Dan Albert
5f3e342a0e Use SEAD for SEAD.
The F-14 was fixed to allow this in the latest patch.
2023-08-16 19:42:11 -07:00
Dan Albert
d3b4d45bd6 Update pydcs. 2023-08-16 16:50:49 -07:00
Starfire13
a5a3b09379 New version of Exercise Bright Star with carrier 2023-08-15 16:51:28 -07:00
Dan Albert
87441b8939 Formalize waypoint actions.
Create a WaypointAction class that defines the actions taken at a
waypoint. These will often map one-to-one with DCS waypoint actions but
can also be higher level and generate multiple actions. Once everything
has migrated all waypoint-type-specific behaviors of
PydcsWaypointBuilder will be gone, and it'll be easier to keep the sim
behaviors in sync with the mission generator behaviors.

For now only hold has been migrated. This is actually probably the most
complicated action we have (starting with this may have been a mistake,
but it did find all the rough edges quickly) since it affects waypoint
timings and flight position during simulation. That part isn't handled
as neatly as I'd like because the FlightState still has to special case
LOITER points to avoid simulating the wrong waypoint position. At some
point we should probably start tracking real positions in FlightState,
and when we do that will be solved.
2023-08-13 12:43:59 -07:00
Dan Albert
2f385086fd Don't flag negative starts for active flights.
If the flight has already passed its start up time, this isn't a
negative start.
2023-08-13 12:39:56 -07:00
Dan Albert
aaf66107ad Improve type inference in loiter flight plans. 2023-08-13 12:28:43 -07:00
Dan Albert
59756ce14c Differentiate total time and travel time.
There's an ugly special case in flight simulation to handle hold points
because we don't differentiate between the total time between two
waypoints (which can include delays from actions like holding) and
travel time. Split those up and remove the special case.
2023-08-13 11:36:05 -07:00
Dan Albert
bf1e559a41 Remove dead code. 2023-08-13 11:36:05 -07:00
Dan Albert
5f4a75601b Show the real front line bounds on the map.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1067.
2023-08-10 21:01:44 -07:00
Dan Albert
c4358daccc Revert "Remove dead code."
Not used by python, but is used by jinja templates.

This reverts commit 1f3eee90f1.
2023-08-10 00:53:29 -07:00
Dan Albert
723b96cd51 Fix faction templates for unit type prop changes. 2023-08-10 00:52:45 -07:00
Dan Albert
3f0b565b82 Drop save compat hacks.
The CAS flight plan tweaks break save compat in a way that's not as easy
to fix. Accept it and drop the existing hacks since they won't be useful
any more.
2023-08-10 00:47:13 -07:00
Dan Albert
cb3bf56d84 Add a real CAS ingress point.
Putting the ingress point directly on one end of the FLOT means that AI
flights won't start searching and engaging targets until they reach that
point. If the front line has advanced toward the flight's departure
airfield, it might overfly targets on its way to the IP.

Instead, place an IP for CAS the same way we place any other IP. The AI
will fly to that and start searching from there.

This also:

* Removes the midpoint waypoint, since it didn't serve any real purpose
* Names the FLOT boundary waypoints for what they actually are

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2231.
2023-08-10 00:47:13 -07:00
Dan Albert
9460586cfe Mark the AI-only F-15E more clearly. 2023-08-09 22:50:05 -07:00
Dan Albert
3c2ace09f3 Add display name property for unit types.
Unlike the variant ID, this can be changed without breaking save compat.
2023-08-09 22:50:05 -07:00
Dan Albert
58d8203c83 Fix unit variants to actually allow variance.
This was always the intent, but apparently it wasn't implemented
correctly. All properties of the unit type can now be overridden per
variant.
2023-08-09 22:50:05 -07:00
Dan Albert
1f3eee90f1 Remove dead code. 2023-08-09 22:27:21 -07:00
Dan Albert
0be6952a93 Rename UnitType.name what it is: the variant ID.
This property affects safe compat because the ID is what gets preserved
in the save, but it's unfortunately also used as the display name, which
means changing the display name breaks save compat. It also prevents us
from changing display names without breaking faction definitions.

This is the first step in fixing that. The next is adding a separate
display_name property that can be updated without breaking either of
those.
2023-08-09 21:53:25 -07:00
Dan Albert
09f1af37fd Force dumping debug info on recreate.
We need a way to debug successful solvers that's still targeting to a
specific flight. This will do for now.
2023-08-08 21:46:52 -07:00
Dan Albert
257f2072e8 Add test cases found by fuzzer. 2023-08-08 21:46:52 -07:00
Dan Albert
1708baf772 Add fuzz testing for waypoint solvers.
This fuzz test generates random inputs for waypoint solvers to check if
they can find a solution. If they can't, the debug info for the solver
is dumped to the testcases directory. Another test loads those test
cases, creates a solver from them, and checks that a solution is found.
Obviously it won't be immediately, but it's a starting point for fixing
the bug and serves as a regression test afterward.
2023-08-08 21:46:52 -07:00
Dan Albert
6b6c4f4112 Migrate IP placement to WaypointSolver. 2023-08-08 21:46:52 -07:00
Dan Albert
5cb4c363e3 Build common interface for waypoint geometry constraints.
This is a replacement for the existing "zone geometry" classes that are
currently used for choosing locations for IP, hold, and join points.
The older approach required the author to define the methods for
choosing locations at a rather low level using shapely APIs to merge or
mask geometries. Debug UIs had to be defined manually which was a great
deal of work. Worse, those debug UIs were only useable for *successful*
waypoint placement. If there was a bug in the solver (which was pretty
much unavoidable during development or tuning), it wasn't possible to
use the debug UI.

This new system adds a (very simple) geometric constraint solver to
allow the author to describe the requirements for a waypoint at a high
level. Each waypoint type will define a waypoint solver that defines one
or more waypoint strategies which will be tried in order. For example,
the IP solver might have the following strategies:

1. Safe IP
2. Threat tolerant IP
3. Unsafe IP
4. Safe backtracking IP
5. Unsafe backtracking IP

We prefer those in the order defined, but the preferred strategies won't
always have a valid solution. When that happens, the next one is tried.

The strategies define the constraints for the waypoint location. For
example, the safe IP strategy could be defined as (in pseudo code):

* At least 5 NM away from the departure airfield
* Not farther from the departure airfield than the target is
* Within 10 NM and 45 NM of the target (doctrine dependent)
* Safe
* Within the permissible region, select the point nearest the departure
  airfield

When a solver fails to find a solution using any strategy, debug
information is automatically written in a GeoJSON format which can be
viewed on geojson.io.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3085.
2023-08-08 21:46:52 -07:00
Dan Albert
13ccf3f536 Add __str__ for Distance. 2023-08-08 01:56:18 -07:00
Dan Albert
42fa5dce94 Add name to Doctrine. 2023-08-08 01:56:06 -07:00
Starfire13
d1baf33d86 Add F-15E Suite 4+ to Allied Sword faction.
Fuzzle's Allied Sword campaign has a custom faction in the campaign
yaml, and it wasn't updated when Razbam's Mudhen was released. I've
added it in.
2023-08-07 21:02:28 -07:00
Dan Albert
a0ab46af8f Note the ARA Veinticinco de Mayo in the changelog. 2023-08-01 18:33:00 -07:00
Nosajthedevil
58ede1b888 Add support for ARA Veinticinco de Mayo.
Includes an Argentina 1982 faction for testing purposes, although it's
sparse because of a lack of assets in DCS.

Note that the carrier is mispelled as the Vienticinco in the game.

Includes prerequisite pydcs update.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3123.
2023-08-02 01:26:08 +00:00
Dan Albert
e72b1b3ae7 Update pyinstaller and hooks.
3149c93016
is needed to fix the pyinstaller package for shapely 2.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3127.
2023-08-01 18:15:08 -07:00
Dan Albert
2c51e126b7 Fix shapely error during mission generation.
It seems shapely doesn't allow `unary_union` on collections any more.
2023-07-31 17:32:46 -07:00
Dan Albert
4f04a2d142 This brace belonged to the now deleted "settings". 2023-07-30 14:34:58 -07:00
Dan Albert
899620c242 Add TODO note to joinzonegeometry. 2023-07-29 21:56:56 -07:00
Dan Albert
431165ab83 Add __str__ for Heading. 2023-07-29 21:56:43 -07:00
Dan Albert
703bb98b62 Update Shapely.
I need the new to_geojson API.
2023-07-29 21:45:20 -07:00
Starfire13
6610162c44 Add Operation Noisy Cricket campaign. 2023-07-29 19:43:35 -07:00
Starfire13
09f0b0b315 Add BLU-107 to OCA/Runway loadout
I had originally opted for iron bombs for anti-runway as the AI sometimes miss with the Durandals because they release them from too high an altitude. But after using them for a while, I am finding they appear to do no worse than with iron bombs. So, might as well let them use the dedicated anti-runway weapon.
2023-07-29 17:35:51 -07:00
Dan Albert
d81ed26fa6 Stop gap fix for AI speed to nav points.
This isn't a great fix for the reason I mention in the comment, but it's
quick and actually is accurate since it looks like we don't actually
handle formation speeds correctly in most cases...

This is probably as "fixed" as this is going to get for now since most
of the flight planning code is in the process of being rewritten.

https://github.com/dcs-liberation/dcs_liberation/issues/3113
2023-07-29 10:31:05 -07:00
Dan Albert
159120b487 Always re-enable loadout UI for member 0. 2023-07-27 22:28:29 -07:00
Dan Albert
160d464f9a Fix synchronization of loadouts on change.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3111.
2023-07-27 22:25:51 -07:00
dependabot[bot]
b893378abe Bump certifi from 2022.12.7 to 2023.7.22
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.12.7 to 2023.7.22.
- [Commits](https://github.com/certifi/python-certifi/compare/2022.12.07...2023.07.22)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-27 21:36:24 -07:00
Starfire13
f9f2c79aeb Add Mirage F1 to Iran 2015 2023-07-23 20:10:47 -07:00
Dan Albert
c7a991687c Configure target points for F-15E S4+.
We don't need explicit configuration of initial points. The plane
automatically configures any steerpoint immediately before a target
point as an initial point.

Target offset points and aim points have not been implemented because I
can't find any information the describes their intent.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3088.
2023-07-23 14:51:56 -07:00
Dan Albert
d2152a259c Handle TOT offsets for patrolling flight plans.
https://github.com/dcs-liberation/dcs_liberation/issues/3107
2023-07-23 11:51:07 -07:00
Dan Albert
5fd29d8c9d Fix negative starts when changing TOT offsets. 2023-07-23 11:51:07 -07:00
Dan Albert
d74ba4a6c9 Fix altering negative TOT offsets.
https://github.com/dcs-liberation/dcs_liberation/issues/3107
2023-07-23 11:51:07 -07:00
Dan Albert
e1dba91b25 Add laser code support for the viper. 2023-07-22 18:34:14 -07:00
Dan Albert
ca5ec65ed1 Remove unused config data from laser code yamls. 2023-07-22 18:23:07 -07:00
Dan Albert
374dd6da9a Clarify that the assigned code is for the TGP. 2023-07-22 18:21:46 -07:00
Dan Albert
e8df6a3d54 Hide properties that have better controls.
The weapon laser codes can be set more easily from the weapon laser code
combo box. Setting the properties explicitly here will just cause
conflicts and annoying UI bugs. Hide those properties from the UI.
2023-07-22 18:14:26 -07:00
Dan Albert
e901d1f538 Add UI for selecting weapon laser codes.
This makes it possible to have the right laser code set for hot start
aircraft that (typically) do not allow changing laser codes when the
engine is on.
2023-07-22 18:14:26 -07:00
Dan Albert
efc2915628 Do not draw empty property rows. 2023-07-22 18:14:26 -07:00
Dan Albert
6c5b35d704 Add laser code property info for the Strike Eagle. 2023-07-22 18:14:26 -07:00
Dan Albert
01e4ebc706 Add laser code config parsing and prop generation. 2023-07-22 18:14:26 -07:00
zhexu14
b10395715d In NewUnitTransferDialog, only list reachable control points.
This PR addresses #3066 by restricting the list of control points in the
new unit transfer dialog to control points reachable from the origin.
This change centralizes the logic for reachable nodes to the
TransitNetworkBuilder class.

This PR was tested by:

1. Loading save from #3066 
2. Using cheat menu to destroy runway at Wadi al Jandali
3. Purchasing units at any of the other control points
4. Pass the turn to allow the purchase to complete
5. Initiating a unit transfer from the other control point and
confirming that Wadi al Jandali does not show up in the list

Steps 2-4 are needed as no ground units show up at Melez when loading
the save directly from the latest dev build.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3066.
2023-07-22 17:58:38 -07:00
Dan Albert
ad8f3d61ea Add UI for acquiring/releasing TGP laser codes. 2023-07-22 17:57:02 -07:00
Dan Albert
85e11711b6 Pre-allocate laser codes for FLOTs and flights. 2023-07-22 17:57:02 -07:00
Dan Albert
31289adb50 Create a checked, releasable type for laser codes.
The release behavior isn't used yet, but I'm working on pre-allocating
laser codes for front lines and flights to make it easier for players to
pick the laser codes for their weapons.
2023-07-22 17:57:02 -07:00
Dan Albert
d3269bca93 Fix crash when changing squadrons in new flight. 2023-07-22 17:36:56 -07:00
Dan Albert
e35e49e05e Add missing LANTIRN clsid. 2023-07-22 14:28:43 -07:00
Dan Albert
91b56b1573 Add tests for LaserCodeRegistry, clean up.
* Store a deque rather than an iterator so it can be pickled
* Remove mangling from staticmethod (and rename now that it's no longer
  a generator)
* Rename "get" to "alloc" to make the mutation clear
* Move to its own package (the changes I'm working on make this no
  longer mission generator specific)
* Remove useless exception class. It's never caught so the unique type
  isn't needed
2023-07-22 13:44:17 -07:00
zhexu14
6475c6d1ac Fix default faction selection when changing campaigns.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/1491.
2023-07-22 12:35:44 -07:00
Dan Albert
48fff39409 Allow per pilot loadouts and properties.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3092.
2023-07-20 23:41:21 -07:00
Dan Albert
2d8cc12a37 Update pydcs.
Needed for the fix for all aircraft of the same type sharing whatever
properties were last set.
2023-07-20 23:41:21 -07:00
Dan Albert
f7d5db7f1e Improve UI for flight properties.
Use the new data from pydcs to improve the properties UI:

* Use human readable names
* Use appropriate control types
* Limit min and max values as appropriate for each property
* Show labels

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3090.
2023-07-19 22:24:44 -07:00
dependabot[bot]
0a82c2b3d1 Bump word-wrap from 1.2.3 to 1.2.4 in /client
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-19 09:17:50 -07:00
zhexu14
a5eeb83783 Fix anti-runway task generation for LGBs.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/894.
2023-07-17 17:07:01 -07:00
Dan Albert
1f73e02d15 Add cheats for destroying and repairing runways. 2023-07-13 22:08:13 -07:00
Dan Albert
aced4d3ef5 Fix canceling transfers when the airbase is full.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2955.
2023-07-13 21:25:45 -07:00
Dan Albert
5b935db923 Add warnings for invalid fast-forward settings.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2739.
2023-07-13 21:03:08 -07:00
Dan Albert
2a29dd4886 Fix off-by-one error in waypoint deletion.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3067.
2023-07-13 20:52:39 -07:00
zhexu14
fa5cabace3 Allow more helicopters to operate from LHAs and CVs.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3072.
2023-07-13 20:16:27 -07:00
Dan Albert
8c6d854732 Always initialize IADS coalition lua tables.
These are read unconditionally, but were only initialized when the
coalition had nodes. When a coalition had no nodes, this caused a nil
access. It's unclear if that had any symptoms, but I expect at the very
least it would break the remainder of the script (so a non-functioning
blue IADS if the red IADS had no nodes).

There's a very small chance this is the culprit behind
https://github.com/dcs-liberation/dcs_liberation/issues/3073.
2023-07-12 19:55:27 -07:00
Dan Albert
9a59db1ed8 Generate anti-ship missions with group attack.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3068.
2023-07-12 10:33:32 -07:00
Dan Albert
82daa631bf Request DCS log file for mission issues.
Not that anyone reads this.
2023-07-12 01:12:47 -07:00
Dan Albert
19976989ca Improve IP selection near threat zone centers.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2754.
2023-07-11 22:16:18 -07:00
Dan Albert
adabb617f3 Update bug templates for 8.1.0. 2023-07-10 09:52:03 -07:00
dependabot[bot]
dc6a18ccb0 Bump tough-cookie from 4.0.0 to 4.1.3 in /client
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-09 11:02:17 -07:00
Dan Albert
b549af9cb7 Clean up remaining Flight.from_cp users.
The preferred API for this has been `Flight.departure` for a while.
2023-07-05 22:45:06 -07:00
Dan Albert
de8d42e3e5 Test (most of) the rest of WaypointMarker.
There isn't any UI observable behavior of the dragend of the waypoint,
but we can test that the backend was called. The only uncovered parts of
that component are now error paths, but the error handling in that
component is to just ignore errors, so there's also nothing to test
there.
2023-06-28 22:44:02 -07:00
Dan Albert
02c9fe93c5 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.
2023-06-27 23:42:14 -07:00
Dan Albert
374759df0f Test most of WaypointMarker.
Unlike the other map tests which heavily rely on mocks, this one uses
React refs to inspect the constructed leaflet objects. The DOM itself
doesn't appear to contain anything worth testing against (react-leaflet
rendering doesn't work like typical React rendering).

This required some infrastructure changes:

1. Forwarded ref from WaypointMarker to Marker so the test can observe
   it. Added a mergeRefs helper (and its own tests) to make that easier.
2. Switched from identity-obj-proxy to jest-transform-stub, because the
   former doesn't produce a useable image for imports, and we need
   usable images for leaflet to be able to render.

This doesn't yet test drag and drop behavior since that requires mocking
the backend, and this commit is already complicated enough. That'll be
next.
2023-06-27 22:41:33 -07:00
Dan Albert
82c234b09e Make save game requirement even more obvious. 2023-06-27 18:12:58 -07:00
Starfire13
427df21da5 Add F-15E Suite 4+ squadrons. 2023-06-27 18:04:31 -07:00
Starfire13
13a6400286 Add LST to final_countdown_2.yaml 2023-06-27 17:55:21 -07:00
Dan Albert
f1e9abd157 Test SupplyRoute. 2023-06-27 00:28:07 -07:00
Dan Albert
eeacc79cb6 Add test for SplitLines. 2023-06-26 23:53:38 -07:00
Dan Albert
054b422cad Move WW2 factions to WW2 transports.
Only for those that require the WW2 asset pack. There don't appear to be
any free WW2 transports.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3039.
2023-06-26 23:04:35 -07:00
Dan Albert
d54d906593 Make loadout/properties tab scrollable.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3044.
2023-06-26 22:51:35 -07:00
Dan Albert
bb36b8cad3 Allow factions to specify their cargo ship type.
https://github.com/dcs-liberation/dcs_liberation/issues/3039
2023-06-26 22:16:47 -07:00
Dan Albert
c482b497db Add unit data for the Handy Wind. 2023-06-26 22:16:47 -07:00
Dan Albert
27a60fd91e Prevent Samuel Chase from being in AAA/SHORAD.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2938.
2023-06-26 21:56:57 -07:00
Dan Albert
cc2dfa5d35 Fix off-by-one error in livery selector. 2023-06-26 19:36:44 -07:00
Dan Albert
f7b0dfc3a5 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.
2023-06-26 18:50:06 -07:00
Dan Albert
4e90c724bf 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.
2023-06-26 17:54:34 -07:00
Starfire13
fc90b6f2df Update Starfire's campaigns.
* Added Razbam Strike Eagle options.
2023-06-26 17:54:22 -07:00
Starfire13
266c453c99 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.
2023-06-25 23:05:15 -07:00
Dan Albert
658a86dff5 Add radio config for the new F-15E.
Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3028.
2023-06-22 20:53:06 -07:00
Dan Albert
d31644c46a Razbam F-15E banner and icon.
Just reusing the old one.

https://github.com/dcs-liberation/dcs_liberation/issues/3028
2023-06-22 20:45:47 -07:00
Dan Albert
f27c9f5a3d Add Razbam F-15E to factions with the old F-15E.
https://github.com/dcs-liberation/dcs_liberation/issues/3028
2023-06-22 20:45:47 -07:00
Dan Albert
f805febd43 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
2023-06-22 20:45:47 -07:00
Dan Albert
dca02fea31 Update pydcs (Strike Eagle).
https://github.com/dcs-liberation/dcs_liberation/issues/3028
2023-06-22 20:45:47 -07:00
Starfire13
f97cd5d28f Add loadouts for Razbam F-15E Strike Eagle. 2023-06-22 17:47:05 -07:00
Dan Albert
acd40fd9ea Update bug templates for 8.0.0. 2023-06-21 17:19:17 -07:00
Dan Albert
dc0e41c8c1 Dump develop version to 9.0.0. 2023-06-20 18:47:07 -07:00
Dan Albert
59c10f5d71 Remove save compat hacks for saves from 7.
Save compat was broken by pydcs anyway, so these now do nothing but hide
initialization bugs.
2023-06-20 18:47:07 -07:00
393 changed files with 19296 additions and 4063 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:
- 7.1.0
- 9.0.0
- Development build
- type: textarea
attributes:
@@ -49,13 +49,19 @@ body:
required: true
- type: textarea
attributes:
label: Save game and other files
label: Save game and other files (save game required, bugs without saves will be closed)
description: >
Attach any files needed to reproduce the bug here. **A save game is
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.
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`.
Other useful files to include are:
@@ -76,6 +82,10 @@ 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:
- 7.1.0
- 9.0.0
- Development build
- type: textarea
attributes:

View File

@@ -11,7 +11,7 @@ jobs:
- uses: actions/setup-python@v2
- uses: psf/black@stable
with:
version: ~=22.12
version: ~=23.11
src: "."
options: "--check"

View File

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

View File

@@ -1,3 +1,86 @@
# 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
## Features/Improvements
* **[Engine]** Support for DCS 2.8.6.41363, including F-15E support.
* **[UI]** Flight loadout/properties tab is now scrollable.
## Fixes
* **[Campaign]** Fixed liveries for premade squadrons all being off-by-one.
* **[UI]** Fixed numbering of waypoints in the map and flight dialog (first waypoint is now 0 rather than 1).
# 8.0.0
Saves from 7.x are not compatible with 8.0.

2572
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.1.2",
"axios": "^1.6.0",
"electron-window-state": "^5.0.3",
"esri-leaflet": "^3.0.8",
"leaflet": "^1.9.2",
@@ -62,15 +62,16 @@
},
"devDependencies": {
"@rtk-query/codegen-openapi": "^1.0.0",
"@trivago/prettier-plugin-sort-imports": "^3.3.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.1",
"@types/leaflet": "^1.8.0",
"@types/redux-logger": "^3.0.9",
"@types/websocket": "^1.0.5",
"electron": "^21.1.0",
"electron": "^22.3.25",
"electron-is-dev": "^2.0.0",
"generate-license-file": "^2.0.0",
"identity-obj-proxy": "^3.0.0",
"jest-transform-stub": "^2.0.0",
"license-checker": "^25.0.1",
"msw": "^1.2.2",
"react-scripts": "5.0.1",
"ts-node": "^10.9.1",
"wait-on": "^6.0.1"
@@ -80,7 +81,7 @@
"node_modules/(?!(@?react-leaflet|axios)/)"
],
"moduleNameMapper": {
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "identity-obj-proxy"
".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub"
}
}
}

View File

@@ -50,14 +50,6 @@ 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
@@ -245,11 +237,6 @@ 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 = {
@@ -379,12 +366,6 @@ 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[][];
@@ -497,7 +478,6 @@ export const {
useSetControlPointDestinationMutation,
useClearControlPointDestinationMutation,
useGetDebugHoldZonesQuery,
useGetDebugIpZonesQuery,
useGetDebugJoinZonesQuery,
useListFlightsQuery,
useGetFlightByIdQuery,

View File

@@ -4,7 +4,10 @@ const backendAddr =
new URL(window.location.toString()).searchParams.get("server") ??
"[::1]:16880";
export const HTTP_URL = `http://${backendAddr}/`;
// 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 backend = axios.create({
baseURL: HTTP_URL,

View File

@@ -30,11 +30,6 @@ 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,7 +1,8 @@
import { Flight } from "../../api/liberationApi";
import { useGetCommitBoundaryForFlightQuery } from "../../api/liberationApi";
import WaypointMarker from "../waypointmarker";
import { ReactElement } from "react";
import { Polyline as LPolyline } from "leaflet";
import { ReactElement, useEffect, useRef } from "react";
import { Polyline } from "react-leaflet";
const BLUE_PATH = "#0084ff";
@@ -27,16 +28,41 @@ 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,8 +95,12 @@ describe("FlightPlansLayer", () => {
},
},
});
expect(mockPolyline).toHaveBeenCalledTimes(2);
expect(mockLayerGroup).toBeCalledTimes(1);
// 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);
});
it("are not drawn if wrong coalition", () => {
renderWithProviders(<FlightPlansLayer blue={true} />, {

View File

@@ -0,0 +1,16 @@
import SplitLines from "./SplitLines";
import { screen } from "@testing-library/dom";
import { render } from "@testing-library/react";
describe("SplitLines", () => {
it("joins items with line break tags", () => {
render(
<div data-testid={"container"}>
<SplitLines items={["foo", "bar", "baz"]} />
</div>
);
const container = screen.getByTestId("container");
expect(container).toContainHTML("foo<br />bar<br />baz<br />");
});
});

View File

@@ -0,0 +1,159 @@
import { renderWithProviders } from "../../testutils";
import SupplyRoute, { RouteColor } from "./SupplyRoute";
import { screen } from "@testing-library/react";
import { PropsWithChildren } from "react";
const mockPolyline = jest.fn();
jest.mock("react-leaflet", () => ({
Polyline: (props: PropsWithChildren<any>) => {
mockPolyline(props);
return <>{props.children}</>;
},
Tooltip: (props: PropsWithChildren<any>) => {
return <p data-testid="tooltip">{props.children}</p>;
},
}));
describe("SupplyRoute", () => {
it("is red when inactive and owned by opfor", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: false,
is_sea: false,
blue: false,
active_transports: [],
}}
/>
);
expect(mockPolyline).toHaveBeenCalledWith(
expect.objectContaining({
color: RouteColor.Red,
})
);
});
it("is blue when inactive and owned by bluefor", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: false,
is_sea: false,
blue: true,
active_transports: [],
}}
/>
);
expect(mockPolyline).toHaveBeenCalledWith(
expect.objectContaining({
color: RouteColor.Blue,
})
);
});
it("is orange when contested", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: true,
is_sea: false,
blue: false,
active_transports: [],
}}
/>
);
expect(mockPolyline).toHaveBeenCalledWith(
expect.objectContaining({
color: RouteColor.Contested,
})
);
});
it("is highlighted when the route has active transports", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: false,
is_sea: false,
blue: false,
active_transports: ["foo"],
}}
/>
);
expect(mockPolyline).toHaveBeenCalledTimes(2);
expect(mockPolyline).toHaveBeenCalledWith(
expect.objectContaining({
color: RouteColor.Highlight,
})
);
});
it("is drawn in the right place", () => {
const points = [
{ lat: 0, lng: 0 },
{ lat: 1, lng: 1 },
];
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: points,
front_active: false,
is_sea: false,
blue: false,
active_transports: ["foo"],
}}
/>
);
expect(mockPolyline).toHaveBeenCalledTimes(2);
expect(mockPolyline).toHaveBeenCalledWith(
expect.objectContaining({
positions: points,
})
);
});
it("has a tooltip describing an inactive supply route", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: false,
is_sea: false,
blue: false,
active_transports: [],
}}
/>
);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toHaveTextContent("This supply route is inactive.");
});
it("has a tooltip describing active supply routes", () => {
renderWithProviders(
<SupplyRoute
route={{
id: "",
points: [],
front_active: false,
is_sea: false,
blue: false,
active_transports: ["foo", "bar"],
}}
/>
);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toContainHTML("foo<br />bar");
});
});

View File

@@ -4,6 +4,13 @@ import { Polyline as LPolyline } from "leaflet";
import { useEffect, useRef } from "react";
import { Polyline, Tooltip } from "react-leaflet";
export enum RouteColor {
Blue = "#2d3e50",
Contested = "#c85050",
Highlight = "#ffffff",
Red = "#8c1414",
}
interface SupplyRouteProps {
route: SupplyRouteModel;
}
@@ -26,18 +33,22 @@ function ActiveSupplyRouteHighlight(props: SupplyRouteProps) {
}
return (
<Polyline positions={props.route.points} color={"#ffffff"} weight={2} />
<Polyline
positions={props.route.points}
color={RouteColor.Highlight}
weight={2}
/>
);
}
function colorFor(route: SupplyRouteModel) {
if (route.front_active) {
return "#c85050";
return RouteColor.Contested;
}
if (route.blue) {
return "#2d3e50";
return RouteColor.Blue;
}
return "#8c1414";
return RouteColor.Red;
}
export default function SupplyRoute(props: SupplyRouteProps) {

View File

@@ -1,73 +0,0 @@
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,7 +1,6 @@
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";
@@ -16,9 +15,6 @@ 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

@@ -0,0 +1,277 @@
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,13 +3,23 @@ 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 { MutableRefObject, useCallback, useEffect, useRef } from "react";
import {
ForwardedRef,
MutableRefObject,
forwardRef,
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,
@@ -22,84 +32,84 @@ interface WaypointMarkerProps {
flight: Flight;
}
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 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 [putDestination] = useSetWaypointPositionMutation();
const [putDestination] = useSetWaypointPositionMutation();
const rebindTooltip = useCallback(() => {
if (marker.current === undefined) {
return;
}
const rebindTooltip = useCallback(() => {
if (marker.current === null) {
return;
}
const tooltip = marker.current.getTooltip();
if (tooltip === undefined) {
return;
}
const tooltip = marker.current.getTooltip();
if (tooltip === undefined) {
return;
}
const permanent = map.getZoom() >= 9;
marker.current
.unbindTooltip()
.bindTooltip(tooltip, { permanent: permanent });
}, [map]);
useMapEvent("zoomend", rebindTooltip);
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
);
});
useEffect(() => {
const waypoint = props.waypoint;
marker.current?.setTooltipContent(
`${props.number} ${waypoint.name}<br />` +
`${waypoint.altitude_ft.toFixed()} ft ${waypoint.altitude_reference}<br />` +
waypoint.timing
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>
);
});
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

@@ -0,0 +1,17 @@
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");
});
});

16
client/src/mergeRefs.ts Normal file
View File

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

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

View File

View File

@@ -10,19 +10,18 @@ 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
@@ -288,7 +287,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.name if unit.unit_type else unit.type.name
unit.unit_type.variant_id if unit.unit_type else unit.type.name
)
unit.position = PointWithHeading.from_point(
ground_object.position + unit.position,

View File

@@ -1,16 +1,17 @@
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,
@@ -26,6 +27,8 @@ if TYPE_CHECKING:
from game.squadrons import Squadron, Pilot
from game.theater import ControlPoint
from game.transfers import TransferOrder
from game.data.weapons import WeaponType
from .flightmember import FlightMember
from .flightplans.flightplan import FlightPlan
from .flighttype import FlightType
from .flightwaypoint import FlightWaypoint
@@ -52,17 +55,16 @@ class Flight(SidcDescribable):
self.country = country
self.coalition = squadron.coalition
self.squadron = squadron
self.flight_type = flight_type
self.squadron.claim_inventory(count)
if roster is None:
self.roster = FlightRoster(self.squadron, initial_size=count)
self.roster = FlightMembers(self, initial_size=count)
else:
self.roster = roster
self.roster = FlightMembers.from_roster(self, 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
@@ -95,6 +97,13 @@ 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()
@@ -149,10 +158,6 @@ 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:]
@@ -171,6 +176,9 @@ 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
@@ -196,6 +204,11 @@ 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}"
@@ -256,9 +269,9 @@ class Flight(SidcDescribable):
Killed(self.state.estimate_position(), self, self.squadron.settings)
)
events.update_flight(self)
for pilot in self.roster.pilots:
for pilot in self.roster.iter_pilots():
if pilot is not None:
results.kill_pilot(self, pilot)
def recreate_flight_plan(self) -> None:
self._flight_plan_builder.regenerate()
def recreate_flight_plan(self, dump_debug_info: bool = False) -> None:
self._flight_plan_builder.regenerate(dump_debug_info)

42
game/ato/flightmember.py Normal file
View File

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

91
game/ato/flightmembers.py Normal file
View File

@@ -0,0 +1,91 @@
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) -> AewcFlightPlan:
def build(self, dump_debug_info: bool = False) -> AewcFlightPlan:
return AewcFlightPlan(self.flight, self.layout())

View File

@@ -152,5 +152,5 @@ class Builder(IBuilder[AirAssaultFlightPlan, AirAssaultLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> AirAssaultFlightPlan:
def build(self, dump_debug_info: bool = False) -> AirAssaultFlightPlan:
return AirAssaultFlightPlan(self.flight, self.layout())

View File

@@ -155,5 +155,5 @@ class Builder(IBuilder[AirliftFlightPlan, AirliftLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> AirliftFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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_BAI, targets)
return self._build(FlightWaypointType.INGRESS_ANTI_SHIP, 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) -> AntiShipFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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) -> BaiFlightPlan:
def build(self, dump_debug_info: bool = False) -> BaiFlightPlan:
return BaiFlightPlan(self.flight, self.layout())

View File

@@ -66,5 +66,5 @@ class Builder(CapBuilder[BarCapFlightPlan, PatrollingLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> BarCapFlightPlan:
def build(self, dump_debug_info: bool = False) -> BarCapFlightPlan:
return BarCapFlightPlan(self.flight, self.layout())

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import copy
import random
from abc import ABC
from typing import Any, TYPE_CHECKING, TypeVar
@@ -26,6 +27,9 @@ 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.
@@ -34,8 +38,43 @@ 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:
raise PlanningError("Could not find any enemy airfields")
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
)
heading = Heading.from_degrees(
location.position.heading_between_point(closest_airfield.position)

View File

@@ -6,13 +6,15 @@ from datetime import timedelta
from typing import TYPE_CHECKING, Type
from game.theater import FrontLine
from game.utils import Distance, Speed, kph, meters
from game.utils import Distance, Speed, kph, meters, dcs_to_shapely_point
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
@@ -20,13 +22,13 @@ if TYPE_CHECKING:
@dataclass(frozen=True)
class CasLayout(PatrollingLayout):
target: FlightWaypoint
ingress: 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
@@ -59,23 +61,20 @@ class CasFlightPlan(PatrollingFlightPlan[CasLayout], UiZoneDisplay):
@property
def combat_speed_waypoints(self) -> set[FlightWaypoint]:
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
return {self.layout.ingress, self.layout.patrol_start, self.layout.patrol_end}
def ui_zone(self) -> UiZone:
midpoint = (
self.layout.patrol_start.position + self.layout.patrol_end.position
) / 2
return UiZone(
[self.layout.target.position],
[midpoint],
self.engagement_distance,
)
class Builder(IBuilder[CasFlightPlan, CasLayout]):
def layout(self) -> CasLayout:
def layout(self, dump_debug_info: bool) -> CasLayout:
location = self.package.target
if not isinstance(location, FrontLine):
@@ -86,46 +85,79 @@ class Builder(IBuilder[CasFlightPlan, CasLayout]):
)
bounds = FrontLineConflictDescription.frontline_bounds(location, self.theater)
ingress = bounds.left_position
center = bounds.center
egress = bounds.right_position
patrol_start = bounds.left_position
patrol_end = bounds.right_position
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
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
builder = WaypointBuilder(self.flight, self.coalition)
is_helo = self.flight.unit_type.dcs_unit_type.helicopter
ingress_egress_altitude = (
self.doctrine.ingress_altitude if not is_helo else meters(50)
patrol_altitude = self.doctrine.ingress_altitude if not is_helo else meters(50)
use_agl_patrol_altitude = is_helo
ip_solver = IpSolver(
dcs_to_shapely_point(self.flight.departure.position),
dcs_to_shapely_point(patrol_start),
self.doctrine,
self.threat_zones.all,
)
use_agl_ingress_egress = is_helo
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}"
return CasLayout(
departure=builder.takeoff(self.flight.departure),
nav_to=builder.nav_path(
self.flight.departure.position,
ingress,
ingress_egress_altitude,
use_agl_ingress_egress,
ingress_point,
patrol_altitude,
use_agl_patrol_altitude,
),
nav_from=builder.nav_path(
egress,
patrol_end,
self.flight.arrival.position,
ingress_egress_altitude,
use_agl_ingress_egress,
patrol_altitude,
use_agl_patrol_altitude,
),
patrol_start=builder.ingress(
FlightWaypointType.INGRESS_CAS, ingress, location
),
target=builder.cas(center),
patrol_end=builder.egress(egress, location),
ingress=ingress,
patrol_start=patrol_start_waypoint,
patrol_end=patrol_end_waypoint,
arrival=builder.land(self.flight.arrival),
divert=builder.divert(self.flight.divert),
bullseye=builder.bullseye(),
)
def build(self) -> CasFlightPlan:
return CasFlightPlan(self.flight, self.layout())
def build(self, dump_debug_info: bool = False) -> CasFlightPlan:
return CasFlightPlan(self.flight, self.layout(dump_debug_info))

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) -> CustomFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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) -> DeadFlightPlan:
def build(self, dump_debug_info: bool = False) -> DeadFlightPlan:
return DeadFlightPlan(self.flight, self.layout())

View File

@@ -50,5 +50,5 @@ class Builder(FormationAttackBuilder[EscortFlightPlan, FormationAttackLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> EscortFlightPlan:
def build(self, dump_debug_info: bool = False) -> EscortFlightPlan:
return EscortFlightPlan(self.flight, self.layout())

View File

@@ -83,5 +83,5 @@ class Builder(IBuilder[FerryFlightPlan, FerryLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> FerryFlightPlan:
def build(self, dump_debug_info: bool = False) -> FerryFlightPlan:
return FerryFlightPlan(self.flight, self.layout())

View File

@@ -12,7 +12,6 @@ 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,11 +19,9 @@ from game.utils import Distance, Speed, meters
from .planningerror import PlanningError
from ..flightwaypointtype import FlightWaypointType
from ..starttype import StartType
from ..traveltime import GroundSpeed, TravelTime
from ...savecompat import has_save_compat_for
from ..traveltime import GroundSpeed
if TYPE_CHECKING:
from game.dcs.aircrafttype import FuelConsumption
from game.theater import ControlPoint
from ..flight import Flight
from ..flightwaypoint import FlightWaypoint
@@ -33,14 +30,6 @@ 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):
@@ -65,12 +54,6 @@ 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
@@ -160,39 +143,6 @@ 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.
@@ -221,7 +171,7 @@ class FlightPlan(ABC, Generic[LayoutT]):
)
for previous_waypoint, waypoint in self.edges(until=destination):
total += self.travel_time_between_waypoints(previous_waypoint, waypoint)
total += self.total_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
@@ -230,12 +180,23 @@ 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:
return TravelTime.between_points(
a.position, b.position, self.speed_between_waypoints(a, b)
)
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)
def tot_for_waypoint(self, waypoint: FlightWaypoint) -> datetime | None:
raise NotImplementedError
@@ -244,24 +205,21 @@ class FlightPlan(ABC, Generic[LayoutT]):
raise NotImplementedError
def request_escort_at(self) -> FlightWaypoint | None:
return None
try:
return next(self.escorted_waypoints())
except StopIteration:
return None
def dismiss_escort_at(self) -> FlightWaypoint | None:
return None
try:
return list(self.escorted_waypoints())[-1]
except IndexError:
return None
def escorted_waypoints(self) -> Iterator[FlightWaypoint]:
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:
for waypoint in self.iter_waypoints():
if waypoint.wants_escort:
yield waypoint
if waypoint == end:
return
def takeoff_time(self) -> datetime:
return self.tot - self._travel_time_to_waypoint(self.tot_waypoint)
@@ -290,7 +248,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.from_cp.is_fleet:
if self.flight.departure.is_fleet:
return timedelta(minutes=2)
else:
return timedelta(minutes=8)
@@ -311,7 +269,9 @@ class FlightPlan(ABC, Generic[LayoutT]):
raise NotImplementedError
@self_type_guard
def is_loiter(self, flight_plan: FlightPlan[Any]) -> TypeGuard[LoiterFlightPlan]:
def is_loiter(
self, flight_plan: FlightPlan[Any]
) -> TypeGuard[LoiterFlightPlan[Any]]:
return False
@self_type_guard
@@ -323,5 +283,8 @@ class FlightPlan(ABC, Generic[LayoutT]):
@self_type_guard
def is_formation(
self, flight_plan: FlightPlan[Any]
) -> TypeGuard[FormationFlightPlan]:
) -> TypeGuard[FormationFlightPlan[Any]]:
return False
def add_waypoint_actions(self) -> None:
pass

View File

@@ -4,13 +4,12 @@ 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
from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar
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
@@ -25,7 +24,10 @@ class FormationLayout(LoiterLayout, ABC):
nav_from: list[FlightWaypoint]
class FormationFlightPlan(LoiterFlightPlan, ABC):
LayoutT = TypeVar("LayoutT", bound=FormationLayout)
class FormationFlightPlan(LoiterFlightPlan[LayoutT], ABC):
@property
@abstractmethod
def package_speed_waypoints(self) -> set[FlightWaypoint]:
@@ -35,12 +37,6 @@ class FormationFlightPlan(LoiterFlightPlan, ABC):
def combat_speed_waypoints(self) -> set[FlightWaypoint]:
return self.package_speed_waypoints
def request_escort_at(self) -> FlightWaypoint | None:
return self.layout.join
def dismiss_escort_at(self) -> FlightWaypoint | None:
return self.layout.split
@cached_property
def best_flight_formation_speed(self) -> Speed:
"""The best speed this flight is capable at all formation waypoints.
@@ -90,10 +86,8 @@ class FormationFlightPlan(LoiterFlightPlan, ABC):
@property
def push_time(self) -> datetime:
return self.join_time - TravelTime.between_points(
self.layout.hold.position,
self.layout.join.position,
GroundSpeed.for_flight(self.flight, self.layout.hold.alt),
return self.join_time - self.travel_time_between_waypoints(
self.layout.hold, self.layout.join
)
@property
@@ -107,5 +101,5 @@ class FormationFlightPlan(LoiterFlightPlan, ABC):
@self_type_guard
def is_formation(
self, flight_plan: FlightPlan[Any]
) -> TypeGuard[FormationFlightPlan]:
) -> TypeGuard[FormationFlightPlan[Any]]:
return True

View File

@@ -14,7 +14,6 @@ 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
@@ -24,7 +23,29 @@ if TYPE_CHECKING:
from ..flight import Flight
class FormationAttackFlightPlan(FormationFlightPlan, ABC):
@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):
@property
def package_speed_waypoints(self) -> set[FlightWaypoint]:
return {
@@ -56,42 +77,19 @@ class FormationAttackFlightPlan(FormationFlightPlan, 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.travel_time_between_waypoints(
travel_time = self.total_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.travel_time_between_waypoints(
travel_time_ingress = self.total_time_between_waypoints(
self.layout.ingress, self.target_area_waypoint
)
travel_time_egress = self.travel_time_between_waypoints(
travel_time_egress = self.total_time_between_waypoints(
self.target_area_waypoint, self.layout.split
)
minutes_at_target = 0.75 * len(self.layout.targets)
@@ -106,7 +104,7 @@ class FormationAttackFlightPlan(FormationFlightPlan, ABC):
@property
def ingress_time(self) -> datetime:
tot = self.tot
travel_time = self.travel_time_between_waypoints(
travel_time = self.total_time_between_waypoints(
self.layout.ingress, self.target_area_waypoint
)
return tot - travel_time
@@ -119,28 +117,6 @@ class FormationAttackFlightPlan(FormationFlightPlan, 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)
@@ -169,7 +145,18 @@ 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(
@@ -179,9 +166,7 @@ class FormationAttackBuilder(IBuilder[FlightPlanT, LayoutT], ABC):
hold.position, join.position, self.doctrine.ingress_altitude
),
join=join,
ingress=builder.ingress(
ingress_type, self.package.waypoints.ingress, self.package.target
),
ingress=ingress,
targets=target_waypoints,
split=split,
refuel=refuel,

View File

@@ -32,10 +32,11 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
assert self._flight_plan is not None
return self._flight_plan
def regenerate(self) -> None:
def regenerate(self, dump_debug_info: bool = False) -> None:
try:
self._generate_package_waypoints_if_needed()
self._flight_plan = self.build()
self._generate_package_waypoints_if_needed(dump_debug_info)
self._flight_plan = self.build(dump_debug_info)
self._flight_plan.add_waypoint_actions()
except NavMeshError as ex:
color = "blue" if self.flight.squadron.player else "red"
raise PlanningError(
@@ -43,10 +44,15 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
f"{self.flight.departure} to {self.package.target}"
) from ex
def _generate_package_waypoints_if_needed(self) -> None:
if self.package.waypoints is None:
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:
self.package.waypoints = PackageWaypoints.create(
self.package, self.coalition
self.package, self.coalition, dump_debug_info
)
@property
@@ -54,11 +60,7 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]):
return self.flight.departure.theater
@abstractmethod
def layout(self) -> LayoutT:
...
@abstractmethod
def build(self) -> FlightPlanT:
def build(self, dump_debug_info: bool = False) -> FlightPlanT:
...
@property

View File

@@ -3,9 +3,11 @@ 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
from typing import Any, TYPE_CHECKING, TypeGuard, TypeVar
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
@@ -18,7 +20,10 @@ class LoiterLayout(StandardLayout, ABC):
hold: FlightWaypoint
class LoiterFlightPlan(StandardFlightPlan[Any], ABC):
LayoutT = TypeVar("LayoutT", bound=LoiterLayout)
class LoiterFlightPlan(StandardFlightPlan[LayoutT], ABC):
@property
def hold_duration(self) -> timedelta:
return timedelta(minutes=5)
@@ -33,14 +38,26 @@ class LoiterFlightPlan(StandardFlightPlan[Any], ABC):
return self.push_time
return None
def travel_time_between_waypoints(
def total_time_between_waypoints(
self, a: FlightWaypoint, b: FlightWaypoint
) -> timedelta:
travel_time = super().travel_time_between_waypoints(a, b)
travel_time = super().total_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]:
def is_loiter(
self, flight_plan: FlightPlan[Any]
) -> TypeGuard[LoiterFlightPlan[Any]]:
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) -> OcaAircraftFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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) -> OcaRunwayFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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.travel_time_between_waypoints(
delay_target_to_split: timedelta = self.total_time_between_waypoints(
self.target_area_waypoint(), split_waypoint
)
delay_split_to_refuel: timedelta = self.travel_time_between_waypoints(
delay_split_to_refuel: timedelta = self.total_time_between_waypoints(
split_waypoint, refuel_waypoint
)
@@ -121,5 +121,5 @@ class Builder(IBuilder[PackageRefuelingFlightPlan, PatrollingLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> PackageRefuelingFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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.package.time_over_target
return self.tot
@property
def patrol_end_time(self) -> datetime:

View File

@@ -93,5 +93,5 @@ class Builder(IBuilder[RtbFlightPlan, RtbLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> RtbFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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) -> SeadFlightPlan:
def build(self, dump_debug_info: bool = False) -> SeadFlightPlan:
return SeadFlightPlan(self.flight, self.layout())

View File

@@ -65,7 +65,6 @@ 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,
@@ -91,5 +90,5 @@ class Builder(IBuilder[RecoveryTankerFlightPlan, RecoveryTankerLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> RecoveryTankerFlightPlan:
def build(self, dump_debug_info: bool = False) -> 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) -> StrikeFlightPlan:
def build(self, dump_debug_info: bool = False) -> StrikeFlightPlan:
return StrikeFlightPlan(self.flight, self.layout())

View File

@@ -5,13 +5,15 @@ from datetime import datetime, timedelta
from typing import Iterator, TYPE_CHECKING, Type
from dcs import Point
from dcs.task import Targets
from game.utils import Heading
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 .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
@@ -37,7 +39,7 @@ class SweepLayout(LoiterLayout):
yield self.bullseye
class SweepFlightPlan(LoiterFlightPlan):
class SweepFlightPlan(LoiterFlightPlan[SweepLayout]):
@staticmethod
def builder_type() -> Type[Builder]:
return Builder
@@ -55,7 +57,7 @@ class SweepFlightPlan(LoiterFlightPlan):
@property
def sweep_start_time(self) -> datetime:
travel_time = self.travel_time_between_waypoints(
travel_time = self.total_time_between_waypoints(
self.layout.sweep_start, self.layout.sweep_end
)
return self.sweep_end_time - travel_time
@@ -78,10 +80,8 @@ class SweepFlightPlan(LoiterFlightPlan):
@property
def push_time(self) -> datetime:
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),
return self.sweep_end_time - self.travel_time_between_waypoints(
self.layout.hold, self.layout.sweep_end
)
@property
@@ -92,6 +92,19 @@ class SweepFlightPlan(LoiterFlightPlan):
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:
@@ -137,5 +150,5 @@ class Builder(IBuilder[SweepFlightPlan, SweepLayout]):
target, origin, ip, join, self.coalition, self.theater
).find_best_hold_point()
def build(self) -> SweepFlightPlan:
def build(self, dump_debug_info: bool = False) -> SweepFlightPlan:
return SweepFlightPlan(self.flight, self.layout())

View File

@@ -122,5 +122,5 @@ class Builder(CapBuilder[TarCapFlightPlan, TarCapLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> TarCapFlightPlan:
def build(self, dump_debug_info: bool = False) -> TarCapFlightPlan:
return TarCapFlightPlan(self.flight, self.layout())

View File

@@ -79,5 +79,5 @@ class Builder(IBuilder[TheaterRefuelingFlightPlan, PatrollingLayout]):
bullseye=builder.bullseye(),
)
def build(self) -> TheaterRefuelingFlightPlan:
def build(self, dump_debug_info: bool = False) -> TheaterRefuelingFlightPlan:
return TheaterRefuelingFlightPlan(self.flight, self.layout())

View File

@@ -168,6 +168,9 @@ class WaypointBuilder:
"HOLD",
FlightWaypointType.LOITER,
position,
# Bug: DCS only accepts MSL altitudes for the orbit task and 500 meters is
# below the ground for most if not all of NTTR (and lots of places in other
# maps).
meters(500) if self.is_helo else self.doctrine.rendezvous_altitude,
alt_type,
description="Wait until push time",
@@ -253,21 +256,6 @@ class WaypointBuilder:
targets=objective.strike_targets,
)
def egress(self, position: Point, target: MissionTarget) -> FlightWaypoint:
alt_type: AltitudeReference = "BARO"
if self.is_helo:
alt_type = "RADIO"
return FlightWaypoint(
"EGRESS",
FlightWaypointType.EGRESS,
position,
meters(60) if self.is_helo else self.doctrine.ingress_altitude,
alt_type,
description=f"EGRESS from {target.name}",
pretty_name=f"EGRESS from {target.name}",
)
def bai_group(self, target: StrikeTarget) -> FlightWaypoint:
return self._target_point(target, f"ATTACK {target.name}")
@@ -357,17 +345,6 @@ 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.

View File

@@ -1,29 +1,30 @@
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:
class FlightRoster(IFlightRoster):
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

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

@@ -1,12 +1,14 @@
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
@@ -37,6 +39,15 @@ 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:
@@ -51,17 +62,9 @@ class InFlight(FlightState, ABC):
return index <= self.waypoint_index
def travel_time_between_waypoints(self) -> timedelta:
travel_time = self.flight.flight_plan.travel_time_between_waypoints(
return 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:
@@ -88,7 +91,6 @@ 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
@@ -97,8 +99,6 @@ class InFlight(FlightState, ABC):
return Completed(self.flight, self.settings)
if self.next_waypoint.waypoint_type is FlightWaypointType.PATROL_TRACK:
return RaceTrack(self.flight, self.settings, new_index)
if self.next_waypoint.waypoint_type is FlightWaypointType.LOITER:
return Loiter(self.flight, self.settings, new_index)
return Navigating(self.flight, self.settings, new_index)
def advance_to_next_waypoint(self) -> FlightState:
@@ -110,6 +110,13 @@ 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()
@@ -160,11 +167,3 @@ 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

@@ -1,46 +0,0 @@
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,6 +29,11 @@ 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()
@@ -80,3 +85,14 @@ 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

@@ -7,6 +7,8 @@ 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
@@ -39,6 +41,11 @@ 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
@@ -46,6 +53,12 @@ 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 # Should do CAS there
CAS = 8 # Unused.
EGRESS = 9 # Should stop attack
DESCENT_POINT = 10 # Should start descending to pattern alt
LANDING_POINT = 11 # Should land there
@@ -50,3 +50,4 @@ 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

34
game/ato/iflightroster.py Normal file
View File

@@ -0,0 +1,34 @@
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,9 +1,10 @@
from __future__ import annotations
import copy
import datetime
import logging
from collections.abc import Iterable
from typing import Iterator, Mapping, Optional, TYPE_CHECKING, Type
from typing import Iterator, Mapping, Optional, TYPE_CHECKING, Type, Any
from dcs.unittype import FlyingType
@@ -35,6 +36,11 @@ 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:
@@ -108,6 +114,24 @@ 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)
@@ -125,14 +149,15 @@ class Loadout:
payloads = aircraft.dcs_unit_type.load_payloads()
for payload in payloads.values():
name = payload["name"]
pylons = payload["pylons"]
try:
pylon_assignments = {
p["num"]: Weapon.with_clsid(p["CLSID"]) for p in pylons.values()
}
pylon_assignments = cls.convert_dcs_loadout_to_pylon_map(
payload["pylons"]
)
except KeyError:
logging.exception(
"Ignoring %s loadout with invalid weapons: %s", aircraft.name, name
"Ignoring %s loadout with invalid weapons: %s",
aircraft.variant_id,
name,
)
continue
@@ -204,7 +229,25 @@ class Loadout:
payload = dcs_unit_type.loadout_by_name(name)
if payload is not None:
try:
pylons = {i: Weapon.with_clsid(d["clsid"]) for i, d in payload}
# 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)
}
)
except KeyError:
logging.exception(
"Ignoring %s loadout with invalid weapons: %s",

View File

@@ -6,8 +6,11 @@ from typing import TYPE_CHECKING
from dcs import Point
from game.ato.flightplans.waypointbuilder import WaypointBuilder
from game.flightplan import IpZoneGeometry, JoinZoneGeometry
from game.flightplan import JoinZoneGeometry
from game.flightplan.ipsolver import IpSolver
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
@@ -22,15 +25,28 @@ class PackageWaypoints:
refuel: Point
@staticmethod
def create(package: Package, coalition: Coalition) -> PackageWaypoints:
def create(
package: Package, coalition: Coalition, dump_debug_info: bool
) -> PackageWaypoints:
origin = package.departure_closest_to_target()
# Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry(
package.target.position,
origin.position,
coalition,
).find_best_ip()
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
)
join_point = JoinZoneGeometry(
package.target.position,

View File

@@ -1,14 +0,0 @@
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,17 +1,9 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime
from typing import TYPE_CHECKING
from dcs.mapping import Point
from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
mach,
meters,
)
from game.utils import Distance, SPEED_OF_SOUND_AT_SEA_LEVEL, Speed, mach
if TYPE_CHECKING:
from .flight import Flight
@@ -26,6 +18,9 @@ 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:
@@ -42,14 +37,6 @@ 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

@@ -17,6 +17,7 @@ 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
@@ -123,7 +124,15 @@ class Campaign:
) from ex
with logged_duration("Importing miz data"):
MizCampaignLoader(self.path.parent / miz, t).populate_theater()
MizCampaignLoader(
self.path.parent / miz,
t,
dict(
ControlPointConfig.iter_from_data(
self.data.get("control_points", {})
)
),
).populate_theater()
# TODO: Move into MizCampaignLoader so this doesn't have unknown initialization
# in ConflictTheater.

View File

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

@@ -0,0 +1,21 @@
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,7 +29,6 @@ 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,
@@ -162,7 +161,6 @@ class DefaultSquadronAssigner:
def override_squadron_defaults(
squadron_def: Optional[SquadronDef], config: SquadronConfig
) -> Optional[SquadronDef]:
if squadron_def is None:
return None

View File

@@ -12,20 +12,14 @@ 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 (
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
OffMapSpawn,
)
from game.theater.controlpoint import ControlPoint
from game.theater.presetlocation import PresetLocation
if TYPE_CHECKING:
@@ -92,8 +86,14 @@ class MizCampaignLoader:
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
def __init__(
self,
miz: Path,
theater: ConflictTheater,
control_point_configs: dict[str | int, ControlPointConfig],
) -> None:
self.theater = theater
self.control_point_builder = ControlPointBuilder(theater, control_point_configs)
self.mission = Mission()
with logged_duration("Loading miz"):
self.mission.load_file(str(miz))
@@ -105,15 +105,6 @@ 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
@@ -240,36 +231,49 @@ class MizCampaignLoader:
@cached_property
def control_points(self) -> dict[UUID, ControlPoint]:
control_points = {}
control_points: dict[UUID, ControlPoint] = {}
control_point: ControlPoint
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_point = self.control_point_builder.create_airfield(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
str(group.name), group.position, self.theater, starts_blue=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.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
control_point = Carrier(
ship.name, ship.position, self.theater, starts_blue=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.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
control_point = Lha(
ship.name, ship.position, self.theater, starts_blue=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.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name), fob.position, self.theater, starts_blue=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.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points

View File

@@ -26,6 +26,7 @@ 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
@@ -90,6 +91,10 @@ 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
@@ -158,12 +163,12 @@ class Coalition:
# is handled correctly.
self.transfers.perform_transfers()
def preinit_turn_0(self, squadrons_start_full: bool) -> None:
def preinit_turn_0(self) -> None:
"""Runs final Coalition initialization.
Final initialization occurs before Game.initialize_turn runs for turn 0.
"""
self.air_wing.populate_for_turn_0(squadrons_start_full)
self.air_wing.populate_for_turn_0()
def initialize_turn(self, is_turn_0: bool) -> None:
"""Processes coalition-specific turn initialization.
@@ -184,7 +189,7 @@ class Coalition:
with logged_duration("Transport planning"):
self.transfers.plan_transports(self.game.conditions.start_time)
if not is_turn_0 or not self.game.settings.enable_squadron_aircraft_limits:
if not is_turn_0:
self.plan_missions(self.game.conditions.start_time)
self.plan_procurement()

View File

@@ -25,6 +25,7 @@ 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)
@@ -193,17 +194,36 @@ 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 = 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
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,
)
if farthest is None:
raise RuntimeError("Found no friendly control points. You probably lost.")

View File

@@ -10,9 +10,10 @@ from ..ato.starttype import StartType
from ..db.database import Database
if TYPE_CHECKING:
from game.dcs.aircrafttype import AircraftType
from game.squadrons.airwing import AirWing
from game.ato.closestairfields import ClosestAirfields
from game.dcs.aircrafttype import AircraftType
from game.lasercodes import LaserCodeRegistry
from game.squadrons.airwing import AirWing
from .missionproposals import ProposedFlight
@@ -24,6 +25,7 @@ class PackageBuilder:
location: MissionTarget,
closest_airfields: ClosestAirfields,
air_wing: AirWing,
laser_code_registry: LaserCodeRegistry,
flight_db: Database[Flight],
is_player: bool,
package_country: str,
@@ -35,6 +37,7 @@ class PackageBuilder:
self.package_country = package_country
self.package = Package(location, flight_db, auto_asap=asap)
self.air_wing = air_wing
self.laser_code_registry = laser_code_registry
self.start_type = start_type
def plan_flight(self, plan: ProposedFlight) -> bool:
@@ -63,6 +66,11 @@ class PackageBuilder:
start_type,
divert=self.find_divert_field(squadron.aircraft, squadron.location),
)
for member in flight.iter_members():
if member.is_player:
member.assign_tgp_laser_code(
self.laser_code_registry.alloc_laser_code()
)
self.package.add_flight(flight)
return True

View File

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

View File

@@ -1,3 +1,9 @@
from __future__ import annotations
from pathlib import Path
import yaml
from typing import ClassVar
from dataclasses import dataclass
from datetime import timedelta
@@ -15,9 +21,21 @@ 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(frozen=True)
class Doctrine:
name: str
cas: bool
cap: bool
sead: bool
@@ -77,113 +95,78 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios
_by_name: ClassVar[dict[str, Doctrine]] = {}
_loaded: ClassVar[bool] = False
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,
}
),
)
@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
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,
}
),
)
@classmethod
def named(cls, name: str) -> Doctrine:
if not cls._loaded:
cls.load_all()
return cls._by_name[name]
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,
}
),
)
@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"],
cap=data["cap"],
cas=data["cas"],
sead=data["sead"],
strike=data["strike"],
antiship=data["antiship"],
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"]
),
ingress_altitude=feet(data["ingress_altitude_ft_msl"]),
min_patrol_altitude=feet(data["min_patrol_altitude_ft_msl"]),
max_patrol_altitude=feet(data["max_patrol_altitude_ft_msl"]),
pattern_altitude=feet(data["pattern_altitude_ft_msl"]),
cap_duration=timedelta(minutes=data["cap_duration_minutes"]),
cap_min_track_length=nautical_miles(
data["cap_min_track_length_nm"]
),
cap_max_track_length=nautical_miles(
data["cap_max_track_length_nm"]
),
cap_min_distance_from_cp=nautical_miles(
data["cap_min_distance_from_cp_nm"]
),
cap_max_distance_from_cp=nautical_miles(
data["cap_max_distance_from_cp_nm"]
),
cap_engagement_range=nautical_miles(
data["cap_engagement_range_nm"]
),
cas_duration=timedelta(minutes=data["cas_duration_minutes"]),
sweep_distance=nautical_miles(data["sweep_distance_nm"]),
ground_unit_procurement_ratios=GroundUnitProcurementRatios.from_dict(
data["ground_unit_procurement_ratios"]
),
)
)
cls._loaded = True

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Iterator, Optional, Any, ClassVar
import yaml
from dcs.unitgroup import FlyingGroup
from dcs.flyingunit import FlyingUnit
from dcs.weapons_data import weapon_ids
from game.dcs.aircrafttype import AircraftType
@@ -235,10 +235,10 @@ class Pylon:
# configuration.
return weapon in self.allowed or weapon.clsid == "<CLEAN>"
def equip(self, group: FlyingGroup[Any], weapon: Weapon) -> None:
def equip(self, unit: FlyingUnit, weapon: Weapon) -> None:
if not self.can_equip(weapon):
logging.error(f"Pylon {self.number} cannot equip {weapon.name}")
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
unit.load_pylon(self.make_pydcs_assignment(weapon), self.number)
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
return self.number, weapon.pydcs_data

View File

@@ -2,18 +2,18 @@ from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, replace as dataclasses_replace
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.unitproperty import UnitProperty
from game.dcs.lasercodeconfig import LaserCodeConfig
from game.dcs.unittype import UnitType
from game.radio.channels import (
ApacheChannelNamer,
@@ -182,6 +182,9 @@ 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]
@@ -205,6 +208,10 @@ 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
@@ -212,7 +219,7 @@ class AircraftType(UnitType[Type[FlyingType]]):
@classmethod
def register(cls, unit_type: AircraftType) -> None:
cls._by_name[unit_type.name] = unit_type
cls._by_name[unit_type.variant_id] = unit_type
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
@property
@@ -286,7 +293,9 @@ 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.name} max_speed * 0.7 is {max_speed * 0.7}")
logging.debug(
f"{self.display_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:
@@ -322,8 +331,18 @@ 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)
def iter_props(self) -> Iterator[UnitProperty[Any]]:
return UnitProperty.for_aircraft(self.dcs_unit_type)
@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 capable_of(self, task: FlightType) -> bool:
return task in self.task_priorities
@@ -333,7 +352,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["name"])
updated = AircraftType.named(state["variant_id"])
state.update(updated.__dict__)
self.__dict__.update(state)
@@ -374,37 +393,41 @@ class AircraftType(UnitType[Type[FlyingType]]):
@staticmethod
def _set_props_overrides(
config: Dict[str, Any], aircraft: Type[FlyingType], data_path: Path
config: Dict[str, Any], aircraft: Type[FlyingType]
) -> None:
if aircraft.property_defaults is None:
logging.warning(
f"'{data_path.name}' attempted to set default prop that does not exist."
f"'{aircraft.id}' 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"'{data_path.name}' attempted to set default prop '{k}' that does not exist"
f"'{aircraft.id}' attempted to set default prop '{k}' that does not exist"
)
@classmethod
def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
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:
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: {data_path}") from ex
raise KeyError(f"Missing required price field") from ex
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
@@ -447,46 +470,54 @@ class AircraftType(UnitType[Type[FlyingType]]):
prop_overrides = data.get("default_overrides")
if prop_overrides is not None:
cls._set_props_overrides(prop_overrides, aircraft, data_path)
cls._set_props_overrides(prop_overrides, aircraft)
task_priorities: dict[FlightType, int] = {}
for task_name, priority in data.get("tasks", {}).items():
task_priorities[FlightType(task_name)] = priority
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),
)
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),
)
def __hash__(self) -> int:
return hash(self.name)
return hash(self.variant_id)

View File

@@ -6,7 +6,6 @@ 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
@@ -65,9 +64,15 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
dict[type[VehicleType], list[GroundUnitType]]
] = defaultdict(list)
def __setstate__(self, state: dict[str, Any]) -> None:
# Update any existing models with new data on load.
updated = GroundUnitType.named(state["variant_id"])
state.update(updated.__dict__)
self.__dict__.update(state)
@classmethod
def register(cls, unit_type: GroundUnitType) -> None:
cls._by_name[unit_type.name] = unit_type
cls._by_name[unit_type.variant_id] = unit_type
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
@classmethod
@@ -87,15 +92,13 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
yield from vehicle_map.values()
@classmethod
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)
def _data_directory(cls) -> Path:
return Path("resources/units/ground_units")
@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:
@@ -110,23 +113,24 @@ class GroundUnitType(UnitType[Type[VehicleType]]):
else:
unit_class = UnitClass(class_name)
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),
)
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),
)

View File

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

View File

@@ -1,12 +1,10 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Iterator, Type
from typing import ClassVar, Iterator, Type, Any
import yaml
from dcs.ships import ship_map
from dcs.unittype import ShipType
@@ -21,9 +19,15 @@ class ShipUnitType(UnitType[Type[ShipType]]):
list
)
def __setstate__(self, state: dict[str, Any]) -> None:
# Update any existing models with new data on load.
updated = ShipUnitType.named(state["variant_id"])
state.update(updated.__dict__)
self.__dict__.update(state)
@classmethod
def register(cls, unit_type: ShipUnitType) -> None:
cls._by_name[unit_type.name] = unit_type
cls._by_name[unit_type.variant_id] = unit_type
cls._by_unit_type[unit_type.dcs_unit_type].append(unit_type)
@classmethod
@@ -43,15 +47,13 @@ class ShipUnitType(UnitType[Type[ShipType]]):
yield from ship_map.values()
@classmethod
def _each_variant_of(cls, ship: Type[ShipType]) -> Iterator[ShipUnitType]:
data_path = Path("resources/units/ships") / f"{ship.id}.yaml"
if not data_path.exists():
logging.warning(f"No data for {ship.id}; it will not be available")
return
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
def _data_directory(cls) -> Path:
return Path("resources/units/ships")
@classmethod
def _variant_from_dict(
cls, ship: Type[ShipType], variant_id: str, data: dict[str, Any]
) -> ShipUnitType:
try:
introduction = data["introduced"]
if introduction is None:
@@ -62,18 +64,19 @@ class ShipUnitType(UnitType[Type[ShipType]]):
class_name = data.get("class")
unit_class = UnitClass(class_name)
for variant in data.get("variants", [ship.id]):
yield ShipUnitType(
dcs_unit_type=ship,
unit_class=unit_class,
name=variant,
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
role=data.get("role", "No data."),
price=data.get("price"),
)
display_name = data.get("display_name", variant_id)
return ShipUnitType(
dcs_unit_type=ship,
unit_class=unit_class,
variant_id=variant_id,
display_name=data.get("display_name", variant_id),
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{display_name.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {display_name}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
role=data.get("role", "No data."),
price=data["price"],
)

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
import logging
from abc import ABC
from dataclasses import dataclass
from functools import cached_property
from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar
from pathlib import Path
from typing import ClassVar, Generic, Iterator, Self, Type, TypeVar, Any
import yaml
from dcs.unittype import UnitType as DcsUnitType
from game.data.units import UnitClass
@@ -15,7 +18,8 @@ DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
@dataclass(frozen=True)
class UnitType(ABC, Generic[DcsUnitTypeT]):
dcs_unit_type: DcsUnitTypeT
name: str
variant_id: str
display_name: str
description: str
year_introduced: str
country_of_origin: str
@@ -27,7 +31,7 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
_loaded: ClassVar[bool] = False
def __str__(self) -> str:
return self.name
return self.display_name
@property
def dcs_id(self) -> str:
@@ -49,8 +53,29 @@ class UnitType(ABC, Generic[DcsUnitTypeT]):
def each_dcs_type() -> Iterator[DcsUnitTypeT]:
raise NotImplementedError
@classmethod
def _data_directory(cls) -> Path:
raise NotImplementedError
@classmethod
def _each_variant_of(cls, unit: DcsUnitTypeT) -> Iterator[Self]:
data_path = cls._data_directory() / f"{unit.id}.yaml"
if not data_path.exists():
logging.warning(f"No data for {unit.id}; it will not be available")
return
with data_path.open(encoding="utf-8") as data_file:
data = yaml.safe_load(data_file)
for variant_id, variant_data in data.get("variants", {unit.id: {}}).items():
if variant_data is None:
variant_data = {}
yield cls._variant_from_dict(unit, variant_id, data | variant_data)
@classmethod
def _variant_from_dict(
cls, dcs_unit_type: DcsUnitTypeT, variant_id: str, data: dict[str, Any]
) -> Self:
raise NotImplementedError
@classmethod

View File

@@ -19,12 +19,7 @@ from game.data.building_data import (
WW2_FREE,
WW2_GERMANY_BUILDINGS,
)
from game.data.doctrine import (
COLDWAR_DOCTRINE,
Doctrine,
MODERN_DOCTRINE,
WWII_DOCTRINE,
)
from game.data.doctrine import Doctrine
from game.data.groups import GroupRole
from game.data.units import UnitClass
from game.dcs.aircrafttype import AircraftType
@@ -42,6 +37,9 @@ class Faction:
#: choose the default locale.
locales: Optional[List[str]]
# The unit type to spawn for cargo shipping.
cargo_ship: ShipUnitType
# Country used by this faction
country: str = field(default="")
@@ -103,7 +101,7 @@ class Faction:
jtac_unit: Optional[AircraftType] = field(default=None)
# doctrine
doctrine: Doctrine = field(default=MODERN_DOCTRINE)
doctrine: Doctrine = field(default=Doctrine.named("modern"))
# List of available building layouts for this faction
building_set: List[str] = field(default_factory=list)
@@ -156,7 +154,7 @@ class Faction:
def air_defenses(self) -> list[str]:
"""Returns the Air Defense types"""
# This is used for the faction overview in NewGameWizard
air_defenses = [a.name for a in self.air_defense_units]
air_defenses = [a.display_name for a in self.air_defense_units]
air_defenses.extend(
[
pg.name
@@ -168,7 +166,10 @@ class Faction:
@classmethod
def from_dict(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
faction = Faction(locales=json.get("locales"))
faction = Faction(
locales=json.get("locales"),
cargo_ship=ShipUnitType.named(json.get("cargo_ship", "Handy Wind")),
)
faction.country = json.get("country", "/")
if faction.country not in [c.name for c in country_dict.values()]:
@@ -232,14 +233,7 @@ class Faction:
# Load doctrine
doctrine = json.get("doctrine", "modern")
if doctrine == "modern":
faction.doctrine = MODERN_DOCTRINE
elif doctrine == "coldwar":
faction.doctrine = COLDWAR_DOCTRINE
elif doctrine == "ww2":
faction.doctrine = WWII_DOCTRINE
else:
faction.doctrine = MODERN_DOCTRINE
faction.doctrine = Doctrine.named(doctrine)
# Load the building set
faction.building_set = []

View File

@@ -1,3 +1,2 @@
from .holdzonegeometry import HoldZoneGeometry
from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry

173
game/flightplan/ipsolver.py Normal file
View File

@@ -0,0 +1,173 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from shapely.geometry import MultiPolygon, Point
from shapely.geometry.base import BaseGeometry
from game.data.doctrine import Doctrine
from game.flightplan.waypointsolver import WaypointSolver
from game.flightplan.waypointstrategy import WaypointStrategy
from game.utils import meters, nautical_miles
MIN_DISTANCE_FROM_DEPARTURE = nautical_miles(5)
class ThreatTolerantIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(target).min_distance_from(
departure, doctrine.min_ingress_distance
)
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(departure)
self.require().at_most(meters(departure.distance(target))).away_from(departure)
self.require().at_least(doctrine.min_ingress_distance).away_from(target)
max_ip_range = min(
doctrine.max_ingress_distance, meters(departure.distance(target))
)
self.require().at_most(max_ip_range).away_from(target)
self.threat_tolerance(target, max_ip_range, nautical_miles(5))
self.nearest(departure)
class UnsafeIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(target).min_distance_from(
departure, doctrine.min_ingress_distance
)
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
departure, "departure"
)
self.require().at_most(meters(departure.distance(target))).away_from(
departure, "departure"
)
self.require().at_least(doctrine.min_ingress_distance).away_from(
target, "target"
)
max_ip_range = min(
doctrine.max_ingress_distance, meters(departure.distance(target))
)
self.require().at_most(max_ip_range).away_from(target, "target")
self.nearest(departure)
class SafeIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(departure).is_safe()
self.prerequisite(target).min_distance_from(
departure, doctrine.min_ingress_distance
)
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
departure, "departure"
)
self.require().at_most(meters(departure.distance(target))).away_from(
departure, "departure"
)
self.require().at_least(doctrine.min_ingress_distance).away_from(
target, "target"
)
self.require().at_most(
min(doctrine.max_ingress_distance, meters(departure.distance(target)))
).away_from(target, "target")
self.require().safe()
self.nearest(departure)
class SafeBackTrackingIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
departure, "departure"
)
self.require().at_least(doctrine.min_ingress_distance).away_from(
target, "target"
)
self.require().at_most(doctrine.max_ingress_distance).away_from(
target, "target"
)
self.require().safe()
self.nearest(departure)
class UnsafeBackTrackingIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from(
departure, "departure"
)
self.require().at_least(doctrine.min_ingress_distance).away_from(
target, "target"
)
self.require().at_most(doctrine.max_ingress_distance).away_from(
target, "target"
)
self.nearest(departure)
class IpSolver(WaypointSolver):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__()
self.departure = departure
self.target = target
self.doctrine = doctrine
self.threat_zones = threat_zones
self.add_strategy(SafeIpStrategy(departure, target, doctrine, threat_zones))
self.add_strategy(
ThreatTolerantIpStrategy(departure, target, doctrine, threat_zones)
)
self.add_strategy(UnsafeIpStrategy(departure, target, doctrine, threat_zones))
self.add_strategy(
SafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
)
# TODO: The cases that require this are not covered by any tests.
self.add_strategy(
UnsafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones)
)
def describe_metadata(self) -> dict[str, Any]:
return {"doctrine": self.doctrine.name}
def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
yield "departure", self.departure
yield "target", self.target
yield "threat_zones", self.threat_zones

View File

@@ -1,119 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import MultiPolygon, Point as ShapelyPoint
from game.utils import meters, nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class IpZoneGeometry:
"""Defines the zones used for finding optimal IP placement.
The zones themselves are stored in the class rather than just the resulting IP so
that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
coalition: Coalition,
) -> None:
self._target = target
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
max_ip_distance = coalition.doctrine.max_ingress_distance
min_ip_distance = coalition.doctrine.min_ingress_distance
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
# The distance that is expected to be needed between the beginning of the attack
# and weapon release. This buffers the threat zone to give a 5nm window between
# the edge of the "safe" zone and the actual threat so that "safe" IPs are less
# likely to end up with the attacker entering a threatened area.
attack_distance_buffer = nautical_miles(5)
home_threatened = coalition.opponent.threat_zone.threatened(home)
shapely_target = ShapelyPoint(target.x, target.y)
home_to_target_distance = meters(home.distance_to_point(target))
self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
self.home.buffer(min_distance_from_home.meters)
)
# If the home zone is not threatened and home is within LAR, constrain the max
# range to the home-to-target distance to prevent excessive backtracking.
#
# If the home zone *is* threatened, we need to back out of the zone to
# rendezvous anyway.
if not home_threatened and (
min_ip_distance < home_to_target_distance < max_ip_distance
):
max_ip_distance = home_to_target_distance
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)
# The intersection of the home bubble and IP bubble will be all the points that
# are within the valid IP range that are not farther from home than the target
# is. However, if the origin airfield is threatened but there are safe
# placements for the IP, we should not constrain to the home zone. In this case
# we'll either end up with a safe zone outside the home zone and pick the
# closest point in to to home (minimizing backtracking), or we'll have no safe
# IP anywhere within range of the target, and we'll later pick the IP nearest
# the edge of the threat zone.
if home_threatened:
self.permissible_zone = self.ip_bubble
else:
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)
if self.permissible_zone.is_empty:
# If home is closer to the target than the min range, there will not be an
# IP solution that's close enough to home, in which case we need to ignore
# the home bubble.
self.permissible_zone = self.ip_bubble
safe_zones = self.permissible_zone.difference(
self.threat_zone.buffer(attack_distance_buffer.meters)
)
if not isinstance(safe_zones, MultiPolygon):
safe_zones = MultiPolygon([safe_zones])
self.safe_zones = safe_zones
def _unsafe_ip(self) -> ShapelyPoint:
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
if unthreatened_home_zone.is_empty:
# Nowhere in our home zone is safe. The package will need to exit the
# threatened area to hold and rendezvous. Pick the IP closest to the
# edge of the threat zone.
return shapely.ops.nearest_points(
self.permissible_zone, self.threat_zone.boundary
)[0]
# No safe point in the IP zone, but the home zone is safe. Pick the max-
# distance IP that's closest to the untreatened home zone.
return shapely.ops.nearest_points(
self.permissible_zone, unthreatened_home_zone
)[0]
def _safe_ip(self) -> ShapelyPoint:
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
# the IP in the zone that's closest to the target.
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
def find_best_ip(self) -> Point:
if self.safe_zones.is_empty:
ip = self._unsafe_ip()
else:
ip = self._safe_ip()
return self._target.new_in_same_map(ip.x, ip.y)

View File

@@ -97,6 +97,8 @@ class JoinZoneGeometry:
self.preferred_lines = preferred_lines
def find_best_join_point(self) -> Point:
# TODO: afaict the permissible_lines case is entirely unnecessary. The two
# definitions appear equivalent.
if self.preferred_lines.is_empty:
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
else:

View File

@@ -0,0 +1,35 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
import dcs.task
from dcs.task import Task
from game.ato.flightstate.actionstate import ActionState
from game.utils import Distance
from .taskcontext import TaskContext
from .waypointaction import WaypointAction
class EngageTargets(WaypointAction):
def __init__(
self,
max_distance_from_flight: Distance,
target_types: list[type[dcs.task.TargetType]],
) -> None:
self._max_distance_from_flight = max_distance_from_flight
self._target_types = target_types
def update_state(
self, state: ActionState, time: datetime, duration: timedelta
) -> timedelta:
state.finish()
return duration
def describe(self) -> str:
return "Searching for targets"
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
yield dcs.task.EngageTargets(
max_distance=int(self._max_distance_from_flight.meters),
targets=self._target_types,
)

View File

@@ -0,0 +1,57 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
from dcs.task import Task, OrbitAction, ControlledTask
from game.ato.flightstate.actionstate import ActionState
from game.provider import Provider
from game.utils import Distance, Speed
from .taskcontext import TaskContext
from .waypointaction import WaypointAction
class Hold(WaypointAction):
"""Loiter at a location until a push time to synchronize with other flights.
Taxi behavior is extremely unpredictable, so we cannot reliably predict ETAs for
waypoints without first fixing a time for one waypoint by holding until a sync time.
This is typically done with a dedicated hold point. If the flight reaches the hold
point before their push time, they will loiter at that location rather than fly to
their next waypoint as a speed that's often dangerously slow.
"""
def __init__(
self, push_time_provider: Provider[datetime], altitude: Distance, speed: Speed
) -> None:
self._push_time_provider = push_time_provider
self._altitude = altitude
self._speed = speed
def describe(self) -> str:
return self._push_time_provider().strftime("Holding until %H:%M:%S")
def update_state(
self, state: ActionState, time: datetime, duration: timedelta
) -> timedelta:
push_time = self._push_time_provider()
if push_time <= time:
state.finish()
return time - push_time
return timedelta()
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
remaining_time = self._push_time_provider() - ctx.mission_start_time
if remaining_time <= timedelta():
return
loiter = ControlledTask(
OrbitAction(
altitude=int(self._altitude.meters),
pattern=OrbitAction.OrbitPattern.Circle,
speed=self._speed.kph,
)
)
# The DCS task is serialized using the time from mission start, not the actual
# time.
loiter.stop_after_time(int(remaining_time.total_seconds()))
yield loiter

View File

@@ -0,0 +1,7 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class TaskContext:
mission_start_time: datetime

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterator
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from dcs.task import Task
from .taskcontext import TaskContext
if TYPE_CHECKING:
from game.ato.flightstate.actionstate import ActionState
class WaypointAction(ABC):
@abstractmethod
def describe(self) -> str:
...
@abstractmethod
def update_state(
self, state: ActionState, time: datetime, duration: timedelta
) -> timedelta:
...
@abstractmethod
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
...

View File

@@ -0,0 +1,21 @@
from collections.abc import Iterator
from enum import Enum
from dcs.task import OptFormation, Task
from game.flightplan.waypointactions.taskcontext import TaskContext
from game.flightplan.waypointoptions.waypointoption import WaypointOption
class Formation(WaypointOption, Enum):
FINGER_FOUR_CLOSE = OptFormation.finger_four_close()
FINGER_FOUR_OPEN = OptFormation.finger_four_open()
LINE_ABREAST_OPEN = OptFormation.line_abreast_open()
SPREAD_FOUR_OPEN = OptFormation.spread_four_open()
TRAIL_OPEN = OptFormation.trail_open()
def id(self) -> str:
return "formation"
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
yield self.value

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from collections.abc import Iterator
from dcs.task import Task
from game.flightplan.waypointactions.taskcontext import TaskContext
# Not explicitly an ABC because that prevents subclasses from deriving Enum.
class WaypointOption:
def id(self) -> str:
raise RuntimeError
def iter_tasks(self, ctx: TaskContext) -> Iterator[Task]:
raise RuntimeError

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
from dcs import Point
from dcs.mapping import Point as DcsPoint
from dcs.terrain import Terrain
from numpy import float64, array
from numpy._typing import NDArray
from shapely import transform, to_geojson
from shapely.geometry.base import BaseGeometry
if TYPE_CHECKING:
from .waypointstrategy import WaypointStrategy
class NoSolutionsError(RuntimeError):
pass
class WaypointSolver:
def __init__(self) -> None:
self.strategies: list[WaypointStrategy] = []
self.debug_output_directory: Path | None = None
self._terrain: Terrain | None = None
def add_strategy(self, strategy: WaypointStrategy) -> None:
self.strategies.append(strategy)
def set_debug_properties(self, path: Path, terrain: Terrain) -> None:
self.debug_output_directory = path
self._terrain = terrain
def to_geojson(self, geometry: BaseGeometry) -> dict[str, Any]:
if geometry.is_empty:
return json.loads(to_geojson(geometry))
assert self._terrain is not None
origin = DcsPoint(0, 0, self._terrain)
def xy_to_ll(points: NDArray[float64]) -> NDArray[float64]:
ll_points = []
for point in points:
p = origin.new_in_same_map(point[0], point[1])
latlng = p.latlng()
# Longitude is unintuitively first because it's the "X" coordinate:
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1
ll_points.append([latlng.lng, latlng.lat])
return array(ll_points)
transformed = transform(geometry, xy_to_ll)
return json.loads(to_geojson(transformed))
def describe_metadata(self) -> dict[str, Any]:
return {}
def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
yield from []
def describe_debug(self) -> dict[str, Any]:
assert self._terrain is not None
metadata = {"name": self.__class__.__name__, "terrain": self._terrain.name}
metadata.update(self.describe_metadata())
return {
"type": "FeatureCollection",
# The GeoJSON spec forbids us from adding a "properties" field to a feature
# collection, but it doesn't restrict us from adding our own custom fields.
# https://gis.stackexchange.com/a/209263
#
# It's possible that some consumers won't work with this, but we don't read
# collections directly with shapely and geojson.io is happy with it, so it
# works where we need it to.
"metadata": metadata,
"features": list(self.describe_features()),
}
def describe_features(self) -> Iterator[dict[str, Any]]:
for description, geometry in self.describe_inputs():
yield {
"type": "Feature",
"properties": {
"description": description,
},
"geometry": self.to_geojson(geometry),
}
def dump_debug_info(self) -> None:
path = self.debug_output_directory
if path is None:
return
path.mkdir(exist_ok=True, parents=True)
inputs_path = path / "solver.json"
with inputs_path.open("w", encoding="utf-8") as inputs_file:
json.dump(self.describe_debug(), inputs_file)
features = list(self.describe_features())
for idx, strategy in enumerate(self.strategies):
strategy_path = path / f"{idx}.json"
with strategy_path.open("w", encoding="utf-8") as strategy_debug_file:
json.dump(
{
"type": "FeatureCollection",
"metadata": {
"name": strategy.__class__.__name__,
"prerequisites": [
p.describe_debug_info(self.to_geojson)
for p in strategy.prerequisites
],
},
# Include the solver's features in the strategy feature
# collection for easy copy/paste into geojson.io.
"features": features
+ [
d.to_geojson(self.to_geojson)
for d in strategy.iter_debug_info()
],
},
strategy_debug_file,
)
def solve(self) -> Point:
if not self.strategies:
raise ValueError(
"WaypointSolver.solve() called before any strategies were added"
)
for strategy in self.strategies:
if (point := strategy.find()) is not None:
return point
self.dump_debug_info()
debug_details = "No debug output directory set"
if (debug_path := self.debug_output_directory) is not None:
debug_details = f"Debug details written to {debug_path}"
raise NoSolutionsError(f"No solutions found for waypoint. {debug_details}")

View File

@@ -0,0 +1,76 @@
import json
from functools import cached_property
from pathlib import Path
from typing import Any
from dcs.mapping import Point as DcsPoint, LatLng
from dcs.terrain import Terrain
from numpy import float64, array
from numpy._typing import NDArray
from shapely import transform
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry
from game.data.doctrine import Doctrine
from .ipsolver import IpSolver
from .waypointsolver import WaypointSolver
from ..theater.theaterloader import TERRAINS_BY_NAME
def doctrine_from_name(name: str) -> Doctrine:
return Doctrine.named(name)
def geometry_ll_to_xy(geometry: BaseGeometry, terrain: Terrain) -> BaseGeometry:
if geometry.is_empty:
return geometry
def ll_to_xy(points: NDArray[float64]) -> NDArray[float64]:
ll_points = []
for point in points:
# Longitude is unintuitively first because it's the "X" coordinate:
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1
p = DcsPoint.from_latlng(LatLng(point[1], point[0]), terrain)
ll_points.append([p.x, p.y])
return array(ll_points)
return transform(geometry, ll_to_xy)
class WaypointSolverLoader:
def __init__(self, debug_info_path: Path) -> None:
self.debug_info_path = debug_info_path
def load_data(self) -> dict[str, Any]:
with self.debug_info_path.open(encoding="utf-8") as debug_info_file:
return json.load(debug_info_file)
@staticmethod
def load_geometries(
feature_collection: dict[str, Any], terrain: Terrain
) -> dict[str, BaseGeometry]:
geometries = {}
for feature in feature_collection["features"]:
description = feature["properties"]["description"]
geometry = shape(feature["geometry"])
geometries[description] = geometry_ll_to_xy(geometry, terrain)
return geometries
@cached_property
def terrain(self) -> Terrain:
return TERRAINS_BY_NAME[self.load_data()["metadata"]["terrain"]]
def load(self) -> WaypointSolver:
data = self.load_data()
metadata = data["metadata"]
name = metadata.pop("name")
terrain_name = metadata.pop("terrain")
terrain = TERRAINS_BY_NAME[terrain_name]
if "doctrine" in metadata:
metadata["doctrine"] = doctrine_from_name(metadata["doctrine"])
geometries = self.load_geometries(data, terrain)
builder: type[WaypointSolver] = {
"IpSolver": IpSolver,
}[name]
metadata.update(geometries)
return builder(**metadata)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
import math
from abc import abstractmethod, ABC
from collections.abc import Iterator, Callable
from dataclasses import dataclass
from typing import Any
from dcs.mapping import heading_between_points
from shapely.geometry import Point, MultiPolygon, Polygon
from shapely.geometry.base import BaseGeometry as Geometry, BaseGeometry
from shapely.ops import nearest_points
from game.utils import Distance, nautical_miles, Heading
def angle_between_points(a: Point, b: Point) -> float:
return heading_between_points(a.x, a.y, b.x, b.y)
def point_at_heading(p: Point, heading: Heading, distance: Distance) -> Point:
rad_heading = heading.radians
return Point(
p.x + math.cos(rad_heading) * distance.meters,
p.y + math.sin(rad_heading) * distance.meters,
)
class Prerequisite(ABC):
@abstractmethod
def is_satisfied(self) -> bool:
...
@abstractmethod
def describe_debug_info(
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
) -> dict[str, Any]:
...
class DistancePrerequisite(Prerequisite):
def __init__(self, a: Point, b: Point, min_range: Distance) -> None:
self.a = a
self.b = b
self.min_range = min_range
def is_satisfied(self) -> bool:
return self.a.distance(self.b) >= self.min_range.meters
def describe_debug_info(
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
) -> dict[str, Any]:
return {
"requirement": f"at least {self.min_range} between",
"satisfied": self.is_satisfied(),
"subject": to_geojson(self.a),
"target": to_geojson(self.b),
}
class SafePrerequisite(Prerequisite):
def __init__(self, point: Point, threat_zones: MultiPolygon) -> None:
self.point = point
self.threat_zones = threat_zones
def is_satisfied(self) -> bool:
return not self.point.intersects(self.threat_zones)
def describe_debug_info(
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
) -> dict[str, Any]:
return {
"requirement": "is safe",
"satisfied": self.is_satisfied(),
"subject": to_geojson(self.point),
}
class PrerequisiteBuilder:
def __init__(
self, subject: Point, threat_zones: MultiPolygon, strategy: WaypointStrategy
) -> None:
self.subject = subject
self.threat_zones = threat_zones
self.strategy = strategy
def is_safe(self) -> None:
self.strategy.add_prerequisite(
SafePrerequisite(self.subject, self.threat_zones)
)
def min_distance_from(self, target: Point, distance: Distance) -> None:
self.strategy.add_prerequisite(
DistancePrerequisite(self.subject, target, distance)
)
@dataclass(frozen=True)
class ThreatTolerance:
target: Point
target_buffer: Distance
tolerance: Distance
class RequirementBuilder:
def __init__(self, threat_zones: MultiPolygon, strategy: WaypointStrategy) -> None:
self.threat_zones = threat_zones
self.strategy = strategy
def safe(self) -> None:
self.strategy.exclude_threat_zone()
def at_least(self, distance: Distance) -> DistanceRequirementBuilder:
return DistanceRequirementBuilder(self.strategy, min_distance=distance)
def at_most(self, distance: Distance) -> DistanceRequirementBuilder:
return DistanceRequirementBuilder(self.strategy, max_distance=distance)
def maximum_turn_to(
self, turn_point: Point, next_point: Point, turn_limit: Heading
) -> None:
large_distance = nautical_miles(400)
next_heading = Heading.from_degrees(
angle_between_points(next_point, turn_point)
)
limit_ccw = point_at_heading(
turn_point, next_heading - turn_limit, large_distance
)
limit_cw = point_at_heading(
turn_point, next_heading + turn_limit, large_distance
)
allowed_wedge = Polygon([turn_point, limit_ccw, limit_cw])
self.strategy.exclude(
f"restrict turn from {turn_point} to {next_point} to {turn_limit}",
turn_point.buffer(large_distance.meters).difference(allowed_wedge),
)
class DistanceRequirementBuilder:
def __init__(
self,
strategy: WaypointStrategy,
min_distance: Distance | None = None,
max_distance: Distance | None = None,
) -> None:
if min_distance is None and max_distance is None:
raise ValueError
self.strategy = strategy
self.min_distance = min_distance
self.max_distance = max_distance
def away_from(self, target: Point, description: str | None = None) -> None:
if description is None:
description = str(target)
if self.min_distance is not None:
self.strategy.exclude(
f"at least {self.min_distance} away from {description}",
target.buffer(self.min_distance.meters),
)
if self.max_distance is not None:
self.strategy.exclude_beyond(
f"at most {self.max_distance} away from {description}",
target.buffer(self.max_distance.meters),
)
@dataclass(frozen=True)
class WaypointDebugInfo:
description: str
geometry: BaseGeometry
def to_geojson(
self, to_geojson: Callable[[BaseGeometry], dict[str, Any]]
) -> dict[str, Any]:
return {
"type": "Feature",
"properties": {
"description": self.description,
},
"geometry": to_geojson(self.geometry),
}
class WaypointStrategy:
def __init__(self, threat_zones: MultiPolygon) -> None:
self.threat_zones = threat_zones
self.prerequisites: list[Prerequisite] = []
self._max_area = Point(0, 0).buffer(2_000_000)
self.allowed_area = self._max_area.buffer(0)
self.debug_infos: list[WaypointDebugInfo] = []
self._threat_tolerance: ThreatTolerance | None = None
self.point_for_nearest_solution: Point | None = None
def add_prerequisite(self, prerequisite: Prerequisite) -> None:
self.prerequisites.append(prerequisite)
def prerequisite(self, subject: Point) -> PrerequisiteBuilder:
return PrerequisiteBuilder(subject, self.threat_zones, self)
def exclude(self, description: str, geometry: Geometry) -> None:
self.debug_infos.append(WaypointDebugInfo(description, geometry))
self.allowed_area = self.allowed_area.difference(geometry)
def exclude_beyond(self, description: str, geometry: Geometry) -> None:
self.exclude(description, self._max_area.difference(geometry))
def exclude_threat_zone(self) -> None:
if (tolerance := self._threat_tolerance) is not None:
description = (
f"safe with a {tolerance.tolerance} tolerance to a "
f"{tolerance.target_buffer} radius about {tolerance.target}"
)
else:
description = "safe"
self.exclude(description, self.threat_zones)
def prerequisites_are_satisfied(self) -> bool:
for prereq in self.prerequisites:
if not prereq.is_satisfied():
return False
return True
def require(self) -> RequirementBuilder:
return RequirementBuilder(self.threat_zones, self)
def threat_tolerance(
self, target: Point, target_size: Distance, wiggle: Distance
) -> None:
if self.threat_zones.is_empty:
return
min_distance_from_threat_to_target_buffer = target.buffer(
target_size.meters
).distance(self.threat_zones.boundary)
threat_mask = self.threat_zones.buffer(
-min_distance_from_threat_to_target_buffer - wiggle.meters
)
self._threat_tolerance = ThreatTolerance(target, target_size, wiggle)
self.threat_zones = self.threat_zones.difference(threat_mask)
def nearest(self, point: Point) -> None:
if self.point_for_nearest_solution is not None:
raise RuntimeError("WaypointStrategy.nearest() called more than once")
self.point_for_nearest_solution = point
def find(self) -> Point | None:
if self.point_for_nearest_solution is None:
raise RuntimeError(
"Must call WaypointStrategy.nearest() before WaypointStrategy.find()"
)
if not self.prerequisites_are_satisfied():
return None
try:
return nearest_points(self.allowed_area, self.point_for_nearest_solution)[0]
except ValueError:
# No solutions.
return None
def iter_debug_info(self) -> Iterator[WaypointDebugInfo]:
yield from self.debug_infos
solution = self.find()
if solution is None:
return
yield WaypointDebugInfo("solution", solution)

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