Merge pull request #841 from Khopa/develop_2_4_x

2.4.0 Release
This commit is contained in:
Malakhit 2021-02-05 18:46:23 +00:00 committed by GitHub
commit c69e5e05a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
519 changed files with 9451 additions and 2723 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
Before filing, please search the issue tracker to see if the issue has already been reported.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Additional information**
We will usually need more information for debugging. Include as much of the following as you are able:
- DCS Liberation save file (the `.liberation` file you save from the DCS Liberation window). By default these are located in your DCS saved games directory (`%USERPROFILE%/Saved Games/DCS`).
- The generated mission file (the `.miz` file that you load in DCS to play the turn). By default these are located in your missions directory (`%USERPROFILE%/Saved Games/DCS/Missions`).
- A tacview track file, especially when demonstrating an issue with AI behavior. By default these are locaed in your Tacview tracks directory (`%USERPROFILE%/Documents/Tacview`).
**Version information (please complete the following information):**
- DCS Liberation [e.g. 2.3.1]:
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
Before filing, please search the issue tracker to see if this feature has already been requested.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ resources/payloads/*.lua
venv venv
logs.txt logs.txt
.DS_Store .DS_Store
.vscode/settings.json
dist/** dist/**
a.py a.py
resources/tools/a.miz resources/tools/a.miz

165
LICENSE Normal file
View File

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@ -21,6 +21,16 @@ It is an external program that generates full and complex DCS missions and manag
Latest release is available here : https://github.com/Khopa/dcs_liberation/releases Latest release is available here : https://github.com/Khopa/dcs_liberation/releases
To download preview builds of the next version of DCS Liberation, see https://github.com/Khopa/dcs_liberation/wiki/Preview-builds.
## Bugs and feature requests
If you need to report a bug or want to suggest a new feature, you can do this on our [bug tracker](https://github.com/Khopa/dcs_liberation/issues). In either case, please use the search bar at the top of the page to see if it has already been reported. Note that you may need to remove the filter for open bugs if it's something we've recently fixed.
## Roadmap
Our plans for future releases can be found on our [Projects page](https://github.com/Khopa/dcs_liberation/projects). Each planned release has a Project, and the page for that project has columns for to do, in progress, and done. Items in the Done column are in the [preview build](https://github.com/Khopa/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release.
## Resources ## Resources
Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/Khopa/dcs_liberation/wiki/) Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/Khopa/dcs_liberation/wiki/)

View File

@ -1,7 +1,86 @@
# 2.4.0
Saves from 2.3 are not compatible with 2.4.
## Highlights
* Improved flight plan generation to avoid loitering in or traveling through threatened areas when practical.
* Improved AI aircraft purchasing behavior.
* Era-restricted weapons (work in progress).
* Tons of UI polish.
* Rebalanced economy to keep opfor competitive over the course of the game.
## Features/Improvements
* **[Flight Planner]** Air-to-air and SEAD escorts will no longer be automatically planned for packages that are not in range of threats.
* **[Flight Planner]** Non-custom flight plans will now navigate around threat areas en route to the target area when practical.
* **[Flight Planner]** Flight plans along front lines now ensure that the race track start is closer to the departure airfield than the race track end.
* **[Campaign AI]** Auto-purchase now prefers airfields that are not within range of the enemy.
* **[Campaign AI]** Auto-purchase now prefers the best aircraft for the task, but will attempt to maintain some variety.
* **[Campaign AI]** Opfor now sells off odd aircraft since they're unlikely to be used.
* **[Campaign AI]** Multiple rounds of CAP will be planned (roughly 90 minutes of coverage). Default starting budget has increased to account for the increased need for aircraft.
* **[Mission Generator]** Multiple groups are created for complex SAM sites (SAMs with additional point defense or SHORADS), improving Skynet behavior.
* **[Mission Generator]** Default start type can now be chosen in the settings. This replaces the non-functional "AI Parking Start" option. **Selecting any type other than cold will break OCA/Aircraft missions.**
* **[Cheat Menu]** Added ability to toggle base capture and frontline advance/retreat cheats.
* **[Skynet]** Updated to 2.0.1.
* **[Skynet]** Point defenses are now configured to remain on to protect the site they accompany.
* **[Hercules]** Updated the Hercules Cargo list file.
* **[Balance]** Opfor now gains income using the same rules as the player, significantly increasing their income relative to the player for most campaigns.
* **[Balance]** Units now retreat from captured bases when able. Units with no retreat path will be captured and sold.
* **[Economy]** FOBs generate only $10M per turn (previously $20M like airbases).
* **[Economy]** Carriers and off-map spawns generate no income (previously $20M like airbases).
* **[Economy]** Sales of aircraft and ground vehicles can now be cancelled before the next turn begins.
* **[UI]** Multi-SAM objectives now show threat and detection rings per group.
* **[UI]** New icon for AA sites with no active threat.
* **[UI]** Unit names are now prettier and more accurate, and can now be set per-country for added historical flavour.
* **[UI]** Default loadout is now shown for flights with no custom loadout selected.
* **[UI]** Aircraft for a new flight are now only selectable if they match the task type for that flight.
* **[UI]** WIP - There is now a unit info button for each unit in the recruitment list, that should help newer players learn what each unit does.
* **[UI]** Docs for time-on-target and creating new theaters/factions/loadouts are now linked in the UI at the appropriate places.
* **[UI]** ASAP is now a checkbox rather than a button. Enabling this will disable the TOT selector but changes to the package structure will automatically re-ASAP the package.
* **[UI]** Arrival airfield is now shown in the flight list if it differs from the departure airfield.
* **[UI]** Start type can now be selected when creating a flight.
* **[UI]** Arrival and divert airfields can be edited after the flight is created.
* **[Factions]** Added option for date-based loadout restriction. Active radar homing missiles are handled, patches welcome for the other thousand weapons.
* **[Factions]** Added Poland 2010 faction.
* **[Factions]** Added Greece 2005 faction.
* **[Factions]** Added Iran 1988 faction.
* **[Units]** Support for E-2 Hawkeye, SH-60B Seahawk, S-3B Viking (thanks to awinterquest) and SpGH Dana - these are now being used by appropriate factions.
* **[Culling]** Missile sites are no longer culled.
* **[Campaigns]** Added campaign "Black Sea Lite" by Starfire
* **[Campaigns]** Added campaign "Exercise Vegas Nerve" by Starfire
* **[New game Wizard]** The theater page is now the first page of the campaign wizard, recommended factions will be selected automatically on the faction selection page
* **[New game Wizard]** Added information text about the selected campaign performance.
* **[Mod Support]** Added support for High Digit SAMs mod 1.4.0
* **[Mod Support]** Added SAMs sites generator : KS19Generator, SA10BGenerator, SA12Generator, SA17Generator, SA20Generator, SA20BGenerator, SA23Generator
## Fixes
* **[Hercules]** Updated the default Hercules radio frequency.
* **[Economy]** Pending unit orders at captured bases will be refunded.
* **[UI]** Carrier group SAM threat rings now move with the carrier.
* **[UI]** Base intel menu no longer compresses text, and is now scrollable.
* **[UI]** Edit Flight window is now dynamically sized to adapt to the width of waypoint names, so they no longer get truncated.
* **[UI]** Budget income display is now rounded to 2 decimal places.
* **[UI]** Fixed incorrect income per turn displayed for strike target tooltip.
* **[Factions]** USA with C-130 faction now links to the required mod.
* **[Campaign]** Fixed issue where destroyed buildings would sometimes not count as destroyed and thus respawn.
* **[Campaign]** Fixed issue where destroyed runways were not registered.
* **[Units]** J-11A is no longer spawned with empty loadout.
* **[Units]** F-14B is no longer spawned with empty loadout for fighter sweep tasks.
* **[Units]** Pyotr Velikiy cruiser has been removed for now as it's nearly unkillable.
* **[Units]** Submarines have been removed for now as they aren't wholly functional.
* **[Units]** Fixed "FACTION ERROR : Unable to find OliverHazardPerryGroupGenerator in pydcs" error at startup.
* **[Mission Generator]** Fixed a bug where units set to Aggressive stance sometimes did not move.
* **[Mission Generator]** Flyover points for OCA/Aircraft missions are now generated correctly.
* **[Flight Planner]** Fixed not being able to create custom waypoints for buildings.
* **[Flight Planner]** Strike missions will no longer be automatically planned against SAMs.
* **[Flight Planner]** Strike missions will no longer be automatically planned against FOB structures.
# 2.3.4 # 2.3.4
## Fixes: ## Fixes:
* **[Mission Generator]** Mission generator would crash when generating fire missions for destroyed SCUD sites - fixed [Mission Generator] Mission generator would crash when generating fire missions for destroyed SCUD sites - fixed
# 2.3.3 # 2.3.3

View File

@ -1,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from game.utils import nm_to_meter, feet_to_meter from game.utils import Distance, feet, nautical_miles
@dataclass(frozen=True) @dataclass(frozen=True)
@ -12,31 +12,43 @@ class Doctrine:
strike: bool strike: bool
antiship: bool antiship: bool
strike_max_range: int rendezvous_altitude: Distance
sead_max_range: int hold_distance: Distance
push_distance: Distance
join_distance: Distance
split_distance: Distance
ingress_egress_distance: Distance
ingress_altitude: Distance
egress_altitude: Distance
rendezvous_altitude: int min_patrol_altitude: Distance
hold_distance: int max_patrol_altitude: Distance
push_distance: int pattern_altitude: Distance
join_distance: int
split_distance: int
ingress_egress_distance: int
ingress_altitude: int
egress_altitude: int
min_patrol_altitude: int
max_patrol_altitude: int
pattern_altitude: int
#: The duration that CAP flights will remain on-station.
cap_duration: timedelta cap_duration: timedelta
cap_min_track_length: int
cap_max_track_length: int #: The minimum length of the CAP race track.
cap_min_distance_from_cp: int cap_min_track_length: Distance
cap_max_distance_from_cp: int
#: The maximum length of the CAP race track.
cap_max_track_length: Distance
#: The minimum distance between the defended position and the *end* of the
#: CAP race track.
cap_min_distance_from_cp: Distance
#: The maximum distance between the defended position and the *end* of the
#: CAP race track.
cap_max_distance_from_cp: Distance
#: The engagement range of CAP flights. Any enemy aircraft within this range
#: of the CAP's current position will be engaged by the CAP.
cap_engagement_range: Distance
cas_duration: timedelta cas_duration: timedelta
sweep_distance: int sweep_distance: Distance
MODERN_DOCTRINE = Doctrine( MODERN_DOCTRINE = Doctrine(
@ -45,26 +57,25 @@ MODERN_DOCTRINE = Doctrine(
sead=True, sead=True,
strike=True, strike=True,
antiship=True, antiship=True,
strike_max_range=1500000, rendezvous_altitude=feet(25000),
sead_max_range=1500000, hold_distance=nautical_miles(15),
rendezvous_altitude=feet_to_meter(25000), push_distance=nautical_miles(20),
hold_distance=nm_to_meter(15), join_distance=nautical_miles(20),
push_distance=nm_to_meter(20), split_distance=nautical_miles(20),
join_distance=nm_to_meter(20), ingress_egress_distance=nautical_miles(45),
split_distance=nm_to_meter(20), ingress_altitude=feet(20000),
ingress_egress_distance=nm_to_meter(45), egress_altitude=feet(20000),
ingress_altitude=feet_to_meter(20000), min_patrol_altitude=feet(15000),
egress_altitude=feet_to_meter(20000), max_patrol_altitude=feet(33000),
min_patrol_altitude=feet_to_meter(15000), pattern_altitude=feet(5000),
max_patrol_altitude=feet_to_meter(33000),
pattern_altitude=feet_to_meter(5000),
cap_duration=timedelta(minutes=30), cap_duration=timedelta(minutes=30),
cap_min_track_length=nm_to_meter(15), cap_min_track_length=nautical_miles(15),
cap_max_track_length=nm_to_meter(40), cap_max_track_length=nautical_miles(40),
cap_min_distance_from_cp=nm_to_meter(10), cap_min_distance_from_cp=nautical_miles(10),
cap_max_distance_from_cp=nm_to_meter(40), cap_max_distance_from_cp=nautical_miles(40),
cap_engagement_range=nautical_miles(50),
cas_duration=timedelta(minutes=30), cas_duration=timedelta(minutes=30),
sweep_distance=nm_to_meter(60), sweep_distance=nautical_miles(60),
) )
COLDWAR_DOCTRINE = Doctrine( COLDWAR_DOCTRINE = Doctrine(
@ -73,26 +84,25 @@ COLDWAR_DOCTRINE = Doctrine(
sead=True, sead=True,
strike=True, strike=True,
antiship=True, antiship=True,
strike_max_range=1500000, rendezvous_altitude=feet(22000),
sead_max_range=1500000, hold_distance=nautical_miles(10),
rendezvous_altitude=feet_to_meter(22000), push_distance=nautical_miles(10),
hold_distance=nm_to_meter(10), join_distance=nautical_miles(10),
push_distance=nm_to_meter(10), split_distance=nautical_miles(10),
join_distance=nm_to_meter(10), ingress_egress_distance=nautical_miles(30),
split_distance=nm_to_meter(10), ingress_altitude=feet(18000),
ingress_egress_distance=nm_to_meter(30), egress_altitude=feet(18000),
ingress_altitude=feet_to_meter(18000), min_patrol_altitude=feet(10000),
egress_altitude=feet_to_meter(18000), max_patrol_altitude=feet(24000),
min_patrol_altitude=feet_to_meter(10000), pattern_altitude=feet(5000),
max_patrol_altitude=feet_to_meter(24000),
pattern_altitude=feet_to_meter(5000),
cap_duration=timedelta(minutes=30), cap_duration=timedelta(minutes=30),
cap_min_track_length=nm_to_meter(12), cap_min_track_length=nautical_miles(12),
cap_max_track_length=nm_to_meter(24), cap_max_track_length=nautical_miles(24),
cap_min_distance_from_cp=nm_to_meter(8), cap_min_distance_from_cp=nautical_miles(8),
cap_max_distance_from_cp=nm_to_meter(25), cap_max_distance_from_cp=nautical_miles(25),
cap_engagement_range=nautical_miles(35),
cas_duration=timedelta(minutes=30), cas_duration=timedelta(minutes=30),
sweep_distance=nm_to_meter(40), sweep_distance=nautical_miles(40),
) )
WWII_DOCTRINE = Doctrine( WWII_DOCTRINE = Doctrine(
@ -101,24 +111,23 @@ WWII_DOCTRINE = Doctrine(
sead=False, sead=False,
strike=True, strike=True,
antiship=True, antiship=True,
strike_max_range=1500000, hold_distance=nautical_miles(5),
sead_max_range=1500000, push_distance=nautical_miles(5),
hold_distance=nm_to_meter(5), join_distance=nautical_miles(5),
push_distance=nm_to_meter(5), split_distance=nautical_miles(5),
join_distance=nm_to_meter(5), rendezvous_altitude=feet(10000),
split_distance=nm_to_meter(5), ingress_egress_distance=nautical_miles(7),
rendezvous_altitude=feet_to_meter(10000), ingress_altitude=feet(8000),
ingress_egress_distance=nm_to_meter(7), egress_altitude=feet(8000),
ingress_altitude=feet_to_meter(8000), min_patrol_altitude=feet(4000),
egress_altitude=feet_to_meter(8000), max_patrol_altitude=feet(15000),
min_patrol_altitude=feet_to_meter(4000), pattern_altitude=feet(5000),
max_patrol_altitude=feet_to_meter(15000),
pattern_altitude=feet_to_meter(5000),
cap_duration=timedelta(minutes=30), cap_duration=timedelta(minutes=30),
cap_min_track_length=nm_to_meter(8), cap_min_track_length=nautical_miles(8),
cap_max_track_length=nm_to_meter(18), cap_max_track_length=nautical_miles(18),
cap_min_distance_from_cp=nm_to_meter(0), cap_min_distance_from_cp=nautical_miles(0),
cap_max_distance_from_cp=nm_to_meter(5), cap_max_distance_from_cp=nautical_miles(5),
cap_engagement_range=nautical_miles(20),
cas_duration=timedelta(minutes=30), cas_duration=timedelta(minutes=30),
sweep_distance=nm_to_meter(10), sweep_distance=nautical_miles(10),
) )

326
game/data/weapons.py Normal file
View File

@ -0,0 +1,326 @@
from __future__ import annotations
import datetime
import inspect
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Iterator, Optional, Set, Tuple, Type, Union, cast
from dcs.unitgroup import FlyingGroup
from dcs.unittype import FlyingType
from dcs.weapons_data import Weapons, weapon_ids
PydcsWeapon = Dict[str, Union[int, str]]
PydcsWeaponAssignment = Tuple[int, PydcsWeapon]
@dataclass(frozen=True)
class Weapon:
"""Wraps a pydcs weapon dict in a hashable type."""
cls_id: str
name: str
weight: int
def available_on(self, date: datetime.date) -> bool:
introduction_year = WEAPON_INTRODUCTION_YEARS.get(self)
if introduction_year is None:
logging.warning(
f"No introduction year for {self}, assuming always available")
return True
return date >= datetime.date(introduction_year, 1, 1)
@property
def as_pydcs(self) -> PydcsWeapon:
return {
"clsid": self.cls_id,
"name": self.name,
"weight": self.weight,
}
@property
def fallbacks(self) -> Iterator[Weapon]:
yield self
fallback = WEAPON_FALLBACK_MAP[self]
if fallback is not None:
yield from fallback.fallbacks
@classmethod
def from_pydcs(cls, weapon_data: PydcsWeapon) -> Weapon:
return cls(
cast(str, weapon_data["clsid"]),
cast(str, weapon_data["name"]),
cast(int, weapon_data["weight"])
)
@classmethod
def from_clsid(cls, clsid: str) -> Optional[Weapon]:
data = weapon_ids.get(clsid)
if data is None:
return None
return cls.from_pydcs(data)
@dataclass(frozen=True)
class Pylon:
number: int
allowed: Set[Weapon]
def can_equip(self, weapon: Weapon) -> bool:
return weapon in self.allowed
def equip(self, group: FlyingGroup, weapon: Weapon) -> None:
if not self.can_equip(weapon):
raise ValueError(f"Pylon {self.number} cannot equip {weapon.name}")
group.load_pylon(self.make_pydcs_assignment(weapon), self.number)
def make_pydcs_assignment(self, weapon: Weapon) -> PydcsWeaponAssignment:
return self.number, weapon.as_pydcs
def available_on(self, date: datetime.date) -> Iterator[Weapon]:
for weapon in self.allowed:
if weapon.available_on(date):
yield weapon
@classmethod
def for_aircraft(cls, aircraft: Type[FlyingType], number: int) -> Pylon:
# In pydcs these are all arbitrary inner classes of the aircraft type.
# The only way to identify them is by their name.
pylons = [v for v in aircraft.__dict__.values() if
inspect.isclass(v) and v.__name__.startswith("Pylon")]
# And that Pylon class has members with irrelevant names that have
# values of (pylon number, allowed weapon).
allowed = set()
for pylon in pylons:
for key, value in pylon.__dict__.items():
if key.startswith("__"):
continue
pylon_number, weapon = value
if pylon_number != number:
continue
allowed.add(Weapon.from_pydcs(weapon))
return cls(number, allowed)
@classmethod
def iter_pylons(cls, aircraft: Type[FlyingType]) -> Iterator[Pylon]:
for pylon in sorted(list(aircraft.pylons)):
yield cls.for_aircraft(aircraft, pylon)
_WEAPON_FALLBACKS = [
# AIM-120C
(Weapons.AIM_120C, Weapons.AIM_120B),
(Weapons.LAU_115___AIM_120C, Weapons.LAU_115___AIM_120B),
(Weapons.LAU_115_2_LAU_127_AIM_120C, Weapons.LAU_115_2_LAU_127_AIM_120B),
# AIM-120B
(Weapons.AIM_120B, Weapons.AIM_7MH),
(Weapons.LAU_115___AIM_120B, Weapons.LAU_115C_AIM_7MH),
(Weapons.LAU_115_2_LAU_127_AIM_120B, Weapons.LAU_115C_AIM_7MH),
# AIM-7MH
(Weapons.AIM_7MH, Weapons.AIM_7M),
(Weapons.AIM_7MH_, Weapons.AIM_7M_),
(Weapons.AIM_7MH__, Weapons.AIM_7M__),
(Weapons.LAU_115C_AIM_7MH, Weapons.LAU_115___AIM_7M),
# AIM-7M
(Weapons.AIM_7M, Weapons.AIM_7F),
(Weapons.AIM_7M_, None),
(Weapons.AIM_7M__, None),
(Weapons.LAU_115___AIM_7M, Weapons.LAU_115C_AIM_7F),
# AIM-7F
(Weapons.AIM_7F, Weapons.AIM_7E),
(Weapons.AIM_7F_, Weapons.AIM_7E),
(Weapons.AIM_7F__, Weapons.AIM_7E),
(Weapons.LAU_115C_AIM_7F, Weapons.LAU_115C_AIM_7E),
# AIM-7E
(Weapons.AIM_7E, Weapons.AIM_9X_Sidewinder_IR_AAM),
(Weapons.LAU_115C_AIM_7E, Weapons.LAU_115_LAU_127_AIM_9X),
# AIM-9X
(Weapons.AIM_9X_Sidewinder_IR_AAM, Weapons.AIM_9P5_Sidewinder_IR_AAM),
(Weapons.LAU_7_AIM_9X_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9P5_Sidewinder_IR_AAM),
(Weapons.LAU_115_LAU_127_AIM_9X, Weapons.LAU_115_LAU_127_AIM_9M),
(Weapons.LAU_115_2_LAU_127_AIM_9X, Weapons.LAU_115_2_LAU_127_AIM_9M),
(Weapons.LAU_127_AIM_9X, Weapons.LAU_127_AIM_9M),
# AIM-9P5
(Weapons.AIM_9P5_Sidewinder_IR_AAM, Weapons.AIM_9P_Sidewinder_IR_AAM),
(Weapons.LAU_7_AIM_9P5_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9P_Sidewinder_IR_AAM),
# AIM-9P
(Weapons.AIM_9P_Sidewinder_IR_AAM, Weapons.AIM_9M_Sidewinder_IR_AAM),
(Weapons.LAU_7_AIM_9P_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9M_Sidewinder_IR_AAM),
# AIM-9M
(Weapons.AIM_9M_Sidewinder_IR_AAM, Weapons.AIM_9L_Sidewinder_IR_AAM),
(Weapons.LAU_7_AIM_9M_Sidewinder_IR_AAM, Weapons.LAU_7_AIM_9L),
# AIM-9L
(Weapons.AIM_9L_Sidewinder_IR_AAM, None),
(Weapons.LAU_7_AIM_9L, None),
# AIM-54C Mk47
(Weapons.AIM_54C_Mk47, Weapons.AIM_54A_Mk60),
(Weapons.AIM_54C_Mk47_, Weapons.AIM_54A_Mk60_),
(Weapons.AIM_54C_Mk47__, Weapons.AIM_54A_Mk60__),
# AIM-54A Mk60
(Weapons.AIM_54A_Mk60, Weapons.AIM_54A_Mk47),
(Weapons.AIM_54A_Mk60_, Weapons.AIM_54A_Mk47_),
(Weapons.AIM_54A_Mk60__, Weapons.AIM_54A_Mk47__),
# R-27 (AA-10 Alamo)
(Weapons.R_27ER, Weapons.R_27R),
(Weapons.R_27ET, Weapons.R_27T),
# R-77 (AA-12)
(Weapons.R_77, Weapons.R_27ER),
(Weapons.R_77_, Weapons.R_27ER),
# R-73 (AA-11)
(Weapons.R_73, Weapons.R_60M),
(Weapons.R_73_, Weapons.R_60M_),
# GBU-38 (JDAM)
(Weapons.GBU_38, Weapons.GBU_12),
(Weapons.GBU_38_16, Weapons.MK_82_28), # B1-B only
(Weapons._2_GBU_38_, Weapons._2_GBU_12),
(Weapons._2_GBU_38, Weapons._2_GBU_12),
(Weapons._3_GBU_38, Weapons._3_GBU_12),
(Weapons.BRU_55___2_x_GBU_38, Weapons.BRU_33___2_x_GBU_12),
(Weapons.BRU_57___2_x_GBU_38, Weapons.BRU_33___2_x_GBU_12),
# AGM-154A (JSOW)
(Weapons.AGM_154A, Weapons.GBU_12),
(Weapons.BRU_55___2_x_AGM_154A, Weapons.BRU_33___2_x_GBU_12),
(Weapons.BRU_57___2_x_AGM_154A, Weapons.BRU_33___2_x_GBU_12),
# AGM-154C (JSOW)
(Weapons.AGM_154C, Weapons.GBU_12),
(Weapons.AGM_154C_4, Weapons.MK_82_28), # B1-B only
(Weapons.BRU_55___2_x_AGM_154C, Weapons.BRU_33___2_x_GBU_12),
# AGM-84E (SLAM)
(Weapons.AGM_84E, Weapons.LAU_117_AGM_65F),
# CBU-97
(Weapons.CBU_97, Weapons.GBU_12),
(Weapons.TER_9A___2_x_CBU_97, Weapons.TER_9A___2_x_GBU_12),
(Weapons.TER_9A___2_x_CBU_97_, Weapons.TER_9A___2_x_GBU_12),
(Weapons.TER_9A___3_x_CBU_97, Weapons.TER_9A___2_x_GBU_12),
]
WEAPON_FALLBACK_MAP: Dict[Weapon, Optional[Weapon]] = defaultdict(
lambda: cast(Optional[Weapon], None),
((Weapon.from_pydcs(a), b if b is None else Weapon.from_pydcs(b))
for a, b in _WEAPON_FALLBACKS))
WEAPON_INTRODUCTION_YEARS = {
# AIM-120C
Weapon.from_pydcs(Weapons.AIM_120C): 1996,
Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120C): 1996,
Weapon.from_pydcs(Weapons.LAU_115___AIM_120C): 1996,
# AIM-120B
Weapon.from_pydcs(Weapons.AIM_120B): 1994,
Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_120B): 1994,
Weapon.from_pydcs(Weapons.LAU_115___AIM_120B): 1994,
# AIM-7MH
Weapon.from_pydcs(Weapons.AIM_7MH): 1987,
Weapon.from_pydcs(Weapons.AIM_7MH_): 1987,
Weapon.from_pydcs(Weapons.AIM_7MH__): 1987,
Weapon.from_pydcs(Weapons.LAU_115C_AIM_7MH): 1987,
# AIM-7M
Weapon.from_pydcs(Weapons.AIM_7M): 1982,
Weapon.from_pydcs(Weapons.AIM_7M_): 1982,
Weapon.from_pydcs(Weapons.AIM_7M__): 1982,
Weapon.from_pydcs(Weapons.LAU_115___AIM_7M): 1982,
# AIM-7F
Weapon.from_pydcs(Weapons.AIM_7F): 1976,
Weapon.from_pydcs(Weapons.AIM_7F_): 1976,
Weapon.from_pydcs(Weapons.AIM_7F__): 1976,
Weapon.from_pydcs(Weapons.LAU_115C_AIM_7F): 1976,
# AIM-7E
Weapon.from_pydcs(Weapons.AIM_7E): 1963,
Weapon.from_pydcs(Weapons.LAU_115C_AIM_7E): 1963,
# AIM-9X
Weapon.from_pydcs(Weapons.AIM_9X_Sidewinder_IR_AAM): 2003,
Weapon.from_pydcs(Weapons.LAU_7_AIM_9X_Sidewinder_IR_AAM): 2003,
Weapon.from_pydcs(Weapons.LAU_115_LAU_127_AIM_9X): 2003,
Weapon.from_pydcs(Weapons.LAU_115_2_LAU_127_AIM_9X): 2003,
Weapon.from_pydcs(Weapons.LAU_127_AIM_9X): 2003,
# AIM-9P5
Weapon.from_pydcs(Weapons.AIM_9P5_Sidewinder_IR_AAM): 1963,
Weapon.from_pydcs(Weapons.LAU_7_AIM_9P5_Sidewinder_IR_AAM): 1963,
# AIM-9P
Weapon.from_pydcs(Weapons.AIM_9P_Sidewinder_IR_AAM): 1978,
Weapon.from_pydcs(Weapons.LAU_7_AIM_9P_Sidewinder_IR_AAM): 1978,
# AIM-9M
Weapon.from_pydcs(Weapons.AIM_9M_Sidewinder_IR_AAM): 1983,
Weapon.from_pydcs(Weapons.LAU_7_AIM_9M_Sidewinder_IR_AAM): 1983,
# AIM-9L
Weapon.from_pydcs(Weapons.AIM_9L_Sidewinder_IR_AAM): 1977,
Weapon.from_pydcs(Weapons.LAU_7_AIM_9L): 1977,
# AIM-54C-Mk47
Weapon.from_pydcs(Weapons.AIM_54C_Mk47): 1986,
Weapon.from_pydcs(Weapons.AIM_54C_Mk47_): 1986,
Weapon.from_pydcs(Weapons.AIM_54C_Mk47__): 1986,
Weapon.from_pydcs(Weapons.AIM_54C): 1986, # this weapon id is unused (legacy F-14A)
# R-77 (AA-12)
Weapon.from_pydcs(Weapons.R_77): 2002,
Weapon.from_pydcs(Weapons.R_77_): 2002,
# R-73 (AA-11)
Weapon.from_pydcs(Weapons.R_73): 1989,
Weapon.from_pydcs(Weapons.R_73_): 1989,
# GBU-38 (JDAM)
Weapon.from_pydcs(Weapons.GBU_38): 1998,
Weapon.from_pydcs(Weapons.GBU_38_16): 1998, # B1-B only
Weapon.from_pydcs(Weapons._2_GBU_38_): 1998,
Weapon.from_pydcs(Weapons._2_GBU_38): 1998,
Weapon.from_pydcs(Weapons._3_GBU_38): 1998,
Weapon.from_pydcs(Weapons.BRU_55___2_x_GBU_38): 1998,
Weapon.from_pydcs(Weapons.BRU_57___2_x_GBU_38): 1998,
# AGM-154A (JSOW)
Weapon.from_pydcs(Weapons.AGM_154A): 1999,
Weapon.from_pydcs(Weapons.BRU_55___2_x_AGM_154A): 1999,
Weapon.from_pydcs(Weapons.BRU_57___2_x_AGM_154A): 1999,
# AGM-154C (JSOW)
Weapon.from_pydcs(Weapons.AGM_154C): 2005,
Weapon.from_pydcs(Weapons.AGM_154C_4): 2005, # B1-B only
Weapon.from_pydcs(Weapons.BRU_55___2_x_AGM_154C): 2005,
# AGM-84E
Weapon.from_pydcs(Weapons.AGM_84E): 1990,
# CBU-97
Weapon.from_pydcs(Weapons.CBU_97): 1995,
Weapon.from_pydcs(Weapons.TER_9A___2_x_CBU_97): 1995,
Weapon.from_pydcs(Weapons.TER_9A___2_x_CBU_97_): 1995,
Weapon.from_pydcs(Weapons.TER_9A___3_x_CBU_97): 1995
}

View File

@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Dict, List, Optional, Tuple, Type, Union from typing import Dict, List, Optional, Tuple, Type, Union
import json
from pathlib import Path
from dcs.countries import country_dict from dcs.countries import country_dict
from dcs.helicopters import ( from dcs.helicopters import (
@ -17,6 +19,7 @@ from dcs.helicopters import (
SA342M, SA342M,
SA342Minigun, SA342Minigun,
SA342Mistral, SA342Mistral,
SH_60B,
UH_1H, UH_1H,
UH_60A, UH_60A,
helicopter_map, helicopter_map,
@ -40,12 +43,14 @@ from dcs.planes import (
C_101CC, C_101CC,
C_130, C_130,
E_3A, E_3A,
E_2C,
FA_18C_hornet, FA_18C_hornet,
FW_190A8, FW_190A8,
FW_190D9, FW_190D9,
F_117A, F_117A,
F_14A_135_GR, F_14A_135_GR,
F_14B, F_14B,
F_111F,
F_15C, F_15C,
F_15E, F_15E,
F_16A, F_16A,
@ -61,6 +66,7 @@ from dcs.planes import (
Ju_88A4, Ju_88A4,
KC130, KC130,
KC_135, KC_135,
KC135MPRS,
KJ_2000, KJ_2000,
L_39C, L_39C,
L_39ZA, L_39ZA,
@ -84,6 +90,7 @@ from dcs.planes import (
P_51D_30_NA, P_51D_30_NA,
PlaneType, PlaneType,
RQ_1A_Predator, RQ_1A_Predator,
S_3B,
S_3B_Tanker, S_3B_Tanker,
SpitfireLFMkIX, SpitfireLFMkIX,
SpitfireLFMkIXCW, SpitfireLFMkIXCW,
@ -130,6 +137,7 @@ from dcs.task import (
CargoTransportation, CargoTransportation,
Embarking, Embarking,
Escort, Escort,
FighterSweep,
GroundAttack, GroundAttack,
Intercept, Intercept,
MainTask, MainTask,
@ -157,6 +165,7 @@ from dcs.vehicles import (
) )
import pydcs_extensions.frenchpack.frenchpack as frenchpack import pydcs_extensions.frenchpack.frenchpack as frenchpack
import pydcs_extensions.highdigitsams.highdigitsams as highdigitsams
# PATCH pydcs data with MODS # PATCH pydcs data with MODS
from game.factions.faction_loader import FactionLoader from game.factions.faction_loader import FactionLoader
from pydcs_extensions.a4ec.a4ec import A_4E_C from pydcs_extensions.a4ec.a4ec import A_4E_C
@ -166,6 +175,8 @@ from pydcs_extensions.mb339.mb339 import MB_339PAN
from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M, Rafale_B from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M, Rafale_B
from pydcs_extensions.su57.su57 import Su_57 from pydcs_extensions.su57.su57 import Su_57
UNITINFOTEXT_PATH = Path("./resources/units/unit_info_text.json")
plane_map["A-4E-C"] = A_4E_C plane_map["A-4E-C"] = A_4E_C
plane_map["MB-339PAN"] = MB_339PAN plane_map["MB-339PAN"] = MB_339PAN
plane_map["Rafale_M"] = Rafale_M plane_map["Rafale_M"] = Rafale_M
@ -209,6 +220,49 @@ vehicle_map["Toyota_vert"] = frenchpack.DIM__TOYOTA_GREEN
vehicle_map["Toyota_desert"] = frenchpack.DIM__TOYOTA_DESERT vehicle_map["Toyota_desert"] = frenchpack.DIM__TOYOTA_DESERT
vehicle_map["Kamikaze"] = frenchpack.DIM__KAMIKAZE vehicle_map["Kamikaze"] = frenchpack.DIM__KAMIKAZE
vehicle_map[highdigitsams.AAA_SON_9_Fire_Can.id] = highdigitsams.AAA_SON_9_Fire_Can
vehicle_map[highdigitsams.AAA_100mm_KS_19.id] = highdigitsams.AAA_100mm_KS_19
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_54K6_CP.id] = highdigitsams.SAM_SA_10B_S_300PS_54K6_CP
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_5P85SE_LN.id] = highdigitsams.SAM_SA_10B_S_300PS_5P85SE_LN
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_5P85SU_LN.id] = highdigitsams.SAM_SA_10B_S_300PS_5P85SU_LN
vehicle_map[highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE.id] = highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE
vehicle_map[highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE.id] = highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_30N6_TR.id] = highdigitsams.SAM_SA_10B_S_300PS_30N6_TR
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_40B6M_TR.id] = highdigitsams.SAM_SA_10B_S_300PS_40B6M_TR
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_40B6MD_SR.id] = highdigitsams.SAM_SA_10B_S_300PS_40B6MD_SR
vehicle_map[highdigitsams.SAM_SA_10B_S_300PS_64H6E_SR.id] = highdigitsams.SAM_SA_10B_S_300PS_64H6E_SR
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6.id] = highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E.id] = highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck.id] = highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E.id] = highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E.id] = highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE.id] = highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE
vehicle_map[highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE.id] = highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE
vehicle_map[highdigitsams.SAM_SA_20B_S_300PMU2_CP_54K6E2.id] = highdigitsams.SAM_SA_20B_S_300PMU2_CP_54K6E2
vehicle_map[highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck.id] = highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck
vehicle_map[highdigitsams.SAM_SA_20B_S_300PMU2_SR_64N6E2.id] = highdigitsams.SAM_SA_20B_S_300PMU2_SR_64N6E2
vehicle_map[highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2.id] = highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9S457_CP.id] = highdigitsams.SAM_SA_12_S_300V_9S457_CP
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9A82_LN.id] = highdigitsams.SAM_SA_12_S_300V_9A82_LN
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9A83_LN.id] = highdigitsams.SAM_SA_12_S_300V_9A83_LN
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9S15_SR.id] = highdigitsams.SAM_SA_12_S_300V_9S15_SR
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9S19_SR.id] = highdigitsams.SAM_SA_12_S_300V_9S19_SR
vehicle_map[highdigitsams.SAM_SA_12_S_300V_9S32_TR.id] = highdigitsams.SAM_SA_12_S_300V_9S32_TR
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP.id] = highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR.id] = highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR.id] = highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR.id] = highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN.id] = highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN
vehicle_map[highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN.id] = highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN
vehicle_map[highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2.id] = highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2
vehicle_map[highdigitsams.SAM_SA_2__V759__LN_SM_90.id] = highdigitsams.SAM_SA_2__V759__LN_SM_90
vehicle_map[highdigitsams.SAM_HQ_2_LN_SM_90.id] = highdigitsams.SAM_HQ_2_LN_SM_90
vehicle_map[highdigitsams.SAM_SA_3__V_601P__LN_5P73.id] = highdigitsams.SAM_SA_3__V_601P__LN_5P73
vehicle_map[highdigitsams.SAM_SA_24_Igla_S_manpad.id] = highdigitsams.SAM_SA_24_Igla_S_manpad
vehicle_map[highdigitsams.SAM_SA_14_Strela_3_manpad.id] = highdigitsams.SAM_SA_14_Strela_3_manpad
vehicle_map[highdigitsams.Polyana_D4M1_C2_node.id] = highdigitsams.Polyana_D4M1_C2_node
vehicle_map[highdigitsams._34Ya6E_Gazetchik_E_decoy.id] = highdigitsams._34Ya6E_Gazetchik_E_decoy
""" """
---------- BEGINNING OF CONFIGURATION SECTION ---------- BEGINNING OF CONFIGURATION SECTION
""" """
@ -302,6 +356,7 @@ PRICES = {
A_10A: 16, A_10A: 16,
A_10C: 22, A_10C: 22,
A_10C_2: 24, A_10C_2: 24,
S_3B: 10,
# heli # heli
Ka_50: 13, Ka_50: 13,
@ -317,6 +372,7 @@ PRICES = {
AH_64A: 24, AH_64A: 24,
AH_64D: 30, AH_64D: 30,
OH_58D: 6, OH_58D: 6,
SH_60B: 6,
# Bombers # Bombers
B_52H: 35, B_52H: 35,
@ -325,6 +381,7 @@ PRICES = {
Tu_160: 50, Tu_160: 50,
Tu_22M3: 40, Tu_22M3: 40,
Tu_95MS: 35, Tu_95MS: 35,
F_111F: 21,
# special # special
IL_76MD: 30, IL_76MD: 30,
@ -335,10 +392,12 @@ PRICES = {
IL_78M: 25, IL_78M: 25,
KC_135: 25, KC_135: 25,
KC130: 25, KC130: 25,
KC135MPRS: 25,
A_50: 50, A_50: 50,
KJ_2000: 50, KJ_2000: 50,
E_3A: 50, E_3A: 50,
E_2C: 50,
C_130: 25, C_130: 25,
Hercules: 25, Hercules: 25,
@ -410,6 +469,7 @@ PRICES = {
Artillery.MLRS_9K57_Uragan_BM_27: 50, Artillery.MLRS_9K57_Uragan_BM_27: 50,
Artillery.MLRS_9A52_Smerch: 40, Artillery.MLRS_9A52_Smerch: 40,
Artillery._2B11_mortar: 4, Artillery._2B11_mortar: 4,
Artillery.SpGH_Dana: 26,
Unarmed.Transport_UAZ_469: 3, Unarmed.Transport_UAZ_469: 3,
Unarmed.Transport_Ural_375: 3, Unarmed.Transport_Ural_375: 3,
@ -459,14 +519,14 @@ PRICES = {
AirDefence.SAM_SA_19_Tunguska_2S6: 30, AirDefence.SAM_SA_19_Tunguska_2S6: 30,
AirDefence.SAM_SA_6_Kub_LN_2P25: 20, AirDefence.SAM_SA_6_Kub_LN_2P25: 20,
AirDefence.SAM_SA_3_S_125_LN_5P73: 6, AirDefence.SAM_SA_3_S_125_LN_5P73: 6,
AirDefence.SAM_SA_10_S_300PS_LN_5P85C: 22,
AirDefence.SAM_SA_10_S_300PS_LN_5P85D: 22,
AirDefence.SAM_SA_11_Buk_LN_9A310M1: 30, AirDefence.SAM_SA_11_Buk_LN_9A310M1: 30,
AirDefence.SAM_SA_11_Buk_CC_9S470M1: 25,
AirDefence.SAM_SA_11_Buk_SR_9S18M1: 28,
AirDefence.SAM_SA_8_Osa_9A33: 28, AirDefence.SAM_SA_8_Osa_9A33: 28,
AirDefence.SAM_SA_15_Tor_9A331: 40, AirDefence.SAM_SA_15_Tor_9A331: 40,
AirDefence.SAM_SA_13_Strela_10M3_9A35M3: 16, AirDefence.SAM_SA_13_Strela_10M3_9A35M3: 16,
AirDefence.SAM_SA_9_Strela_1_9P31: 12, AirDefence.SAM_SA_9_Strela_1_9P31: 12,
AirDefence.SAM_SA_11_Buk_CC_9S470M1: 25,
AirDefence.SAM_SA_8_Osa_LD_9T217: 22, AirDefence.SAM_SA_8_Osa_LD_9T217: 22,
AirDefence.SAM_Patriot_AMG_AN_MRC_137: 35, AirDefence.SAM_Patriot_AMG_AN_MRC_137: 35,
AirDefence.SAM_Patriot_ECS_AN_MSQ_104: 30, AirDefence.SAM_Patriot_ECS_AN_MSQ_104: 30,
@ -481,7 +541,6 @@ PRICES = {
AirDefence.SAM_Patriot_EPP_III: 15, AirDefence.SAM_Patriot_EPP_III: 15,
AirDefence.SAM_Patriot_ICC: 18, AirDefence.SAM_Patriot_ICC: 18,
AirDefence.SAM_Roland_ADS: 12, AirDefence.SAM_Roland_ADS: 12,
AirDefence.SAM_SA_10_S_300PS_CP_54K6: 18,
AirDefence.Stinger_MANPADS: 6, AirDefence.Stinger_MANPADS: 6,
AirDefence.SAM_Stinger_comm_dsr: 4, AirDefence.SAM_Stinger_comm_dsr: 4,
AirDefence.SAM_Stinger_comm: 4, AirDefence.SAM_Stinger_comm: 4,
@ -499,11 +558,8 @@ PRICES = {
AirDefence.SAM_SA_18_Igla_S_comm: 8, AirDefence.SAM_SA_18_Igla_S_comm: 8,
AirDefence.EWR_1L13: 30, AirDefence.EWR_1L13: 30,
AirDefence.SAM_SA_6_Kub_STR_9S91: 22, AirDefence.SAM_SA_6_Kub_STR_9S91: 22,
AirDefence.SAM_SA_10_S_300PS_TR_30N6: 24,
AirDefence.SAM_SA_10_S_300PS_SR_5N66M: 30,
AirDefence.EWR_55G6: 30, AirDefence.EWR_55G6: 30,
AirDefence.SAM_SA_10_S_300PS_SR_64H6E: 30,
AirDefence.SAM_SA_11_Buk_SR_9S18M1: 28,
AirDefence.CP_9S80M1_Sborka: 10, AirDefence.CP_9S80M1_Sborka: 10,
AirDefence.SAM_Hawk_TR_AN_MPQ_46: 14, AirDefence.SAM_Hawk_TR_AN_MPQ_46: 14,
AirDefence.SAM_Hawk_SR_AN_MPQ_50: 18, AirDefence.SAM_Hawk_SR_AN_MPQ_50: 18,
@ -564,6 +620,57 @@ PRICES = {
frenchpack.DIM__TOYOTA_DESERT: 2, frenchpack.DIM__TOYOTA_DESERT: 2,
frenchpack.DIM__KAMIKAZE: 6, frenchpack.DIM__KAMIKAZE: 6,
# SA-10
AirDefence.SAM_SA_10_S_300PS_CP_54K6: 18,
AirDefence.SAM_SA_10_S_300PS_TR_30N6: 24,
AirDefence.SAM_SA_10_S_300PS_SR_5N66M: 30,
AirDefence.SAM_SA_10_S_300PS_SR_64H6E: 30,
AirDefence.SAM_SA_10_S_300PS_LN_5P85C: 22,
AirDefence.SAM_SA_10_S_300PS_LN_5P85D: 22,
# High digit sams mod
highdigitsams.AAA_SON_9_Fire_Can: 8,
highdigitsams.AAA_100mm_KS_19: 10,
highdigitsams.SAM_SA_10B_S_300PS_54K6_CP: 20,
highdigitsams.SAM_SA_10B_S_300PS_5P85SE_LN: 24,
highdigitsams.SAM_SA_10B_S_300PS_5P85SU_LN: 24,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE: 24,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE: 24,
highdigitsams.SAM_SA_10B_S_300PS_30N6_TR: 26,
highdigitsams.SAM_SA_10B_S_300PS_40B6M_TR: 26,
highdigitsams.SAM_SA_10B_S_300PS_40B6MD_SR: 32,
highdigitsams.SAM_SA_10B_S_300PS_64H6E_SR: 32,
highdigitsams.SAM_SA_12_S_300V_9S457_CP: 22,
highdigitsams.SAM_SA_12_S_300V_9A82_LN: 26,
highdigitsams.SAM_SA_12_S_300V_9A83_LN: 26,
highdigitsams.SAM_SA_12_S_300V_9S15_SR: 34,
highdigitsams.SAM_SA_12_S_300V_9S19_SR: 34,
highdigitsams.SAM_SA_12_S_300V_9S32_TR: 28,
highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6: 26,
highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E: 30,
highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck: 32,
highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E: 38,
highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E: 38,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE: 28,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE: 28,
highdigitsams.SAM_SA_20B_S_300PMU2_CP_54K6E2: 27,
highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck: 33,
highdigitsams.SAM_SA_20B_S_300PMU2_SR_64N6E2: 40,
highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2: 30,
highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP: 30,
highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR: 45,
highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR: 45,
highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR: 35,
highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN: 32,
highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN: 32,
highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2: 40,
} }
""" """
@ -574,6 +681,7 @@ Following tasks are present:
* CAS - CAS aircraft * CAS - CAS aircraft
* Transport - transport aircraft (used as targets in intercept operations) * Transport - transport aircraft (used as targets in intercept operations)
* AWACS - awacs * AWACS - awacs
* AntishipStrike - units that will engage shipping
* PinpointStrike - armor that will engage in ground war * PinpointStrike - armor that will engage in ground war
* AirDefense - AA units * AirDefense - AA units
* Reconnaissance - units that will be used as targets in destroy insurgents operations * Reconnaissance - units that will be used as targets in destroy insurgents operations
@ -633,6 +741,7 @@ UNIT_BY_TASK = {
A_10C_2, A_10C_2,
A_20G, A_20G,
B_17G, B_17G,
F_111F,
B_1B, B_1B,
B_52H, B_52H,
F_117A, F_117A,
@ -655,6 +764,7 @@ UNIT_BY_TASK = {
RQ_1A_Predator, RQ_1A_Predator,
Rafale_A_S, Rafale_A_S,
Rafale_B, Rafale_B,
S_3B,
SA342L, SA342L,
SA342M, SA342M,
SA342Minigun, SA342Minigun,
@ -670,6 +780,7 @@ UNIT_BY_TASK = {
Tu_22M3, Tu_22M3,
Tu_95MS, Tu_95MS,
UH_1H, UH_1H,
SH_60B,
WingLoong_I, WingLoong_I,
Hercules Hercules
], ],
@ -685,8 +796,14 @@ UNIT_BY_TASK = {
KC_135, KC_135,
KC130, KC130,
S_3B_Tanker, S_3B_Tanker,
KC135MPRS,
],
AWACS: [
E_3A,
E_2C,
A_50,
KJ_2000
], ],
AWACS: [E_3A, A_50, KJ_2000],
PinpointStrike: [ PinpointStrike: [
Armor.APC_MTLB, Armor.APC_MTLB,
Armor.APC_MTLB, Armor.APC_MTLB,
@ -839,6 +956,7 @@ UNIT_BY_TASK = {
Artillery.MLRS_BM_21_Grad, Artillery.MLRS_BM_21_Grad,
Artillery.MLRS_9K57_Uragan_BM_27, Artillery.MLRS_9K57_Uragan_BM_27,
Artillery.MLRS_9A52_Smerch, Artillery.MLRS_9A52_Smerch,
Artillery.SpGH_Dana,
Artillery.M12_GMC, Artillery.M12_GMC,
Artillery.Sturmpanzer_IV_Brummbär, Artillery.Sturmpanzer_IV_Brummbär,
@ -998,7 +1116,33 @@ COMMON_OVERRIDE = {
AntishipStrike: "ANTISHIP", AntishipStrike: "ANTISHIP",
GroundAttack: "STRIKE", GroundAttack: "STRIKE",
Escort: "CAP", Escort: "CAP",
RunwayAttack: "RUNWAY_ATTACK" RunwayAttack: "RUNWAY_ATTACK",
FighterSweep: "CAP"
}
"""
This is a list of mappings from the FlightType of a Flight to the type of payload defined in the
resources/payloads/UNIT_TYPE.lua file. A Flight has no concept of a PyDCS task, so COMMON_OVERRIDE cannot be
used here. This is used in the payload editor, for setting the default loadout of an object.
The left element is the FlightType name, and the right element is a tuple containing what is used in the lua file.
Some aircraft differ from the standard loadout names, so those have been included here too.
The priority goes from first to last - the first element in the tuple will be tried first, then the second, etc.
"""
EXPANDED_TASK_PAYLOAD_OVERRIDE = {
"TARCAP": ("CAP HEAVY", "CAP"),
"BARCAP": ("CAP HEAVY", "CAP"),
"CAS": ("CAS MAVERICK F", "CAS"),
"INTERCEPTION": ("CAP HEAVY", "CAP"),
"STRIKE": ("STRIKE",),
"ANTISHIP": ("ANTISHIP",),
"SEAD": ("SEAD",),
"DEAD": ("SEAD",),
"ESCORT": ("CAP HEAVY", "CAP"),
"BAI": ( "BAI", "CAS MAVERICK F", "CAS"),
"SWEEP": ("CAP HEAVY", "CAP"),
"OCA_RUNWAY": ("RUNWAY_ATTACK","RUNWAY_STRIKE","STRIKE"),
"OCA_AIRCRAFT": ("OCA","CAS MAVERICK F", "CAS")
} }
PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = { PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
@ -1016,6 +1160,7 @@ PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
AntishipStrike: "ANTISHIP", AntishipStrike: "ANTISHIP",
GroundAttack: "STRIKE", GroundAttack: "STRIKE",
Escort: "CAP HEAVY", Escort: "CAP HEAVY",
FighterSweep: "CAP HEAVY",
}, },
F_A_18C: { F_A_18C: {
CAP: "CAP HEAVY", CAP: "CAP HEAVY",
@ -1026,6 +1171,7 @@ PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
AntishipStrike: "ANTISHIP", AntishipStrike: "ANTISHIP",
GroundAttack: "STRIKE", GroundAttack: "STRIKE",
Escort: "CAP HEAVY", Escort: "CAP HEAVY",
FighterSweep: "CAP HEAVY",
}, },
Tu_160: { Tu_160: {
PinpointStrike: "Kh-65*12", PinpointStrike: "Kh-65*12",
@ -1041,6 +1187,7 @@ PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
F_14A_135_GR: COMMON_OVERRIDE, F_14A_135_GR: COMMON_OVERRIDE,
F_14B: COMMON_OVERRIDE, F_14B: COMMON_OVERRIDE,
F_15C: COMMON_OVERRIDE, F_15C: COMMON_OVERRIDE,
F_111F: COMMON_OVERRIDE,
F_22A: COMMON_OVERRIDE, F_22A: COMMON_OVERRIDE,
F_16C_50: COMMON_OVERRIDE, F_16C_50: COMMON_OVERRIDE,
JF_17: COMMON_OVERRIDE, JF_17: COMMON_OVERRIDE,
@ -1066,6 +1213,7 @@ PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
Tornado_IDS: COMMON_OVERRIDE, Tornado_IDS: COMMON_OVERRIDE,
Mirage_2000_5: COMMON_OVERRIDE, Mirage_2000_5: COMMON_OVERRIDE,
MiG_31: COMMON_OVERRIDE, MiG_31: COMMON_OVERRIDE,
S_3B: COMMON_OVERRIDE,
SA342M: COMMON_OVERRIDE, SA342M: COMMON_OVERRIDE,
SA342L: COMMON_OVERRIDE, SA342L: COMMON_OVERRIDE,
SA342Mistral: COMMON_OVERRIDE, SA342Mistral: COMMON_OVERRIDE,
@ -1103,6 +1251,7 @@ PLANE_PAYLOAD_OVERRIDES: Dict[Type[PlaneType], Dict[Type[Task], str]] = {
AH_1W: COMMON_OVERRIDE, AH_1W: COMMON_OVERRIDE,
AH_64D: COMMON_OVERRIDE, AH_64D: COMMON_OVERRIDE,
AH_64A: COMMON_OVERRIDE, AH_64A: COMMON_OVERRIDE,
SH_60B: COMMON_OVERRIDE,
Hercules: COMMON_OVERRIDE, Hercules: COMMON_OVERRIDE,
Su_25TM: { Su_25TM: {
@ -1170,9 +1319,6 @@ REWARDS = {
"derrick": 8 "derrick": 8
} }
# Base post-turn bonus value
PLAYER_BUDGET_BASE = 20
CARRIER_CAPABLE = [ CARRIER_CAPABLE = [
FA_18C_hornet, FA_18C_hornet,
F_14A_135_GR, F_14A_135_GR,
@ -1181,6 +1327,7 @@ CARRIER_CAPABLE = [
Su_33, Su_33,
A_4E_C, A_4E_C,
Rafale_M, Rafale_M,
S_3B,
UH_1H, UH_1H,
Mi_8MT, Mi_8MT,
@ -1188,6 +1335,7 @@ CARRIER_CAPABLE = [
AH_1W, AH_1W,
OH_58D, OH_58D,
UH_60A, UH_60A,
SH_60B,
SA342L, SA342L,
SA342M, SA342M,
@ -1204,6 +1352,7 @@ LHA_CAPABLE = [
AH_1W, AH_1W,
OH_58D, OH_58D,
UH_60A, UH_60A,
SH_60B,
SA342L, SA342L,
SA342M, SA342M,
@ -1308,6 +1457,33 @@ def unit_type_name(unit_type) -> str:
def unit_type_name_2(unit_type) -> str: def unit_type_name_2(unit_type) -> str:
return unit_type.name and unit_type.name or unit_type.id return unit_type.name and unit_type.name or unit_type.id
def unit_get_expanded_info(country_name: str, unit_type, request_type: str) -> str:
original_name = unit_type.name and unit_type.name or unit_type.id
default_value = None
faction_value = None
with UNITINFOTEXT_PATH.open("r", encoding="utf-8") as fdata:
data = json.load(fdata, encoding="utf-8")
type_exists = data.get(original_name)
if type_exists is not None:
for faction in type_exists:
if default_value is None:
default_exists = faction.get("default")
if default_exists is not None:
default_value = default_exists.get(request_type)
if faction_value is None:
faction_exists = faction.get(country_name)
if faction_exists is not None:
faction_value = faction_exists.get(request_type)
if default_value is None:
if request_type == "text":
return "WIP - This unit doesn't have any description text yet."
if request_type == "name":
return original_name
else:
return "Unknown"
if faction_value is None:
return default_value
return faction_value
def unit_type_from_name(name: str) -> Optional[Type[UnitType]]: def unit_type_from_name(name: str) -> Optional[Type[UnitType]]:
if name in vehicle_map: if name in vehicle_map:

View File

@ -197,6 +197,11 @@ class Debriefing:
continue continue
building = self.unit_map.building_or_fortification(unit_name) building = self.unit_map.building_or_fortification(unit_name)
# Try appending object to the name, because we do this for building statics.
if building is None:
building = self.unit_map.building_or_fortification(
f"{unit_name} object"
)
if building is not None: if building is not None:
if building.ground_object.control_point.captured: if building.ground_object.control_point.captured:
losses.player_buildings.append(building) losses.player_buildings.append(building)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
import math import math
from typing import Dict, List, TYPE_CHECKING, Type from typing import Dict, Iterator, List, TYPE_CHECKING, Tuple, Type
from dcs.mapping import Point from dcs.mapping import Point
from dcs.task import Task from dcs.task import Task
@ -15,16 +15,13 @@ from game.operation.operation import Operation
from game.theater import ControlPoint from game.theater import ControlPoint
from gen import AirTaskingOrder from gen import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from ..db import PRICES
from ..unitmap import UnitMap from ..unitmap import UnitMap
if TYPE_CHECKING: if TYPE_CHECKING:
from ..game import Game from ..game import Game
DIFFICULTY_LOG_BASE = 1.1
EVENT_DEPARTURE_MAX_DISTANCE = 340000
MINOR_DEFEAT_INFLUENCE = 0.1 MINOR_DEFEAT_INFLUENCE = 0.1
DEFEAT_INFLUENCE = 0.3 DEFEAT_INFLUENCE = 0.3
STRONG_DEFEAT_INFLUENCE = 0.5 STRONG_DEFEAT_INFLUENCE = 0.5
@ -39,7 +36,6 @@ class Event:
from_cp = None # type: ControlPoint from_cp = None # type: ControlPoint
to_cp = None # type: ControlPoint to_cp = None # type: ControlPoint
difficulty = 1 # type: int difficulty = 1 # type: int
BONUS_BASE = 5
def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str): def __init__(self, game, from_cp: ControlPoint, target_cp: ControlPoint, location: Point, attacker_name: str, defender_name: str):
self.game = game self.game = game
@ -57,9 +53,6 @@ class Event:
def tasks(self) -> List[Type[Task]]: def tasks(self) -> List[Type[Task]]:
return [] return []
def bonus(self) -> int:
return int(math.log(self.to_cp.importance + 1, DIFFICULTY_LOG_BASE) * self.BONUS_BASE)
def generate(self) -> UnitMap: def generate(self) -> UnitMap:
Operation.prepare(self.game) Operation.prepare(self.game)
unit_map = Operation.generate() unit_map = Operation.generate()
@ -303,9 +296,6 @@ class Event:
self.game.turn) self.game.turn)
self.game.informations.append(info) self.game.informations.append(info)
def skip(self):
pass
def redeploy_units(self, cp): def redeploy_units(self, cp):
"""" """"
Auto redeploy units to newly captured base Auto redeploy units to newly captured base
@ -347,36 +337,63 @@ class Event:
logging.info(info.text) logging.info(info.text)
class UnitsDeliveryEvent(Event): class UnitsDeliveryEvent:
informational = True
def __init__(self, attacker_name: str, defender_name: str,
from_cp: ControlPoint, to_cp: ControlPoint,
game: Game) -> None:
super(UnitsDeliveryEvent, self).__init__(game=game,
location=to_cp.position,
from_cp=from_cp,
target_cp=to_cp,
attacker_name=attacker_name,
defender_name=defender_name)
def __init__(self, control_point: ControlPoint) -> None:
self.to_cp = control_point
self.units: Dict[Type[UnitType], int] = {} self.units: Dict[Type[UnitType], int] = {}
def __str__(self) -> str: def __str__(self) -> str:
return "Pending delivery to {}".format(self.to_cp) return "Pending delivery to {}".format(self.to_cp)
def deliver(self, units: Dict[Type[UnitType], int]) -> None: def order(self, units: Dict[Type[UnitType], int]) -> None:
for k, v in units.items(): for k, v in units.items():
self.units[k] = self.units.get(k, 0) + v self.units[k] = self.units.get(k, 0) + v
def skip(self) -> None: def sell(self, units: Dict[Type[UnitType], int]) -> None:
for k, v in self.units.items(): for k, v in units.items():
if self.to_cp.captured: self.units[k] = self.units.get(k, 0) - v
name = "Ally "
else:
name = "Enemy "
self.game.message(
f"{name} reinforcements: {k.id} x {v} at {self.to_cp.name}")
self.to_cp.base.commision_units(self.units) def consume_each_order(self) -> Iterator[Tuple[Type[UnitType], int]]:
while self.units:
yield self.units.popitem()
def refund_all(self, game: Game) -> None:
for unit_type, count in self.consume_each_order():
try:
price = PRICES[unit_type]
except KeyError:
logging.error(f"Could not refund {unit_type.id}, price unknown")
continue
logging.info(
f"Refunding {count} {unit_type.id} at {self.to_cp.name}")
game.adjust_budget(price * count, player=self.to_cp.captured)
def available_next_turn(self, unit_type: Type[UnitType]) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
current_units = self.to_cp.base.total_units_of_type(unit_type)
return pending_units + current_units
def process(self, game: Game) -> None:
bought_units: Dict[Type[UnitType], int] = {}
sold_units: Dict[Type[UnitType], int] = {}
for unit_type, count in self.units.items():
coalition = "Ally" if self.to_cp.captured else "Enemy"
aircraft = unit_type.id
name = self.to_cp.name
if count >= 0:
bought_units[unit_type] = count
game.message(
f"{coalition} reinforcements: {aircraft} x {count} at {name}")
else:
sold_units[unit_type] = -count
game.message(
f"{coalition} sold: {aircraft} x {-count} at {name}")
self.to_cp.base.commision_units(bought_units)
self.to_cp.base.commit_losses(sold_units)
self.units = {}
bought_units = {}
sold_units = {}

View File

@ -4,7 +4,7 @@ import random
import sys import sys
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from enum import Enum from enum import Enum
from typing import Dict, List from typing import Any, Dict, List
from dcs.action import Coalition from dcs.action import Coalition
from dcs.mapping import Point from dcs.mapping import Point
@ -28,9 +28,12 @@ from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction from .factions.faction import Faction
from .income import Income from .income import Income
from .infos.information import Information from .infos.information import Information
from .navmesh import NavMesh
from .procurement import ProcurementAi from .procurement import ProcurementAi
from .settings import Settings from .settings import Settings
from .theater import ConflictTheater, ControlPoint from .theater import ConflictTheater, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import MissileSiteGroundObject
from .threatzones import ThreatZones
from .unitmap import UnitMap from .unitmap import UnitMap
from .weather import Conditions, TimeOfDay from .weather import Conditions, TimeOfDay
@ -67,16 +70,18 @@ AWACS_BUDGET_COST = 4
# Bonus multiplier logarithm base # Bonus multiplier logarithm base
PLAYER_BUDGET_IMPORTANCE_LOG = 2 PLAYER_BUDGET_IMPORTANCE_LOG = 2
class TurnState(Enum): class TurnState(Enum):
WIN = 0 WIN = 0
LOSS = 1 LOSS = 1
CONTINUE = 2 CONTINUE = 2
class Game: class Game:
def __init__(self, player_name: str, enemy_name: str, def __init__(self, player_name: str, enemy_name: str,
theater: ConflictTheater, start_date: datetime, theater: ConflictTheater, start_date: datetime,
settings: Settings, player_budget: int, settings: Settings, player_budget: float,
enemy_budget: int) -> None: enemy_budget: float) -> None:
self.settings = settings self.settings = settings
self.events: List[Event] = [] self.events: List[Event] = []
self.theater = theater self.theater = theater
@ -108,9 +113,6 @@ class Game:
self.theater.controlpoints self.theater.controlpoints
) )
for cp in self.theater.controlpoints:
cp.pending_unit_deliveries = self.units_delivery_event(cp)
self.sanitize_sides() self.sanitize_sides()
self.on_load() self.on_load()
@ -126,6 +128,21 @@ class Game:
self.plan_procurement(blue_planner, red_planner) self.plan_procurement(blue_planner, red_planner)
def __getstate__(self) -> Dict[str, Any]:
state = self.__dict__.copy()
# Avoid persisting any volatile types that can be deterministically
# recomputed on load for the sake of save compatibility.
del state["blue_threat_zone"]
del state["red_threat_zone"]
del state["blue_navmesh"]
del state["red_navmesh"]
return state
def __setstate__(self, state: Dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.on_load()
def generate_conditions(self) -> Conditions: def generate_conditions(self) -> Conditions:
return Conditions.generate(self.theater, self.date, return Conditions.generate(self.theater, self.date,
self.current_turn_time_of_day, self.settings) self.current_turn_time_of_day, self.settings)
@ -151,6 +168,11 @@ class Game:
def enemy_faction(self) -> Faction: def enemy_faction(self) -> Faction:
return db.FACTIONS[self.enemy_name] return db.FACTIONS[self.enemy_name]
def faction_for(self, player: bool) -> Faction:
if player:
return self.player_faction
return self.enemy_faction
def _roll(self, prob, mult): def _roll(self, prob, mult):
if self.settings.version == "dev": if self.settings.version == "dev":
# always generate all events for dev # always generate all events for dev
@ -167,6 +189,12 @@ class Game:
front_line.control_point_a, front_line.control_point_a,
front_line.control_point_b) front_line.control_point_b)
def adjust_budget(self, amount: float, player: bool) -> None:
if player:
self.budget += amount
else:
self.enemy_budget += amount
def process_player_income(self): def process_player_income(self):
self.budget += Income(self, player=True).total self.budget += Income(self, player=True).total
@ -176,15 +204,6 @@ class Game:
self.enemy_budget = 0 self.enemy_budget = 0
self.enemy_budget += Income(self, player=False).total self.enemy_budget += Income(self, player=False).total
def units_delivery_event(self, to_cp: ControlPoint) -> UnitsDeliveryEvent:
event = UnitsDeliveryEvent(attacker_name=self.player_name,
defender_name=self.player_name,
from_cp=to_cp,
to_cp=to_cp,
game=self)
self.events.append(event)
return event
def initiate_event(self, event: Event) -> UnitMap: def initiate_event(self, event: Event) -> UnitMap:
#assert event in self.events #assert event in self.events
logging.info("Generating {} (regular)".format(event)) logging.info("Generating {} (regular)".format(event))
@ -193,8 +212,6 @@ class Game:
def finish_event(self, event: Event, debriefing: Debriefing): def finish_event(self, event: Event, debriefing: Debriefing):
logging.info("Finishing event {}".format(event)) logging.info("Finishing event {}".format(event))
event.commit(debriefing) event.commit(debriefing)
self.budget += int(event.bonus() *
self.settings.player_income_multiplier)
if event in self.events: if event in self.events:
self.events.remove(event) self.events.remove(event)
@ -211,22 +228,15 @@ class Game:
LuaPluginManager.load_settings(self.settings) LuaPluginManager.load_settings(self.settings)
ObjectiveDistanceCache.set_theater(self.theater) ObjectiveDistanceCache.set_theater(self.theater)
self.compute_conflicts_position() self.compute_conflicts_position()
self.compute_threat_zones()
def pass_turn(self, no_action: bool = False) -> None: def pass_turn(self, no_action: bool = False) -> None:
logging.info("Pass turn") logging.info("Pass turn")
self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0)) self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0))
self.turn += 1 self.turn += 1
for event in self.events:
if self.settings.version == "dev":
# don't damage player CPs in by skipping in dev mode
if isinstance(event, UnitsDeliveryEvent):
event.skip()
else:
event.skip()
for control_point in self.theater.controlpoints: for control_point in self.theater.controlpoints:
control_point.process_turn() control_point.process_turn(self)
self.process_enemy_income() self.process_enemy_income()
@ -264,7 +274,6 @@ class Game:
self.aircraft_inventory.reset() self.aircraft_inventory.reset()
for cp in self.theater.controlpoints: for cp in self.theater.controlpoints:
cp.pending_unit_deliveries = self.units_delivery_event(cp)
self.aircraft_inventory.set_from_control_point(cp) self.aircraft_inventory.set_from_control_point(cp)
# Check for win or loss condition # Check for win or loss condition
@ -274,6 +283,7 @@ class Game:
# Plan flights & combat for next turn # Plan flights & combat for next turn
self.compute_conflicts_position() self.compute_conflicts_position()
self.compute_threat_zones()
self.ground_planners = {} self.ground_planners = {}
self.blue_ato.clear() self.blue_ato.clear()
self.red_ato.clear() self.red_ato.clear()
@ -294,13 +304,20 @@ class Game:
def plan_procurement(self, blue_planner: CoalitionMissionPlanner, def plan_procurement(self, blue_planner: CoalitionMissionPlanner,
red_planner: CoalitionMissionPlanner) -> None: red_planner: CoalitionMissionPlanner) -> None:
# The first turn needs to buy a *lot* of aircraft to fill CAPs, so it
# gets much more of the budget that turn. Otherwise budget (after
# repairs) is split evenly between air and ground. For the default
# starting budget of 2000 this gives 600 to ground forces and 1400 to
# aircraft.
ground_portion = 0.3 if self.turn == 0 else 0.5
self.budget = ProcurementAi( self.budget = ProcurementAi(
self, self,
for_player=True, for_player=True,
faction=self.player_faction, faction=self.player_faction,
manage_runways=self.settings.automate_runway_repair, manage_runways=self.settings.automate_runway_repair,
manage_front_line=self.settings.automate_front_line_reinforcements, manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements manage_aircraft=self.settings.automate_aircraft_reinforcements,
front_line_budget_share=ground_portion
).spend_budget(self.budget, blue_planner.procurement_requests) ).spend_budget(self.budget, blue_planner.procurement_requests)
self.enemy_budget = ProcurementAi( self.enemy_budget = ProcurementAi(
@ -309,7 +326,8 @@ class Game:
faction=self.enemy_faction, faction=self.enemy_faction,
manage_runways=True, manage_runways=True,
manage_front_line=True, manage_front_line=True,
manage_aircraft=True manage_aircraft=True,
front_line_budget_share=ground_portion
).spend_budget(self.enemy_budget, red_planner.procurement_requests) ).spend_budget(self.enemy_budget, red_planner.procurement_requests)
def message(self, text: str) -> None: def message(self, text: str) -> None:
@ -337,6 +355,24 @@ class Game:
self.current_group_id += 1 self.current_group_id += 1
return self.current_group_id return self.current_group_id
def compute_threat_zones(self) -> None:
self.blue_threat_zone = ThreatZones.for_faction(self, player=True)
self.red_threat_zone = ThreatZones.for_faction(self, player=False)
self.blue_navmesh = NavMesh.from_threat_zones(self.red_threat_zone,
self.theater)
self.red_navmesh = NavMesh.from_threat_zones(self.blue_threat_zone,
self.theater)
def threat_zone_for(self, player: bool) -> ThreatZones:
if player:
return self.blue_threat_zone
return self.red_threat_zone
def navmesh_for(self, player: bool) -> NavMesh:
if player:
return self.blue_navmesh
return self.red_navmesh
def compute_conflicts_position(self): def compute_conflicts_position(self):
""" """
Compute the current conflict center position(s), mainly used for culling calculation Compute the current conflict center position(s), mainly used for culling calculation
@ -353,9 +389,14 @@ class Game:
points.append(front_line.control_point_a.position) points.append(front_line.control_point_a.position)
points.append(front_line.control_point_b.position) points.append(front_line.control_point_b.position)
for cp in self.theater.controlpoints:
# Don't cull missile sites - their range is long enough to make them
# easily culled despite being a threat.
for tgo in cp.ground_objects:
if isinstance(tgo, MissileSiteGroundObject):
points.append(cp.position)
# If do_not_cull_carrier is enabled, add carriers as culling point # If do_not_cull_carrier is enabled, add carriers as culling point
if self.settings.perf_do_not_cull_carrier: if self.settings.perf_do_not_cull_carrier:
for cp in self.theater.controlpoints:
if cp.is_carrier or cp.is_lha: if cp.is_carrier or cp.is_lha:
points.append(cp.position) points.append(cp.position)

View File

@ -3,8 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from game.db import PLAYER_BUDGET_BASE, REWARDS from game.db import REWARDS
from game.theater import ControlPoint
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
@ -22,12 +21,6 @@ class BuildingIncome:
return self.number * self.income_per_building return self.number * self.income_per_building
@dataclass(frozen=True)
class ControlPointIncome:
control_point: ControlPoint
income: int
class Income: class Income:
def __init__(self, game: Game, player: bool) -> None: def __init__(self, game: Game, player: bool) -> None:
if player: if player:
@ -37,12 +30,10 @@ class Income:
self.control_points = [] self.control_points = []
self.buildings = [] self.buildings = []
self.income_per_base = PLAYER_BUDGET_BASE if player else 0
names = set() names = set()
for cp in game.theater.control_points_for(player): for cp in game.theater.control_points_for(player):
self.control_points.append( if cp.income_per_turn:
ControlPointIncome(cp, self.income_per_base)) self.control_points.append(cp)
for tgo in cp.ground_objects: for tgo in cp.ground_objects:
names.add(tgo.obj_name) names.add(tgo.obj_name)
@ -58,7 +49,7 @@ class Income:
self.buildings.append(BuildingIncome(name, category, count, self.buildings.append(BuildingIncome(name, category, count,
REWARDS[category])) REWARDS[category]))
self.from_bases = sum(cp.income for cp in self.control_points) self.from_bases = sum(cp.income_per_turn for cp in self.control_points)
self.total_buildings = sum(b.income for b in self.buildings) self.total_buildings = sum(b.income for b in self.buildings)
self.total = ((self.total_buildings + self.from_bases) * self.total = ((self.total_buildings + self.from_bases) *
self.multiplier) self.multiplier)

270
game/navmesh.py Normal file
View File

@ -0,0 +1,270 @@
from __future__ import annotations
import heapq
import math
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple, Union
from dcs.mapping import Point
from shapely.geometry import (
LineString,
MultiPolygon,
Point as ShapelyPoint,
Polygon,
box,
)
from shapely.ops import nearest_points, triangulate
from game.theater import ConflictTheater
from game.threatzones import ThreatZones
from game.utils import nautical_miles
class NavMeshPoly:
def __init__(self, ident: int, poly: Polygon, threatened: bool) -> None:
self.ident = ident
self.poly = poly
self.threatened = threatened
self.neighbors: Dict[NavMeshPoly, Union[LineString, ShapelyPoint]] = {}
def __eq__(self, other: object) -> bool:
if not isinstance(other, NavMeshPoly):
return False
return self.ident == other.ident
def __hash__(self) -> int:
return self.ident
@dataclass(frozen=True)
class NavPoint:
point: ShapelyPoint
poly: NavMeshPoly
@property
def world_point(self) -> Point:
return Point(self.point.x, self.point.y)
def __hash__(self) -> int:
return hash(self.poly.ident)
def __eq__(self, other: object) -> bool:
if id(self) == id(other):
return True
if not isinstance(other, NavPoint):
return False
if not self.point.almost_equals(other.point):
return False
return self.poly == other.poly
def __str__(self) -> str:
return f"{self.point} in {self.poly.ident}"
@dataclass(frozen=True, order=True)
class FrontierNode:
cost: float
point: NavPoint = field(compare=False)
class NavFrontier:
def __init__(self) -> None:
self.nodes: List[FrontierNode] = []
def push(self, poly: NavPoint, cost: float) -> None:
heapq.heappush(self.nodes, FrontierNode(cost, poly))
def pop(self) -> Optional[NavPoint]:
try:
return heapq.heappop(self.nodes).point
except IndexError:
return None
class NavMesh:
def __init__(self, polys: List[NavMeshPoly]) -> None:
self.polys = polys
def localize(self, point: Point) -> Optional[NavMeshPoly]:
# This is a naive implementation but it's O(n). Runs at about 10k
# lookups a second on a 5950X. Flights usually have 5-10 waypoints, so
# that's 1k-2k flights before we lose a full second to localization as a
# part of flight plan creation.
#
# Can improve the algorithm later if needed, but that seems unnecessary
# currently.
p = ShapelyPoint(point.x, point.y)
for navpoly in self.polys:
if navpoly.poly.contains(p):
return navpoly
return None
@staticmethod
def travel_cost(a: NavPoint, b: NavPoint) -> float:
modifier = 1.0
if a.poly.threatened:
modifier = 3.0
return a.point.distance(b.point) * modifier
def travel_heuristic(self, a: NavPoint, b: NavPoint) -> float:
return self.travel_cost(a, b)
@staticmethod
def reconstruct_path(came_from: Dict[NavPoint, Optional[NavPoint]],
origin: NavPoint,
destination: NavPoint) -> List[Point]:
current = destination
path: List[Point] = []
while current != origin:
path.append(current.world_point)
previous = came_from[current]
if previous is None:
raise RuntimeError(
f"Could not reconstruct path to {destination} from {origin}"
)
current = previous
path.append(origin.world_point)
path.reverse()
return path
@staticmethod
def dcs_to_shapely_point(point: Point) -> ShapelyPoint:
return ShapelyPoint(point.x, point.y)
def shortest_path(self, origin: Point, destination: Point) -> List[Point]:
origin_poly = self.localize(origin)
if origin_poly is None:
raise ValueError(f"Origin point {origin} is outside the navmesh")
destination_poly = self.localize(destination)
if destination_poly is None:
raise ValueError(
f"Origin point {destination} is outside the navmesh")
return self._shortest_path(
NavPoint(self.dcs_to_shapely_point(origin), origin_poly),
NavPoint(self.dcs_to_shapely_point(destination), destination_poly)
)
def _shortest_path(self, origin: NavPoint,
destination: NavPoint) -> List[Point]:
# Adapted from
# https://www.redblobgames.com/pathfinding/a-star/implementation.py.
frontier = NavFrontier()
frontier.push(origin, 0.0)
came_from: Dict[NavPoint, Optional[NavPoint]] = {origin: None}
best_known: Dict[NavPoint, float] = defaultdict(lambda: math.inf)
best_known[origin] = 0.0
while (current := frontier.pop()) is not None:
if current == destination:
break
if current.poly == destination.poly:
# Made it to the correct nav poly. Add the leg from the border
# to the target.
cost = best_known[current] + self.travel_cost(
current, destination
)
if cost < best_known[destination]:
best_known[destination] = cost
estimated = cost
frontier.push(destination, estimated)
came_from[destination] = current
for neighbor, boundary in current.poly.neighbors.items():
previous = came_from[current]
if previous is not None and previous.poly == neighbor:
# Don't backtrack.
continue
if previous is None and current != origin:
raise RuntimeError
_, neighbor_point = nearest_points(current.point, boundary)
neighbor_nav = NavPoint(neighbor_point, neighbor)
cost = best_known[current] + self.travel_cost(
current, neighbor_nav
)
if cost < best_known[neighbor_nav]:
best_known[neighbor_nav] = cost
estimated = cost + self.travel_heuristic(
neighbor_nav, destination
)
frontier.push(neighbor_nav, estimated)
came_from[neighbor_nav] = current
return self.reconstruct_path(came_from, origin, destination)
@staticmethod
def map_bounds(theater: ConflictTheater) -> Polygon:
points = []
for cp in theater.controlpoints:
points.append(ShapelyPoint(cp.position.x, cp.position.y))
for tgo in cp.ground_objects:
points.append(ShapelyPoint(tgo.position.x, tgo.position.y))
# Needs to be a large enough boundary beyond the known points so that
# threatened airbases at the map edges have room to retreat from the
# threat without running off the navmesh.
return box(*LineString(points).bounds).buffer(
nautical_miles(100).meters, resolution=1)
@staticmethod
def create_navpolys(polys: List[Polygon],
threat_zones: ThreatZones) -> List[NavMeshPoly]:
return [NavMeshPoly(i, p, threat_zones.threatened(p))
for i, p in enumerate(polys)]
@staticmethod
def associate_neighbors(polys: List[NavMeshPoly]) -> None:
# Maps (rounded) points to polygons that have a vertex at that point.
# The points are rounded to the nearest int so we can use them as dict
# keys. This allows us to perform approximate neighbor lookups more
# efficiently than comparing each poly to every other poly by finding
# approximate neighbors before checking if the polys actually touch.
points_map: Dict[Tuple[int, int], Set[NavMeshPoly]] = defaultdict(set)
for navpoly in polys:
# The coordinates of the polygon's boundary are a sequence of
# coordinates that define the polygon. The first point is repeated
# at the end, so skip the last vertex.
for x, y in navpoly.poly.boundary.coords[:-1]:
point = (int(x), int(y))
neighbors = {}
for potential_neighbor in points_map[point]:
intersection = navpoly.poly.intersection(
potential_neighbor.poly)
if not intersection.is_empty:
potential_neighbor.neighbors[navpoly] = intersection
neighbors[potential_neighbor] = intersection
navpoly.neighbors.update(neighbors)
points_map[point].add(navpoly)
@classmethod
def from_threat_zones(cls, threat_zones: ThreatZones,
theater: ConflictTheater) -> NavMesh:
# Simplify the threat poly to reduce the number of nav zones. Increase
# the size of the zone and then simplify it with the buffer size as the
# error margin. This will create a simpler poly around the threat zone.
buffer = nautical_miles(10).meters
threat_poly = threat_zones.all.buffer(buffer).simplify(buffer)
# Threat zones can be disconnected. Create a list of threat zones.
if isinstance(threat_poly, MultiPolygon):
polys = list(threat_poly.geoms)
else:
polys = [threat_poly]
# Subtract the threat zones from the whole-map poly to build a navmesh
# for the *safe* areas. Navigation within threatened regions is always
# a straight line to the target or out of the threatened region.
bounds = cls.map_bounds(theater)
for poly in polys:
bounds = bounds.difference(poly)
# Triangulate the safe-region to build the navmesh.
navpolys = cls.create_navpolys(triangulate(bounds), threat_zones)
cls.associate_neighbors(navpolys)
return cls(navpolys)

View File

@ -26,12 +26,12 @@ from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator from gen.kneeboard import KneeboardGenerator
from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry from gen.tacan import TacanRegistry
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from .. import db from .. import db
from ..debriefing import Debriefing
from ..theater import Airfield from ..theater import Airfield
from ..unitmap import UnitMap from ..unitmap import UnitMap
@ -349,7 +349,7 @@ class Operation:
cls.jtacs, cls.jtacs,
cls.airgen cls.airgen
) )
cls.reset_naming_ids()
return cls.unit_map return cls.unit_map
@classmethod @classmethod
@ -411,6 +411,10 @@ class Operation:
ground_conflict_gen.generate() ground_conflict_gen.generate()
cls.jtacs.extend(ground_conflict_gen.jtacs) cls.jtacs.extend(ground_conflict_gen.jtacs)
@classmethod
def reset_naming_ids(cls):
namegen.reset_numbers()
@classmethod @classmethod
def generate_lua(cls, airgen: AircraftConflictGenerator, def generate_lua(cls, airgen: AircraftConflictGenerator,
airsupportgen: AirSupportConflictGenerator, airsupportgen: AirSupportConflictGenerator,

View File

@ -5,18 +5,16 @@ import random
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterator, List, Optional, TYPE_CHECKING, Type from typing import Iterator, List, Optional, TYPE_CHECKING, Type
from dcs.task import CAP, CAS
from dcs.unittype import FlyingType, VehicleType from dcs.unittype import FlyingType, VehicleType
from game import db from game import db
from game.factions.faction import Faction from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget, TYPE_SHORAD from game.theater import ControlPoint, MissionTarget
from gen.flights.ai_flight_planner_db import ( from game.utils import Distance
capable_aircraft_for_task, from gen.flights.ai_flight_planner_db import aircraft_for_task
preferred_aircraft_for_task,
)
from gen.flights.closestairfields import ObjectiveDistanceCache from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
@ -25,36 +23,75 @@ if TYPE_CHECKING:
@dataclass(frozen=True) @dataclass(frozen=True)
class AircraftProcurementRequest: class AircraftProcurementRequest:
near: MissionTarget near: MissionTarget
range: int range: Distance
task_capability: FlightType task_capability: FlightType
number: int number: int
def __str__(self) -> str:
task = self.task_capability.value
distance = self.range.nautical_miles
target = self.near.name
return f"{self.number} ship {task} within {distance} nm of {target}"
class ProcurementAi: class ProcurementAi:
def __init__(self, game: Game, for_player: bool, faction: Faction, def __init__(self, game: Game, for_player: bool, faction: Faction,
manage_runways: bool, manage_front_line: bool, manage_runways: bool, manage_front_line: bool,
manage_aircraft: bool) -> None: manage_aircraft: bool, front_line_budget_share: float) -> None:
if front_line_budget_share > 1.0:
raise ValueError
self.game = game self.game = game
self.is_player = for_player self.is_player = for_player
self.faction = faction self.faction = faction
self.manage_runways = manage_runways self.manage_runways = manage_runways
self.manage_front_line = manage_front_line self.manage_front_line = manage_front_line
self.manage_aircraft = manage_aircraft self.manage_aircraft = manage_aircraft
self.front_line_budget_share = front_line_budget_share
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def spend_budget( def spend_budget(
self, budget: int, self, budget: float,
aircraft_requests: List[AircraftProcurementRequest]) -> int: aircraft_requests: List[AircraftProcurementRequest]) -> float:
if self.manage_runways: if self.manage_runways:
budget = self.repair_runways(budget) budget = self.repair_runways(budget)
if self.manage_front_line: if self.manage_front_line:
armor_budget = math.ceil(budget / 2) armor_budget = math.ceil(budget * self.front_line_budget_share)
budget -= armor_budget budget -= armor_budget
budget += self.reinforce_front_line(armor_budget) budget += self.reinforce_front_line(armor_budget)
# Don't sell overstock aircraft until after we've bought runways and
# front lines. Any budget we free up should be earmarked for aircraft.
if not self.is_player:
budget += self.sell_incomplete_squadrons()
if self.manage_aircraft: if self.manage_aircraft:
budget = self.purchase_aircraft(budget, aircraft_requests) budget = self.purchase_aircraft(budget, aircraft_requests)
return budget return budget
def repair_runways(self, budget: int) -> int: def sell_incomplete_squadrons(self) -> float:
# Selling incomplete squadrons gives us more money to spend on the next
# turn. This serves as a short term fix for
# https://github.com/Khopa/dcs_liberation/issues/41.
#
# Only incomplete squadrons which are unlikely to get used will be sold
# rather than all unused aircraft because the unused aircraft are what
# make OCA strikes worthwhile.
#
# This option is only used by the AI since players cannot cancel sales
# (https://github.com/Khopa/dcs_liberation/issues/365).
total = 0.0
for cp in self.game.theater.control_points_for(self.is_player):
inventory = self.game.aircraft_inventory.for_control_point(cp)
for aircraft, available in inventory.all_aircraft:
# We only ever plan even groups, so the odd aircraft is unlikely
# to get used.
if available % 2 == 0:
continue
inventory.remove_aircraft(aircraft, 1)
total += db.PRICES[aircraft]
return total
def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points: for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST: if budget < db.RUNWAY_REPAIR_COST:
break break
@ -74,7 +111,8 @@ class ProcurementAi:
return budget return budget
def random_affordable_ground_unit( def random_affordable_ground_unit(
self, budget: int, cp: ControlPoint) -> Optional[Type[VehicleType]]: self, budget: float,
cp: ControlPoint) -> Optional[Type[VehicleType]]:
affordable_units = [u for u in self.faction.frontline_units + self.faction.artillery_units if affordable_units = [u for u in self.faction.frontline_units + self.faction.artillery_units if
db.PRICES[u] <= budget] db.PRICES[u] <= budget]
@ -91,7 +129,7 @@ class ProcurementAi:
return None return None
return random.choice(affordable_units) return random.choice(affordable_units)
def reinforce_front_line(self, budget: int) -> int: def reinforce_front_line(self, budget: float) -> float:
if not self.faction.frontline_units and not self.faction.artillery_units: if not self.faction.frontline_units and not self.faction.artillery_units:
return budget return budget
@ -107,43 +145,38 @@ class ProcurementAi:
break break
budget -= db.PRICES[unit] budget -= db.PRICES[unit]
assert cp.pending_unit_deliveries is not None cp.pending_unit_deliveries.order({unit: 1})
cp.pending_unit_deliveries.deliver({unit: 1})
return budget return budget
def _affordable_aircraft_of_types( def _affordable_aircraft_of_types(
self, types: List[Type[FlyingType]], airbase: ControlPoint, self, types: List[Type[FlyingType]], airbase: ControlPoint,
number: int, max_price: int) -> Optional[Type[FlyingType]]: number: int, max_price: float) -> Optional[Type[FlyingType]]:
unit_pool = [u for u in self.faction.aircrafts if u in types] best_choice: Optional[Type[FlyingType]] = None
affordable_units = [ for unit in [u for u in self.faction.aircrafts if u in types]:
u for u in unit_pool if db.PRICES[unit] * number > max_price:
if db.PRICES[u] * number <= max_price and airbase.can_operate(u) continue
] if not airbase.can_operate(unit):
if not affordable_units: continue
return None
return random.choice(affordable_units) # Affordable and compatible. To keep some variety, skip with a 50/50
# chance. Might be a good idea to have the chance to skip based on
# the price compared to the rest of the choices.
best_choice = unit
if random.choice([True, False]):
break
return best_choice
def affordable_aircraft_for( def affordable_aircraft_for(
self, request: AircraftProcurementRequest, self, request: AircraftProcurementRequest,
airbase: ControlPoint, budget: int) -> Optional[Type[FlyingType]]: airbase: ControlPoint, budget: float) -> Optional[Type[FlyingType]]:
aircraft = self._affordable_aircraft_of_types(
preferred_aircraft_for_task(request.task_capability),
airbase, request.number, budget)
if aircraft is not None:
return aircraft
return self._affordable_aircraft_of_types( return self._affordable_aircraft_of_types(
capable_aircraft_for_task(request.task_capability), aircraft_for_task(request.task_capability),
airbase, request.number, budget) airbase, request.number, budget)
def purchase_aircraft( def purchase_aircraft(
self, budget: int, self, budget: float,
aircraft_requests: List[AircraftProcurementRequest]) -> int: aircraft_requests: List[AircraftProcurementRequest]) -> float:
unit_pool = [u for u in self.faction.aircrafts
if u in db.UNIT_BY_TASK[CAS] or u in db.UNIT_BY_TASK[CAP]]
if not unit_pool:
return budget
for request in aircraft_requests: for request in aircraft_requests:
for airbase in self.best_airbases_for(request): for airbase in self.best_airbases_for(request):
unit = self.affordable_aircraft_for(request, airbase, budget) unit = self.affordable_aircraft_for(request, airbase, budget)
@ -156,8 +189,7 @@ class ProcurementAi:
continue continue
budget -= db.PRICES[unit] * request.number budget -= db.PRICES[unit] * request.number
assert airbase.pending_unit_deliveries is not None airbase.pending_unit_deliveries.order({unit: request.number})
airbase.pending_unit_deliveries.deliver({unit: request.number})
return budget return budget
@ -174,6 +206,7 @@ class ProcurementAi:
distance_cache = ObjectiveDistanceCache.get_closest_airfields( distance_cache = ObjectiveDistanceCache.get_closest_airfields(
request.near request.near
) )
threatened = []
for cp in distance_cache.airfields_within(request.range): for cp in distance_cache.airfields_within(request.range):
if not cp.is_friendly(self.is_player): if not cp.is_friendly(self.is_player):
continue continue
@ -181,7 +214,10 @@ class ProcurementAi:
continue continue
if cp.unclaimed_parking(self.game) < request.number: if cp.unclaimed_parking(self.game) < request.number:
continue continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
yield cp yield cp
yield from threatened
def front_line_candidates(self) -> List[ControlPoint]: def front_line_candidates(self) -> List[ControlPoint]:
candidates = [] candidates = []
@ -189,7 +225,7 @@ class ProcurementAi:
# Prefer to buy front line units at active front lines that are not # Prefer to buy front line units at active front lines that are not
# already overloaded. # already overloaded.
for cp in self.owned_points: for cp in self.owned_points:
if cp.base.total_armor >= 30: if cp.expected_ground_units_next_turn.total >= 30:
# Control point is already sufficiently defended. # Control point is already sufficiently defended.
continue continue
for connected in cp.connected_points: for connected in cp.connected_points:
@ -197,8 +233,23 @@ class ProcurementAi:
candidates.append(cp) candidates.append(cp)
if not candidates: if not candidates:
# Otherwise buy them anywhere valid. # Otherwise buy reserves, but don't exceed 10 reserve units per CP.
candidates = [p for p in self.owned_points # These units do not exist in the world until the CP becomes
if p.can_deploy_ground_units] # connected to an active front line, at which point all these units
# will suddenly appear at the gates of the newly captured CP.
#
# To avoid sudden overwhelming numbers of units we avoid buying
# many.
#
# Also, do not bother buying units at bases that will never connect
# to a front line.
for cp in self.owned_points:
if not cp.can_deploy_ground_units:
continue
if cp.expected_ground_units_next_turn.total >= 10:
continue
if cp.is_global:
continue
candidates.append(cp)
return candidates return candidates

View File

@ -19,15 +19,17 @@ class Settings:
supercarrier: bool = False supercarrier: bool = False
generate_marks: bool = True generate_marks: bool = True
manpads: bool = True manpads: bool = True
cold_start: bool = False # Legacy parameter do not use
version: Optional[str] = None version: Optional[str] = None
player_income_multiplier: float = 1.0 player_income_multiplier: float = 1.0
enemy_income_multiplier: float = 1.0 enemy_income_multiplier: float = 1.0
default_start_type: str = "Cold"
# Campaign management # Campaign management
automate_runway_repair: bool = False automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False automate_aircraft_reinforcements: bool = False
restrict_weapons_by_date: bool = False
# Performance oriented # Performance oriented
perf_red_alert_state: bool = True perf_red_alert_state: bool = True
@ -35,7 +37,6 @@ class Settings:
perf_artillery: bool = True perf_artillery: bool = True
perf_moving_units: bool = True perf_moving_units: bool = True
perf_infantry: bool = True perf_infantry: bool = True
perf_ai_parking_start: bool = True
perf_destroyed_units: bool = True perf_destroyed_units: bool = True
# Performance culling # Performance culling
@ -48,6 +49,8 @@ class Settings:
# Cheating # Cheating
show_red_ato: bool = False show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
never_delay_player_flights: bool = False never_delay_player_flights: bool = False

View File

@ -9,6 +9,7 @@ from dcs.unittype import FlyingType, UnitType, VehicleType
from dcs.vehicles import AirDefence, Armor from dcs.vehicles import AirDefence, Armor
from game import db from game import db
from game.db import PRICES
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
STRENGTH_AA_ASSEMBLE_MIN = 0.2 STRENGTH_AA_ASSEMBLE_MIN = 0.2
@ -37,6 +38,16 @@ class Base:
def total_armor(self) -> int: def total_armor(self) -> int:
return sum(self.armor.values()) return sum(self.armor.values())
@property
def total_armor_value(self) -> int:
total = 0
for unit_type, count in self.armor.items():
try:
total += PRICES[unit_type] * count
except KeyError:
logging.exception(f"No price found for {unit_type.id}")
return total
@property @property
def total_frontline_aa(self) -> int: def total_frontline_aa(self) -> int:
return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD]) return sum([v for k, v in self.armor.items() if k in TYPE_SHORAD])

View File

@ -55,7 +55,7 @@ from .controlpoint import (
Fob, Fob,
) )
from .landmap import Landmap, load_landmap, poly_contains from .landmap import Landmap, load_landmap, poly_contains
from ..utils import nm_to_meter from ..utils import Distance, meters, nautical_miles
Numeric = Union[int, float] Numeric = Union[int, float]
@ -115,7 +115,7 @@ class MizCampaignLoader:
AirDefence.SAM_SA_3_S_125_LN_5P73.id, AirDefence.SAM_SA_3_S_125_LN_5P73.id,
} }
BASE_DEFENSE_RADIUS = nm_to_meter(2) BASE_DEFENSE_RADIUS = nautical_miles(2)
def __init__(self, miz: Path, theater: ConflictTheater) -> None: def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater self.theater = theater
@ -317,9 +317,9 @@ class MizCampaignLoader:
self.control_points[origin.id]) self.control_points[origin.id])
return front_lines return front_lines
def objective_info(self, group: Group) -> Tuple[ControlPoint, int]: def objective_info(self, group: Group) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(group.position) closest = self.theater.closest_control_point(group.position)
distance = closest.position.distance_to_point(group.position) distance = meters(closest.position.distance_to_point(group.position))
return closest, distance return closest, distance
def add_preset_locations(self) -> None: def add_preset_locations(self) -> None:
@ -447,11 +447,11 @@ class ConflictTheater:
if self.is_on_land(point): if self.is_on_land(point):
return False return False
for exclusion_zone in self.landmap[1]: for exclusion_zone in self.landmap.exclusion_zones:
if poly_contains(point.x, point.y, exclusion_zone): if poly_contains(point.x, point.y, exclusion_zone):
return False return False
for sea in self.landmap[2]: for sea in self.landmap.sea_zones:
if poly_contains(point.x, point.y, sea): if poly_contains(point.x, point.y, sea):
return True return True
@ -462,14 +462,13 @@ class ConflictTheater:
return True return True
is_point_included = False is_point_included = False
for inclusion_zone in self.landmap[0]: if poly_contains(point.x, point.y, self.landmap.inclusion_zones):
if poly_contains(point.x, point.y, inclusion_zone):
is_point_included = True is_point_included = True
if not is_point_included: if not is_point_included:
return False return False
for exclusion_zone in self.landmap[1]: for exclusion_zone in self.landmap.exclusion_zones:
if poly_contains(point.x, point.y, exclusion_zone): if poly_contains(point.x, point.y, exclusion_zone):
return False return False
@ -484,7 +483,7 @@ class ConflictTheater:
nearest_points = [] nearest_points = []
if not self.landmap: if not self.landmap:
raise RuntimeError("Landmap not initialized") raise RuntimeError("Landmap not initialized")
for inclusion_zone in self.landmap[0]: for inclusion_zone in self.landmap.inclusion_zones:
nearest_pair = ops.nearest_points(point, inclusion_zone) nearest_pair = ops.nearest_points(point, inclusion_zone)
nearest_points.append(nearest_pair[1]) nearest_points.append(nearest_pair[1])
min_distance = point.distance(nearest_points[0]) # type: geometry.Point min_distance = point.distance(nearest_points[0]) # type: geometry.Point
@ -529,6 +528,26 @@ class ConflictTheater:
closest_distance = distance closest_distance = distance
return closest return closest
def closest_target(self, point: Point) -> MissionTarget:
closest: MissionTarget = self.controlpoints[0]
closest_distance = point.distance_to_point(closest.position)
for control_point in self.controlpoints[1:]:
distance = point.distance_to_point(control_point.position)
if distance < closest_distance:
closest = control_point
closest_distance = distance
for tgo in control_point.ground_objects:
distance = point.distance_to_point(tgo.position)
if distance < closest_distance:
closest = tgo
closest_distance = distance
for conflict in self.conflicts():
distance = conflict.position.distance_to_point(point)
if distance < closest_distance:
closest = conflict
closest_distance = distance
return closest
def closest_opposing_control_points(self) -> Tuple[ControlPoint, ControlPoint]: def closest_opposing_control_points(self) -> Tuple[ControlPoint, ControlPoint]:
""" """
Returns a tuple of the two nearest opposing ControlPoints in theater. Returns a tuple of the two nearest opposing ControlPoints in theater.

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import heapq
import itertools import itertools
import logging import logging
import random import random
@ -7,7 +8,8 @@ import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Dict, Iterator, List, Optional, TYPE_CHECKING, Type from functools import total_ordering
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Type
from dcs.mapping import Point from dcs.mapping import Point
from dcs.ships import ( from dcs.ships import (
@ -20,24 +22,27 @@ from dcs.terrain.terrain import Airport, ParkingSlot
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game import db from game import db
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD from gen.ground_forces.ai_ground_planner_db import TYPE_SHORAD
from gen.runways import RunwayAssigner, RunwayData
from gen.ground_forces.combat_stance import CombatStance from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData
from .base import Base from .base import Base
from .missiontarget import MissionTarget from .missiontarget import MissionTarget
from .theatergroundobject import ( from .theatergroundobject import (
BaseDefenseGroundObject, BaseDefenseGroundObject,
EwrGroundObject, EwrGroundObject,
GenericCarrierGroundObject,
SamGroundObject, SamGroundObject,
TheaterGroundObject, TheaterGroundObject,
VehicleGroupGroundObject, GenericCarrierGroundObject, VehicleGroupGroundObject,
) )
from ..db import PRICES
from ..utils import nautical_miles
from ..weather import Conditions from ..weather import Conditions
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
from ..event import UnitsDeliveryEvent
class ControlPointType(Enum): class ControlPointType(Enum):
@ -191,6 +196,28 @@ class RunwayStatus:
return f"Runway repairing, {turns_remaining} turns remaining" return f"Runway repairing, {turns_remaining} turns remaining"
@total_ordering
class GroundUnitDestination:
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
@property
def total_value(self) -> float:
return self.control_point.base.total_armor_value
def __eq__(self, other: Any) -> bool:
if not isinstance(other, GroundUnitDestination):
raise TypeError
return self.total_value == other.total_value
def __lt__(self, other: Any) -> bool:
if not isinstance(other, GroundUnitDestination):
raise TypeError
return self.total_value < other.total_value
class ControlPoint(MissionTarget, ABC): class ControlPoint(MissionTarget, ABC):
position = None # type: Point position = None # type: Point
@ -208,7 +235,7 @@ class ControlPoint(MissionTarget, ABC):
at: db.StartingPosition, size: int, at: db.StartingPosition, size: int,
importance: float, has_frontline=True, importance: float, has_frontline=True,
cptype=ControlPointType.AIRBASE): cptype=ControlPointType.AIRBASE):
super().__init__(" ".join(re.split(r"[ \-]", name)[:2]), position) super().__init__(name, position)
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
self.id = cp_id self.id = cp_id
self.full_name = name self.full_name = name
@ -229,7 +256,8 @@ class ControlPoint(MissionTarget, ABC):
self.cptype = cptype self.cptype = cptype
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
self.stances: Dict[int, CombatStance] = {} self.stances: Dict[int, CombatStance] = {}
self.pending_unit_deliveries: Optional[UnitsDeliveryEvent] = None from ..event import UnitsDeliveryEvent
self.pending_unit_deliveries = UnitsDeliveryEvent(self)
self.target_position: Optional[Point] = None self.target_position: Optional[Point] = None
@ -364,8 +392,90 @@ class ControlPoint(MissionTarget, ABC):
base_defense.position) base_defense.position)
self.base_defenses = [] self.base_defenses = []
def capture_equipment(self, game: Game) -> None:
total = self.base.total_armor_value
self.base.armor.clear()
game.adjust_budget(total, player=not self.captured)
game.message(
f"{self.name} is not connected to any friendly points. Ground "
f"vehicles have been captured and sold for ${total}M.")
def retreat_ground_units(self, game: Game):
# When there are multiple valid destinations, deliver units to whichever
# base is least defended first. The closest approximation of unit
# strength we have is price
destinations = [GroundUnitDestination(cp)
for cp in self.connected_points
if cp.captured == self.captured]
if not destinations:
self.capture_equipment(game)
return
heapq.heapify(destinations)
destination = heapq.heappop(destinations)
while self.base.armor:
unit_type, count = self.base.armor.popitem()
for _ in range(count):
destination.control_point.base.commision_units({unit_type: 1})
destination = heapq.heappushpop(destinations, destination)
def capture_aircraft(self, game: Game, airframe: Type[FlyingType],
count: int) -> None:
try:
value = PRICES[airframe] * count
except KeyError:
logging.exception(f"Unknown price for {airframe.id}")
return
game.adjust_budget(value, player=not self.captured)
game.message(
f"No valid retreat destination in range of {self.name} for "
f"{airframe.id}. {count} aircraft have been captured and sold for "
f"${value}M.")
def aircraft_retreat_destination(
self, game: Game,
airframe: Type[FlyingType]) -> Optional[ControlPoint]:
closest = ObjectiveDistanceCache.get_closest_airfields(self)
# TODO: Should be airframe dependent.
max_retreat_distance = nautical_miles(200)
# Skip the first airbase because that's the airbase we're retreating
# from.
airfields = list(closest.airfields_within(max_retreat_distance))[1:]
for airbase in airfields:
if not airbase.can_operate(airframe):
continue
if airbase.captured != self.captured:
continue
if airbase.unclaimed_parking(game) > 0:
return airbase
return None
def _retreat_air_units(self, game: Game, airframe: Type[FlyingType],
count: int) -> None:
while count:
logging.debug(f"Retreating {count} {airframe.id} from {self.name}")
destination = self.aircraft_retreat_destination(game, airframe)
if destination is None:
self.capture_aircraft(game, airframe, count)
return
parking = destination.unclaimed_parking(game)
transfer_amount = min([parking, count])
destination.base.commision_units({airframe: transfer_amount})
count -= transfer_amount
def retreat_air_units(self, game: Game) -> None:
# TODO: Capture in order of price to retain maximum value?
while self.base.aircraft:
airframe, count = self.base.aircraft.popitem()
self._retreat_air_units(game, airframe, count)
# TODO: Should be Airbase specific. # TODO: Should be Airbase specific.
def capture(self, game: Game, for_player: bool) -> None: def capture(self, game: Game, for_player: bool) -> None:
self.pending_unit_deliveries.refund_all(game)
self.retreat_ground_units(game)
self.retreat_air_units(game)
if for_player: if for_player:
self.captured = True self.captured = True
else: else:
@ -373,9 +483,6 @@ class ControlPoint(MissionTarget, ABC):
self.base.set_strength_to_minimum() self.base.set_strength_to_minimum()
self.base.aircraft = {}
self.base.armor = {}
self.clear_base_defenses() self.clear_base_defenses()
from .start_generator import BaseDefenseGenerator from .start_generator import BaseDefenseGenerator
BaseDefenseGenerator(game, self).generate() BaseDefenseGenerator(game, self).generate()
@ -402,7 +509,6 @@ class ControlPoint(MissionTarget, ABC):
return total return total
def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy: def expected_aircraft_next_turn(self, game: Game) -> PendingOccupancy:
assert self.pending_unit_deliveries
on_order = 0 on_order = 0
for unit_bought in self.pending_unit_deliveries.units: for unit_bought in self.pending_unit_deliveries.units:
if issubclass(unit_bought, FlyingType): if issubclass(unit_bought, FlyingType):
@ -439,7 +545,9 @@ class ControlPoint(MissionTarget, ABC):
return return
self.runway_status.begin_repair() self.runway_status.begin_repair()
def process_turn(self) -> None: def process_turn(self, game: Game) -> None:
self.pending_unit_deliveries.process(game)
runway_status = self.runway_status runway_status = self.runway_status
if runway_status is not None: if runway_status is not None:
runway_status.process_turn() runway_status.process_turn()
@ -453,6 +561,8 @@ class ControlPoint(MissionTarget, ABC):
# Move the linked unit groups # Move the linked unit groups
for ground_object in self.ground_objects: for ground_object in self.ground_objects:
if isinstance(ground_object, GenericCarrierGroundObject): if isinstance(ground_object, GenericCarrierGroundObject):
ground_object.position.x = ground_object.position.x + delta.x
ground_object.position.y = ground_object.position.y + delta.y
for group in ground_object.groups: for group in ground_object.groups:
for u in group.units: for u in group.units:
u.position.x = u.position.x + delta.x u.position.x = u.position.x + delta.x
@ -478,6 +588,24 @@ class ControlPoint(MissionTarget, ABC):
else: else:
return 0 return 0
@property
def expected_ground_units_next_turn(self) -> PendingOccupancy:
on_order = 0
for unit_bought in self.pending_unit_deliveries.units:
if issubclass(unit_bought, FlyingType):
continue
if unit_bought in TYPE_SHORAD:
continue
on_order += self.pending_unit_deliveries.units[unit_bought]
return PendingOccupancy(self.base.total_armor, on_order,
# Ground unit transfers not yet implemented.
transferring=0)
@property
def income_per_turn(self) -> int:
return 0
class Airfield(ControlPoint): class Airfield(ControlPoint):
@ -542,6 +670,10 @@ class Airfield(ControlPoint):
def can_deploy_ground_units(self) -> bool: def can_deploy_ground_units(self) -> bool:
return True return True
@property
def income_per_turn(self) -> int:
return 20
class NavalControlPoint(ControlPoint, ABC): class NavalControlPoint(ControlPoint, ABC):
@ -741,3 +873,7 @@ class Fob(ControlPoint):
@property @property
def can_deploy_ground_units(self) -> bool: def can_deploy_ground_units(self) -> bool:
return True return True
@property
def income_per_turn(self) -> int:
return 10

View File

@ -1,11 +1,30 @@
from dataclasses import dataclass
import pickle import pickle
from typing import Collection, Optional, Tuple from functools import cached_property
from typing import Optional, Tuple, Union
import logging import logging
from shapely import geometry from shapely import geometry
from shapely.geometry import MultiPolygon, Polygon
Zone = Collection[Tuple[float, float]]
Landmap = Tuple[Collection[geometry.Polygon], Collection[geometry.Polygon], Collection[geometry.Polygon]] @dataclass(frozen=True)
class Landmap:
inclusion_zones: MultiPolygon
exclusion_zones: MultiPolygon
sea_zones: MultiPolygon
def __post_init__(self):
if not self.inclusion_zones.is_valid:
raise RuntimeError("Inclusion zones not valid")
if not self.exclusion_zones.is_valid:
raise RuntimeError("Exclusion zones not valid")
if not self.sea_zones.is_valid:
raise RuntimeError("Sea zones not valid")
@cached_property
def inclusion_zone_only(self) -> MultiPolygon:
return self.inclusion_zones - self.exclusion_zones - self.sea_zones
def load_landmap(filename: str) -> Optional[Landmap]: def load_landmap(filename: str) -> Optional[Landmap]:
@ -17,7 +36,7 @@ def load_landmap(filename: str) -> Optional[Landmap]:
return None return None
def poly_contains(x, y, poly:geometry.Polygon): def poly_contains(x, y, poly: Union[MultiPolygon, Polygon]):
return poly.contains(geometry.Point(x, y)) return poly.contains(geometry.Point(x, y))

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import pickle import pickle
import random import random
from dataclasses import dataclass from dataclasses import dataclass
@ -15,7 +14,6 @@ from dcs.vehicles import AirDefence
from game import Game, db from game import Game, db
from game.factions.faction import Faction from game.factions.faction import Faction
from game.theater import Carrier, Lha, LocationType from game.theater import Carrier, Lha, LocationType
from game.theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW
from game.theater.theatergroundobject import ( from game.theater.theatergroundobject import (
BuildingGroundObject, BuildingGroundObject,
CarrierGroundObject, CarrierGroundObject,
@ -479,11 +477,11 @@ class BaseDefenseGenerator:
g = SamGroundObject(namegen.random_objective_name(), group_id, g = SamGroundObject(namegen.random_objective_name(), group_id,
position, self.control_point, for_airbase=True) position, self.control_point, for_airbase=True)
group = generate_anti_air_group(self.game, g, self.faction) groups = generate_anti_air_group(self.game, g, self.faction)
if group is None: if not groups:
logging.error(f"Could not generate SAM at {self.control_point}") logging.error(f"Could not generate SAM at {self.control_point}")
return return
g.groups.append(group) g.groups = groups
self.control_point.base_defenses.append(g) self.control_point.base_defenses.append(g)
def generate_shorad(self) -> None: def generate_shorad(self) -> None:
@ -497,13 +495,13 @@ class BaseDefenseGenerator:
g = SamGroundObject(namegen.random_objective_name(), group_id, g = SamGroundObject(namegen.random_objective_name(), group_id,
position, self.control_point, for_airbase=True) position, self.control_point, for_airbase=True)
group = generate_anti_air_group(self.game, g, self.faction, groups = generate_anti_air_group(self.game, g, self.faction,
ranges=[{AirDefenseRange.Short}]) ranges=[{AirDefenseRange.Short}])
if group is None: if not groups:
logging.error( logging.error(
f"Could not generate SHORAD group at {self.control_point}") f"Could not generate SHORAD group at {self.control_point}")
return return
g.groups.append(group) g.groups = groups
self.control_point.base_defenses.append(g) self.control_point.base_defenses.append(g)
@ -642,12 +640,12 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
g = SamGroundObject(namegen.random_objective_name(), group_id, g = SamGroundObject(namegen.random_objective_name(), group_id,
position, self.control_point, for_airbase=False) position, self.control_point, for_airbase=False)
group = generate_anti_air_group(self.game, g, self.faction, ranges) groups = generate_anti_air_group(self.game, g, self.faction, ranges)
if group is None: if not groups:
logging.error("Could not generate air defense group for %s at %s", logging.error("Could not generate air defense group for %s at %s",
g.name, self.control_point) g.name, self.control_point)
return return
g.groups = [group] g.groups = groups
self.control_point.connected_objectives.append(g) self.control_point.connected_objectives.append(g)
def generate_missile_sites(self) -> None: def generate_missile_sites(self) -> None:

View File

@ -1,12 +1,17 @@
from __future__ import annotations from __future__ import annotations
import itertools import itertools
import logging
from typing import Iterator, List, TYPE_CHECKING from typing import Iterator, List, TYPE_CHECKING
from dcs.mapping import Point from dcs.mapping import Point
from dcs.unit import Unit from dcs.unit import Unit
from dcs.unitgroup import Group from dcs.unitgroup import Group
from .. import db
from ..data.radar_db import UNITS_WITH_RADAR
from ..utils import Distance, meters
if TYPE_CHECKING: if TYPE_CHECKING:
from .controlpoint import ControlPoint from .controlpoint import ControlPoint
from gen.flights.flight import FlightType from gen.flights.flight import FlightType
@ -85,7 +90,6 @@ class TheaterGroundObject(MissionTarget):
self.dcs_identifier = dcs_identifier self.dcs_identifier = dcs_identifier
self.airbase_group = airbase_group self.airbase_group = airbase_group
self.sea_object = sea_object self.sea_object = sea_object
# TODO: There is never more than one group.
self.groups: List[Group] = [] self.groups: List[Group] = []
@property @property
@ -147,6 +151,46 @@ class TheaterGroundObject(MissionTarget):
def might_have_aa(self) -> bool: def might_have_aa(self) -> bool:
return False return False
@property
def has_radar(self) -> bool:
"""Returns True if the ground object contains a unit with radar."""
for group in self.groups:
for unit in group.units:
if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR:
return True
return False
def _max_range_of_type(self, group: Group, range_type: str) -> Distance:
if not self.might_have_aa:
return meters(0)
max_range = meters(0)
for u in group.units:
unit = db.unit_type_from_name(u.type)
if unit is None:
logging.error(f"Unknown unit type {u.type}")
continue
# Some units in pydcs have detection_range/threat_range defined,
# but explicitly set to None.
unit_range = getattr(unit, range_type, None)
if unit_range is not None:
max_range = max(max_range, meters(unit_range))
return max_range
def detection_range(self, group: Group) -> Distance:
return self._max_range_of_type(group, "detection_range")
def threat_range(self, group: Group) -> Distance:
if not self.detection_range(group):
# For simple SAMs like shilkas, the unit has both a threat and
# detection range. For complex sites like SA-2s, the launcher has a
# threat range and the search/track radars have detection ranges. If
# the site has no detection range it has no radars and can't fire,
# so it's not actually a threat even if it still has launchers.
return meters(0)
return self._max_range_of_type(group, "threat_range")
class BuildingGroundObject(TheaterGroundObject): class BuildingGroundObject(TheaterGroundObject):
def __init__(self, name: str, category: str, group_id: int, object_id: int, def __init__(self, name: str, category: str, group_id: int, object_id: int,

158
game/threatzones.py Normal file
View File

@ -0,0 +1,158 @@
from __future__ import annotations
from functools import singledispatchmethod
from typing import Optional, TYPE_CHECKING, Union
from dcs.mapping import Point as DcsPoint
from shapely.geometry import (
LineString,
MultiPolygon,
Point as ShapelyPoint,
Polygon,
)
from shapely.geometry.base import BaseGeometry
from shapely.ops import nearest_points, unary_union
from game.theater import ControlPoint
from game.utils import Distance, meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game import Game
ThreatPoly = Union[MultiPolygon, Polygon]
class ThreatZones:
def __init__(self, airbases: ThreatPoly, air_defenses: ThreatPoly) -> None:
self.airbases = airbases
self.air_defenses = air_defenses
self.all = unary_union([airbases, air_defenses])
def closest_boundary(self, point: DcsPoint) -> DcsPoint:
boundary, _ = nearest_points(self.all.boundary,
self.dcs_to_shapely_point(point))
return DcsPoint(boundary.x, boundary.y)
@singledispatchmethod
def threatened(self, position) -> bool:
raise NotImplementedError
@threatened.register
def _threatened_geometry(self, position: BaseGeometry) -> bool:
return self.all.intersects(position)
@threatened.register
def _threatened_dcs_point(self, position: DcsPoint) -> bool:
return self.all.intersects(self.dcs_to_shapely_point(position))
def path_threatened(self, a: DcsPoint, b: DcsPoint) -> bool:
return self.threatened(LineString(
[self.dcs_to_shapely_point(a), self.dcs_to_shapely_point(b)]))
@singledispatchmethod
def threatened_by_aircraft(self, target) -> bool:
raise NotImplementedError
@threatened_by_aircraft.register
def _threatened_by_aircraft_geom(self, position: BaseGeometry) -> bool:
return self.airbases.intersects(position)
@threatened_by_aircraft.register
def _threatened_by_aircraft_flight(self, flight: Flight) -> bool:
return self.threatened_by_aircraft(LineString((
self.dcs_to_shapely_point(p.position) for p in flight.points
)))
@singledispatchmethod
def threatened_by_air_defense(self, target) -> bool:
raise NotImplementedError
@threatened_by_air_defense.register
def _threatened_by_air_defense_geom(self, position: BaseGeometry) -> bool:
return self.air_defenses.intersects(position)
@threatened_by_air_defense.register
def _threatened_by_air_defense_flight(self, flight: Flight) -> bool:
return self.threatened_by_air_defense(LineString((
self.dcs_to_shapely_point(p.position) for p in flight.points
)))
@classmethod
def closest_enemy_airbase(cls, location: ControlPoint,
max_distance: Distance) -> Optional[ControlPoint]:
airfields = ObjectiveDistanceCache.get_closest_airfields(location)
for airfield in airfields.airfields_within(max_distance):
if airfield.captured != location.captured:
return airfield
return None
@classmethod
def barcap_threat_range(cls, game: Game,
control_point: ControlPoint) -> Distance:
doctrine = game.faction_for(control_point.captured).doctrine
cap_threat_range = (doctrine.cap_max_distance_from_cp +
doctrine.cap_engagement_range)
opposing_airfield = cls.closest_enemy_airbase(control_point,
cap_threat_range * 2)
if opposing_airfield is None:
return cap_threat_range
airfield_distance = meters(
opposing_airfield.position.distance_to_point(control_point.position)
)
# BARCAPs should not commit further than halfway to the closest enemy
# airfield (with some breathing room) to avoid those missions becoming
# offensive. For dissimilar doctrines we could weight this so that, as
# an example, modern US goes no closer than 70% of the way to the WW2
# German base, and the Germans go no closer than 30% of the way to the
# US base, but for now equal weighting is fine.
max_distance = airfield_distance * 0.45
return min(cap_threat_range, max_distance)
@classmethod
def for_faction(cls, game: Game, player: bool) -> ThreatZones:
"""Generates the threat zones projected by the given coalition.
Args:
game: The game to generate the threat zone for.
player: True if the coalition projecting the threat zone belongs to
the player.
Returns:
The threat zones projected by the given coalition. If the threat
zone belongs to the player, it is the zone that will be avoided by
the enemy and vice versa.
"""
airbases = []
air_defenses = []
for control_point in game.theater.controlpoints:
if control_point.captured != player:
continue
if control_point.runway_is_operational():
point = ShapelyPoint(control_point.position.x,
control_point.position.y)
cap_threat_range = cls.barcap_threat_range(game, control_point)
airbases.append(point.buffer(cap_threat_range.meters))
for tgo in control_point.ground_objects:
for group in tgo.groups:
threat_range = tgo.threat_range(group)
# Any system with a shorter range than this is not worth
# even avoiding.
if threat_range > nautical_miles(3):
point = ShapelyPoint(tgo.position.x, tgo.position.y)
threat_zone = point.buffer(threat_range.meters)
air_defenses.append(threat_zone)
return cls(
airbases=unary_union(airbases),
air_defenses=unary_union(air_defenses)
)
@staticmethod
def dcs_to_shapely_point(point: DcsPoint) -> ShapelyPoint:
return ShapelyPoint(point.x, point.y)

View File

@ -112,7 +112,9 @@ class UnitMap:
group: Group) -> None: group: Group) -> None:
# The actual name is a String (the pydcs translatable string), which # The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__. # doesn't define __eq__.
name = str(group.name) # The name of the initiator in the DCS dead event will have " object"
# appended for statics.
name = f"{group.name} object"
if name in self.buildings: if name in self.buildings:
raise RuntimeError(f"Duplicate TGO unit: {name}") raise RuntimeError(f"Duplicate TGO unit: {name}")
self.buildings[name] = Building(ground_object) self.buildings[name] = Building(ground_object)

View File

@ -1,65 +1,18 @@
def meter_to_feet(value_in_meter: float) -> int: from __future__ import annotations
"""Converts meters to feets
:arg value_in_meter Value in meters import math
""" from dataclasses import dataclass
return int(3.28084 * value_in_meter) from typing import Union
METERS_TO_FEET = 3.28084
FEET_TO_METERS = 1 / METERS_TO_FEET
NM_TO_METERS = 1852
METERS_TO_NM = 1 / NM_TO_METERS
def feet_to_meter(value_in_feet: float) -> int: KNOTS_TO_KPH = 1.852
"""Converts feets to meters KPH_TO_KNOTS = 1 / KNOTS_TO_KPH
MS_TO_KPH = 3.6
:arg value_in_feet Value in feets KPH_TO_MS = 1 / MS_TO_KPH
"""
return int(value_in_feet / 3.28084)
def meter_to_nm(value_in_meter: float) -> int:
"""Converts meters to nautic miles
:arg value_in_meter Value in meters
"""
return int(value_in_meter / 1852)
def nm_to_meter(value_in_nm: float) -> int:
"""Converts nautic miles to meters
:arg value_in_nm Value in nautic miles
"""
return int(value_in_nm * 1852)
def knots_to_kph(value_in_knots: float) -> int:
"""Converts Knots to Kilometer Per Hour
:arg value_in_knots Knots
"""
return int(value_in_knots * 1.852)
def mps_to_knots(value_in_mps: float) -> int:
"""Converts Meters Per Second To Knots
:arg value_in_mps Meters Per Second
"""
return int(value_in_mps * 1.943)
def mps_to_kph(speed: float) -> int:
"""Converts meters per second to kilometers per hour.
:arg speed Speed in m/s.
"""
return int(speed * 3.6)
def kph_to_mps(speed: float) -> int:
"""Converts kilometers per hour to meters per second.
:arg speed Speed in KPH.
"""
return int(speed / 3.6)
def heading_sum(h, a) -> int: def heading_sum(h, a) -> int:
@ -71,5 +24,157 @@ def heading_sum(h, a) -> int:
else: else:
return h return h
def opposite_heading(h): def opposite_heading(h):
return heading_sum(h, 180) return heading_sum(h, 180)
@dataclass(frozen=True, order=True)
class Distance:
distance_in_meters: float
@property
def feet(self) -> float:
return self.distance_in_meters * METERS_TO_FEET
@property
def meters(self) -> float:
return self.distance_in_meters
@property
def nautical_miles(self) -> float:
return self.distance_in_meters * METERS_TO_NM
@classmethod
def from_feet(cls, value: float) -> Distance:
return cls(value * FEET_TO_METERS)
@classmethod
def from_meters(cls, value: float) -> Distance:
return cls(value)
@classmethod
def from_nautical_miles(cls, value: float) -> Distance:
return cls(value * NM_TO_METERS)
def __add__(self, other: Distance) -> Distance:
return meters(self.meters + other.meters)
def __sub__(self, other: Distance) -> Distance:
return meters(self.meters - other.meters)
def __mul__(self, other: Union[float, int]) -> Distance:
return meters(self.meters * other)
def __truediv__(self, other: Union[float, int]) -> Distance:
return meters(self.meters / other)
def __floordiv__(self, other: Union[float, int]) -> Distance:
return meters(self.meters // other)
def __bool__(self) -> bool:
return not math.isclose(self.meters, 0.0)
def feet(value: float) -> Distance:
return Distance.from_feet(value)
def meters(value: float) -> Distance:
return Distance.from_meters(value)
def nautical_miles(value: float) -> Distance:
return Distance.from_nautical_miles(value)
@dataclass(frozen=True, order=True)
class Speed:
speed_in_kph: float
@property
def knots(self) -> float:
return self.speed_in_kph * KPH_TO_KNOTS
@property
def kph(self) -> float:
return self.speed_in_kph
@property
def meters_per_second(self) -> float:
return self.speed_in_kph * KPH_TO_MS
def mach(self, altitude: Distance = meters(0)) -> float:
c_sound = mach(1, altitude)
return self.speed_in_kph / c_sound.kph
@classmethod
def from_knots(cls, value: float) -> Speed:
return cls(value * KNOTS_TO_KPH)
@classmethod
def from_kph(cls, value: float) -> Speed:
return cls(value)
@classmethod
def from_meters_per_second(cls, value: float) -> Speed:
return cls(value * MS_TO_KPH)
@classmethod
def from_mach(cls, value: float, altitude: Distance) -> Speed:
# https://www.grc.nasa.gov/WWW/K-12/airplane/atmos.html
if altitude <= feet(36152):
temperature_f = 59 - 0.00356 * altitude.feet
else:
# There's another formula for altitudes over 82k feet, but we better
# not be planning waypoints that high...
temperature_f = -70
temperature_k = (temperature_f + 459.67) * (5 / 9)
# https://www.engineeringtoolbox.com/specific-heat-ratio-d_602.html
# Dependent on temperature, but varies very little (+/-0.001)
# between -40F and 180F.
heat_capacity_ratio = 1.4
# https://www.grc.nasa.gov/WWW/K-12/airplane/sound.html
gas_constant = 286 # m^2/s^2/K
c_sound = math.sqrt(heat_capacity_ratio * gas_constant * temperature_k)
return mps(c_sound) * value
def __add__(self, other: Speed) -> Speed:
return kph(self.kph + other.kph)
def __sub__(self, other: Speed) -> Speed:
return kph(self.kph - other.kph)
def __mul__(self, other: Union[float, int]) -> Speed:
return kph(self.kph * other)
def __truediv__(self, other: Union[float, int]) -> Speed:
return kph(self.kph / other)
def __floordiv__(self, other: Union[float, int]) -> Speed:
return kph(self.kph // other)
def __bool__(self) -> bool:
return not math.isclose(self.kph, 0.0)
def knots(value: float) -> Speed:
return Speed.from_knots(value)
def kph(value: float) -> Speed:
return Speed.from_kph(value)
def mps(value: float) -> Speed:
return Speed.from_meters_per_second(value)
def mach(value: float, altitude: Distance) -> Speed:
return Speed.from_mach(value, altitude)
SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5)

View File

@ -2,7 +2,7 @@ from pathlib import Path
def _build_version_string() -> str: def _build_version_string() -> str:
components = ["2.3.4"] components = ["2.4"]
build_number_path = Path("resources/buildnumber") build_number_path = Path("resources/buildnumber")
if build_number_path.exists(): if build_number_path.exists():
with build_number_path.open("r") as build_number_file: with build_number_path.open("r") as build_number_file:

View File

@ -10,6 +10,7 @@ from typing import Optional, TYPE_CHECKING
from dcs.weather import Weather as PydcsWeather, Wind from dcs.weather import Weather as PydcsWeather, Wind
from game.settings import Settings from game.settings import Settings
from game.utils import Distance, meters
if TYPE_CHECKING: if TYPE_CHECKING:
from game.theater import ConflictTheater from game.theater import ConflictTheater
@ -39,7 +40,7 @@ class Clouds:
@dataclass(frozen=True) @dataclass(frozen=True)
class Fog: class Fog:
visibility: int visibility: Distance
thickness: int thickness: int
@ -56,7 +57,7 @@ class Weather:
if random.randrange(5) != 0: if random.randrange(5) != 0:
return None return None
return Fog( return Fog(
visibility=random.randint(2500, 5000), visibility=meters(random.randint(2500, 5000)),
thickness=random.randint(100, 500) thickness=random.randint(100, 500)
) )

View File

@ -74,6 +74,7 @@ from dcs.unittype import FlyingType, UnitType
from game import db from game import db
from game.data.cap_capabilities_db import GUNFIGHTERS from game.data.cap_capabilities_db import GUNFIGHTERS
from game.data.weapons import Pylon, Weapon
from game.factions.faction import Faction from game.factions.faction import Faction
from game.settings import Settings from game.settings import Settings
from game.theater.controlpoint import ( from game.theater.controlpoint import (
@ -85,7 +86,7 @@ from game.theater.controlpoint import (
) )
from game.theater.theatergroundobject import TheaterGroundObject from game.theater.theatergroundobject import TheaterGroundObject
from game.unitmap import UnitMap from game.unitmap import UnitMap
from game.utils import knots_to_kph, nm_to_meter from game.utils import Distance, meters, nautical_miles
from gen.airsupportgen import AirSupport from gen.airsupportgen import AirSupport
from gen.ato import AirTaskingOrder, Package from gen.ato import AirTaskingOrder, Package
from gen.callsigns import create_group_callsign_from_unit from gen.callsigns import create_group_callsign_from_unit
@ -110,12 +111,10 @@ from .naming import namegen
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
WARM_START_HELI_AIRSPEED = 120 WARM_START_HELI_ALT = meters(500)
WARM_START_HELI_ALT = 500 WARM_START_ALTITUDE = meters(3000)
WARM_START_ALTITUDE = 3000
WARM_START_AIRSPEED = 550
RTB_ALTITUDE = 800 RTB_ALTITUDE = meters(800)
RTB_DISTANCE = 5000 RTB_DISTANCE = 5000
HELI_ALT = 500 HELI_ALT = 500
@ -263,6 +262,9 @@ class FlightData:
#: The package that the flight belongs to. #: The package that the flight belongs to.
package: Package package: Package
#: The country that the flight belongs to.
country: str
flight_type: FlightType flight_type: FlightType
#: All units in the flight. #: All units in the flight.
@ -300,7 +302,7 @@ class FlightData:
joker_fuel: Optional[int] joker_fuel: Optional[int]
def __init__(self, package: Package, flight_type: FlightType, def __init__(self, package: Package, country: str, flight_type: FlightType,
units: List[FlyingUnit], size: int, friendly: bool, units: List[FlyingUnit], size: int, friendly: bool,
departure_delay: timedelta, departure: RunwayData, departure_delay: timedelta, departure: RunwayData,
arrival: RunwayData, divert: Optional[RunwayData], arrival: RunwayData, divert: Optional[RunwayData],
@ -309,6 +311,7 @@ class FlightData:
bingo_fuel: Optional[int], bingo_fuel: Optional[int],
joker_fuel: Optional[int]) -> None: joker_fuel: Optional[int]) -> None:
self.package = package self.package = package
self.country = country
self.flight_type = flight_type self.flight_type = flight_type
self.units = units self.units = units
self.size = size self.size = size
@ -779,6 +782,7 @@ class AircraftConflictGenerator:
self.flights.append(FlightData( self.flights.append(FlightData(
package=package, package=package,
country=faction.country,
flight_type=flight.flight_type, flight_type=flight.flight_type,
units=group.units, units=group.units,
size=len(group.units), size=len(group.units),
@ -832,19 +836,21 @@ class AircraftConflictGenerator:
else: else:
alt = WARM_START_ALTITUDE alt = WARM_START_ALTITUDE
speed = knots_to_kph(GroundSpeed.for_flight(flight, alt)) speed = GroundSpeed.for_flight(flight, alt)
pos = Point(at.x + random.randint(100, 1000), at.y + random.randint(100, 1000)) pos = Point(at.x + random.randint(100, 1000), at.y + random.randint(100, 1000))
logging.info("airgen: {} for {} at {} at {}".format(flight.unit_type, side.id, alt, speed)) logging.info(
"airgen: {} for {} at {} at {}".format(flight.unit_type, side.id,
alt, int(speed.kph)))
group = self.m.flight_group( group = self.m.flight_group(
country=side, country=side,
name=name, name=name,
aircraft_type=flight.unit_type, aircraft_type=flight.unit_type,
airport=None, airport=None,
position=pos, position=pos,
altitude=alt, altitude=alt.meters,
speed=speed, speed=speed.kph,
maintask=None, maintask=None,
group_size=flight.count) group_size=flight.count)
@ -867,8 +873,10 @@ class AircraftConflictGenerator:
start_type=self._start_type(start_type), start_type=self._start_type(start_type),
group_size=count) group_size=count)
def _add_radio_waypoint(self, group: FlyingGroup, position, altitude: int, airspeed: int = 600): def _add_radio_waypoint(self, group: FlyingGroup, position,
point = group.add_waypoint(position, altitude, airspeed) altitude: Distance,
airspeed: int = 600) -> MovingPoint:
point = group.add_waypoint(position, altitude.meters, airspeed)
point.alt_type = "RADIO" point.alt_type = "RADIO"
return point return point
@ -884,7 +892,8 @@ class AircraftConflictGenerator:
tod_location = position.point_from_heading(heading, RTB_DISTANCE) tod_location = position.point_from_heading(heading, RTB_DISTANCE)
self._add_radio_waypoint(group, tod_location, last_waypoint.alt) self._add_radio_waypoint(group, tod_location, last_waypoint.alt)
destination_waypoint = self._add_radio_waypoint(group, position, RTB_ALTITUDE) destination_waypoint = self._add_radio_waypoint(group, position,
RTB_ALTITUDE)
if isinstance(at, Airport): if isinstance(at, Airport):
group.land_at(at) group.land_at(at)
return destination_waypoint return destination_waypoint
@ -899,22 +908,39 @@ class AircraftConflictGenerator:
else: else:
assert False assert False
def _setup_custom_payload(self, flight, group:FlyingGroup): @staticmethod
if flight.use_custom_loadout: def _setup_custom_payload(flight: Flight, group: FlyingGroup) -> None:
if not flight.use_custom_loadout:
return
logging.info("Custom loadout for flight : " + flight.__repr__()) logging.info("Custom loadout for flight : " + flight.__repr__())
for p in group.units: for p in group.units:
p.pylons.clear() p.pylons.clear()
for key in flight.loadout.keys(): for pylon_number, weapon in flight.loadout.items():
if "Pylon" + key in flight.unit_type.__dict__.keys(): if weapon is None:
print(flight.loadout) continue
weapon_dict = flight.unit_type.__dict__["Pylon" + key].__dict__ pylon = Pylon.for_aircraft(flight.unit_type, pylon_number)
if flight.loadout[key] in weapon_dict.keys(): pylon.equip(group, weapon)
weapon = weapon_dict[flight.loadout[key]]
group.load_pylon(weapon, int(key)) def _degrade_payload_to_era(self, flight: Flight,
else: group: FlyingGroup) -> None:
logging.warning("Pylon not found ! => Pylon" + key + " on " + str(flight.unit_type)) loadout = dict(group.units[0].pylons)
for pylon_number, clsid in loadout.items():
weapon = Weapon.from_clsid(clsid["CLSID"])
if weapon is None:
logging.error(f"Could not find weapon for clsid {clsid}")
continue
if not weapon.available_on(self.game.date):
pylon = Pylon.for_aircraft(flight.unit_type, pylon_number)
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(self.game.date):
continue
pylon.equip(group, fallback)
break
def clear_parking_slots(self) -> None: def clear_parking_slots(self) -> None:
for cp in self.game.theater.controlpoints: for cp in self.game.theater.controlpoints:
@ -963,13 +989,13 @@ class AircraftConflictGenerator:
# Creating a flight even those this isn't a fragged mission lets us # Creating a flight even those this isn't a fragged mission lets us
# reuse the existing debriefing code. # reuse the existing debriefing code.
# TODO: Special flight type? # TODO: Special flight type?
flight = Flight(Package(control_point), aircraft, 1, flight = Flight(Package(control_point), faction.country, aircraft, 1,
FlightType.BARCAP, "Cold", departure=control_point, FlightType.BARCAP, "Cold", departure=control_point,
arrival=control_point, divert=None) arrival=control_point, divert=None)
group = self._generate_at_airport( group = self._generate_at_airport(
name=namegen.next_unit_name(country, control_point.id, name=namegen.next_aircraft_name(country, control_point.id,
aircraft), flight),
side=country, side=country,
unit_type=aircraft, unit_type=aircraft,
count=1, count=1,
@ -1033,17 +1059,18 @@ class AircraftConflictGenerator:
CoalitionHasAirdrome(coalition, flight.from_cp.id)) CoalitionHasAirdrome(coalition, flight.from_cp.id))
def generate_planned_flight(self, cp, country, flight:Flight): def generate_planned_flight(self, cp, country, flight:Flight):
name = namegen.next_aircraft_name(country, cp.id, flight)
try: try:
if flight.start_type == "In Flight": if flight.start_type == "In Flight":
group = self._generate_inflight( group = self._generate_inflight(
name=namegen.next_unit_name(country, cp.id, flight.unit_type), name=name,
side=country, side=country,
flight=flight, flight=flight,
origin=cp) origin=cp)
elif isinstance(cp, NavalControlPoint): elif isinstance(cp, NavalControlPoint):
group_name = cp.get_carrier_group_name() group_name = cp.get_carrier_group_name()
group = self._generate_at_group( group = self._generate_at_group(
name=namegen.next_unit_name(country, cp.id, flight.unit_type), name=name,
side=country, side=country,
unit_type=flight.unit_type, unit_type=flight.unit_type,
count=flight.count, count=flight.count,
@ -1054,8 +1081,7 @@ class AircraftConflictGenerator:
raise RuntimeError( raise RuntimeError(
f"Attempted to spawn at airfield for non-airfield {cp}") f"Attempted to spawn at airfield for non-airfield {cp}")
group = self._generate_at_airport( group = self._generate_at_airport(
name=namegen.next_unit_name(country, cp.id, name=name,
flight.unit_type),
side=country, side=country,
unit_type=flight.unit_type, unit_type=flight.unit_type,
count=flight.count, count=flight.count,
@ -1067,7 +1093,7 @@ class AircraftConflictGenerator:
logging.warning("No room on runway or parking slots. Starting from the air.") logging.warning("No room on runway or parking slots. Starting from the air.")
flight.start_type = "In Flight" flight.start_type = "In Flight"
group = self._generate_inflight( group = self._generate_inflight(
name=namegen.next_unit_name(country, cp.id, flight.unit_type), name=name,
side=country, side=country,
flight=flight, flight=flight,
origin=cp) origin=cp)
@ -1312,6 +1338,8 @@ class AircraftConflictGenerator:
# have their TOTs set. # have their TOTs set.
self.flights[-1].waypoints = [takeoff_point] + flight.points self.flights[-1].waypoints = [takeoff_point] + flight.points
self._setup_custom_payload(flight, group) self._setup_custom_payload(flight, group)
if self.game.settings.restrict_weapons_by_date:
self._degrade_payload_to_era(flight, group)
def should_delay_flight(self, flight: Flight, def should_delay_flight(self, flight: Flight,
start_time: timedelta) -> bool: start_time: timedelta) -> bool:
@ -1380,11 +1408,15 @@ class PydcsWaypointBuilder:
def build(self) -> MovingPoint: def build(self) -> MovingPoint:
waypoint = self.group.add_waypoint( waypoint = self.group.add_waypoint(
Point(self.waypoint.x, self.waypoint.y), self.waypoint.alt, Point(self.waypoint.x, self.waypoint.y),
self.waypoint.alt.meters,
name=self.mission.string(self.waypoint.name)) name=self.mission.string(self.waypoint.name))
if self.waypoint.flyover: if self.waypoint.flyover:
waypoint.type = PointAction.FlyOverPoint.value waypoint.action = PointAction.FlyOverPoint
# It seems we need to leave waypoint.type exactly as it is even
# though it's set to "Turning Point". If I set this to "Fly Over
# Point" and then save the mission in the ME DCS resets it.
waypoint.alt_type = self.waypoint.alt_type waypoint.alt_type = self.waypoint.alt_type
tot = self.flight.flight_plan.tot_for_waypoint(self.waypoint) tot = self.flight.flight_plan.tot_for_waypoint(self.waypoint)
@ -1497,7 +1529,7 @@ class CasIngressBuilder(PydcsWaypointBuilder):
if isinstance(self.flight.flight_plan, CasFlightPlan): if isinstance(self.flight.flight_plan, CasFlightPlan):
waypoint.add_task(EngageTargetsInZone( waypoint.add_task(EngageTargetsInZone(
position=self.flight.flight_plan.target, position=self.flight.flight_plan.target,
radius=FRONTLINE_LENGTH / 2, radius=int(self.flight.flight_plan.engagement_distance.meters),
targets=[ targets=[
Targets.All.GroundUnits.GroundVehicles, Targets.All.GroundUnits.GroundVehicles,
Targets.All.GroundUnits.AirDefence.AAA, Targets.All.GroundUnits.AirDefence.AAA,
@ -1508,7 +1540,7 @@ class CasIngressBuilder(PydcsWaypointBuilder):
logging.error( logging.error(
"No CAS waypoint found. Falling back to search and engage") "No CAS waypoint found. Falling back to search and engage")
waypoint.add_task(EngageTargets( waypoint.add_task(EngageTargets(
max_distance=nm_to_meter(10), max_distance=int(nautical_miles(10).meters),
targets=[ targets=[
Targets.All.GroundUnits.GroundVehicles, Targets.All.GroundUnits.GroundVehicles,
Targets.All.GroundUnits.AirDefence.AAA, Targets.All.GroundUnits.AirDefence.AAA,
@ -1554,7 +1586,7 @@ class OcaAircraftIngressBuilder(PydcsWaypointBuilder):
position=target.position, position=target.position,
# Al Dhafra is 4 nm across at most. Add a little wiggle room in case # Al Dhafra is 4 nm across at most. Add a little wiggle room in case
# the airport position from DCS is not centered. # the airport position from DCS is not centered.
radius=nm_to_meter(3), radius=int(nautical_miles(3).meters),
targets=[Targets.All.Air] targets=[Targets.All.Air]
) )
task.params["attackQtyLimit"] = False task.params["attackQtyLimit"] = False
@ -1591,7 +1623,7 @@ class SeadIngressBuilder(PydcsWaypointBuilder):
if tgroup is not None: if tgroup is not None:
waypoint.add_task(EngageTargetsInZone( waypoint.add_task(EngageTargetsInZone(
position=tgroup.position, position=tgroup.position,
radius=nm_to_meter(30), radius=int(nautical_miles(30).meters),
targets=[ targets=[
Targets.All.GroundUnits.AirDefence, Targets.All.GroundUnits.AirDefence,
]) ])
@ -1673,7 +1705,7 @@ class SweepIngressBuilder(PydcsWaypointBuilder):
return waypoint return waypoint
waypoint.tasks.append(EngageTargets( waypoint.tasks.append(EngageTargets(
max_distance=nm_to_meter(50), max_distance=int(nautical_miles(50).meters),
targets=[Targets.All.Air.Planes.Fighters])) targets=[Targets.All.Air.Planes.Fighters]))
return waypoint return waypoint
@ -1716,7 +1748,7 @@ class JoinPointBuilder(PydcsWaypointBuilder):
# https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/250183-task-follow-and-escort-temporarily-aborted # https://forums.eagle.ru/forum/english/digital-combat-simulator/dcs-world-2-5/bugs-and-problems-ai/ai-ad/250183-task-follow-and-escort-temporarily-aborted
waypoint.add_task(ControlledTask(EngageTargets( waypoint.add_task(ControlledTask(EngageTargets(
# TODO: From doctrine. # TODO: From doctrine.
max_distance=nm_to_meter(30), max_distance=int(nautical_miles(30).meters),
targets=[Targets.All.Air.Planes.Fighters] targets=[Targets.All.Air.Planes.Fighters]
))) )))
@ -1737,8 +1769,10 @@ class RaceTrackBuilder(PydcsWaypointBuilder):
def build(self) -> MovingPoint: def build(self) -> MovingPoint:
waypoint = super().build() waypoint = super().build()
if not isinstance(self.flight.flight_plan, PatrollingFlightPlan): flight_plan = self.flight.flight_plan
flight_plan_type = self.flight.flight_plan.__class__.__name__
if not isinstance(flight_plan, PatrollingFlightPlan):
flight_plan_type = flight_plan.__class__.__name__
logging.error( logging.error(
f"Cannot create race track for {self.flight} because " f"Cannot create race track for {self.flight} because "
f"{flight_plan_type} does not define a patrol.") f"{flight_plan_type} does not define a patrol.")
@ -1756,17 +1790,18 @@ class RaceTrackBuilder(PydcsWaypointBuilder):
# later. # later.
cap_types = {FlightType.BARCAP, FlightType.TARCAP} cap_types = {FlightType.BARCAP, FlightType.TARCAP}
if self.flight.flight_type in cap_types: if self.flight.flight_type in cap_types:
waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), engagement_distance = int(flight_plan.engagement_distance.meters)
waypoint.tasks.append(
EngageTargets(max_distance=engagement_distance,
targets=[Targets.All.Air])) targets=[Targets.All.Air]))
racetrack = ControlledTask(OrbitAction( racetrack = ControlledTask(OrbitAction(
altitude=waypoint.alt, altitude=waypoint.alt,
pattern=OrbitAction.OrbitPattern.RaceTrack pattern=OrbitAction.OrbitPattern.RaceTrack
)) ))
self.set_waypoint_tot( self.set_waypoint_tot(waypoint, flight_plan.patrol_start_time)
waypoint, self.flight.flight_plan.patrol_start_time)
racetrack.stop_after_time( racetrack.stop_after_time(
int(self.flight.flight_plan.patrol_end_time.total_seconds())) int(flight_plan.patrol_end_time.total_seconds()))
waypoint.add_task(racetrack) waypoint.add_task(racetrack)
return waypoint return waypoint

View File

@ -410,7 +410,10 @@ AIRFIELD_DATA = {
icao="OMLW", icao="OMLW",
elevation=400, elevation=400,
runway_length=10768, runway_length=10768,
atc=AtcData(MHz(4, 175), MHz(39, 250), MHz(119, 300), MHz(250, 850)), tacan=TacanChannel(121, TacanBand.X),
tacan_callsign="OMLW",
vor=("OMLW", MHz(117,400)),
atc=AtcData(MHz(4, 225), MHz(39, 350), MHz(119, 300), MHz(250, 950)),
), ),
"Al Dhafra AB": AirfieldData( "Al Dhafra AB": AirfieldData(

View File

@ -208,7 +208,7 @@ class GroundConflictGenerator:
u = random.choice(manpads) u = random.choice(manpads)
self.mission.vehicle_group( self.mission.vehicle_group(
side, side,
namegen.next_infantry_name(side, cp, u), u, namegen.next_infantry_name(side, cp.id, u), u,
position=infantry_position, position=infantry_position,
group_size=1, group_size=1,
heading=forward_heading, heading=forward_heading,
@ -222,7 +222,7 @@ class GroundConflictGenerator:
u = random.choice(possible_infantry_units) u = random.choice(possible_infantry_units)
self.mission.vehicle_group( self.mission.vehicle_group(
side, side,
namegen.next_infantry_name(side, cp, u), u, namegen.next_infantry_name(side, cp.id, u), u,
position=infantry_position, position=infantry_position,
group_size=1, group_size=1,
heading=forward_heading, heading=forward_heading,
@ -233,7 +233,7 @@ class GroundConflictGenerator:
position = infantry_position.random_point_within(55, 5) position = infantry_position.random_point_within(55, 5)
self.mission.vehicle_group( self.mission.vehicle_group(
side, side,
namegen.next_infantry_name(side, cp, u), u, namegen.next_infantry_name(side, cp.id, u), u,
position=position, position=position,
group_size=1, group_size=1,
heading=forward_heading, heading=forward_heading,
@ -352,9 +352,14 @@ class GroundConflictGenerator:
to_cp.position.random_point_within(500, 0) to_cp.position.random_point_within(500, 0)
) )
else: else:
# We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
offset_heading = forward_heading - 2
if offset_heading < 0:
offset_heading = 358
attack_point = self.find_offensive_point( attack_point = self.find_offensive_point(
dcs_group, dcs_group,
forward_heading, offset_heading,
AGGRESIVE_MOVE_DISTANCE AGGRESIVE_MOVE_DISTANCE
) )
dcs_group.add_waypoint(attack_point, PointAction.OffRoad) dcs_group.add_waypoint(attack_point, PointAction.OffRoad)
@ -367,7 +372,12 @@ class GroundConflictGenerator:
to_cp.position.random_point_within(500, 0) to_cp.position.random_point_within(500, 0)
) )
else: else:
attack_point = self.find_offensive_point(dcs_group, forward_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE) # We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
offset_heading = forward_heading - 1
if offset_heading < 0:
offset_heading = 359
attack_point = self.find_offensive_point(dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE)
dcs_group.add_waypoint(attack_point, PointAction.OffRoad) dcs_group.add_waypoint(attack_point, PointAction.OffRoad)
elif stance == CombatStance.ELIMINATION: elif stance == CombatStance.ELIMINATION:
# In elimination mode, the units focus on destroying as much enemy groups as possible # In elimination mode, the units focus on destroying as much enemy groups as possible

View File

@ -17,8 +17,10 @@ from typing import Dict, List, Optional
from dcs.mapping import Point from dcs.mapping import Point
from game.theater.missiontarget import MissionTarget from game.theater.missiontarget import MissionTarget
from game.utils import Speed
from .flights.flight import Flight, FlightType from .flights.flight import Flight, FlightType
from .flights.flightplan import FormationFlightPlan from .flights.flightplan import FormationFlightPlan
from .flights.traveltime import TotEstimator
@dataclass(frozen=True) @dataclass(frozen=True)
@ -53,13 +55,18 @@ class Package:
delay: int = field(default=0) delay: int = field(default=0)
#: True if the package ToT should be reset to ASAP whenever the player makes
#: a change. This is really a UI property rather than a game property, but
#: we want it to persist in the save.
auto_asap: bool = field(default=False)
#: Desired TOT as an offset from mission start. #: Desired TOT as an offset from mission start.
time_over_target: timedelta = field(default=timedelta()) time_over_target: timedelta = field(default=timedelta())
waypoints: Optional[PackageWaypoints] = field(default=None) waypoints: Optional[PackageWaypoints] = field(default=None)
@property @property
def formation_speed(self) -> Optional[int]: def formation_speed(self) -> Optional[Speed]:
"""The speed of the package when in formation. """The speed of the package when in formation.
If none of the flights in the package will join a formation, this If none of the flights in the package will join a formation, this
@ -117,6 +124,18 @@ class Package:
return max(times) return max(times)
return None return None
@property
def mission_departure_time(self) -> Optional[timedelta]:
times = []
for flight in self.flights:
times.append(flight.flight_plan.mission_departure_time)
if times:
return max(times)
return None
def set_tot_asap(self) -> None:
self.time_over_target = TotEstimator(self).earliest_tot()
def add_flight(self, flight: Flight) -> None: def add_flight(self, flight: Flight) -> None:
"""Adds a flight to the package.""" """Adds a flight to the package."""
self.flights.append(flight) self.flights.append(flight)

View File

@ -1,9 +1,9 @@
import logging import logging
import random
from typing import Tuple, Optional from typing import Tuple, Optional
from dcs.country import Country from dcs.country import Country
from dcs.mapping import Point from dcs.mapping import Point
from shapely.geometry import LineString, Point as ShapelyPoint
from game.theater.conflicttheater import ConflictTheater, FrontLine from game.theater.conflicttheater import ConflictTheater, FrontLine
from game.theater.controlpoint import ControlPoint from game.theater.controlpoint import ControlPoint
@ -83,12 +83,25 @@ class Conflict:
@classmethod @classmethod
def extend_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point: def extend_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point:
"""Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance""" """Finds the first intersection with an exclusion zone in one heading from an initial point up to max_distance"""
pos = initial extended = initial.point_from_heading(heading, max_distance)
for distance in range(0, int(max_distance), 100): if theater.landmap is None:
pos = initial.point_from_heading(heading, distance) # TODO: Why is this possible?
if not theater.is_on_land(pos): return extended
return initial.point_from_heading(heading, distance - 100)
return pos p0 = ShapelyPoint(initial.x, initial.y)
p1 = ShapelyPoint(extended.x, extended.y)
line = LineString([p0, p1])
intersection = line.intersection(
theater.landmap.inclusion_zone_only.boundary)
if intersection.is_empty:
# Max extent does not intersect with the boundary of the inclusion
# zone, so the full front line is usable. This does assume that the
# front line was centered on a valid location.
return extended
# Otherwise extend the front line only up to the intersection.
return initial.point_from_heading(heading, p0.distance(intersection))
@classmethod @classmethod
def find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater, coerce=True) -> Optional[Point]: def find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater, coerce=True) -> Optional[Point]:

View File

@ -21,7 +21,7 @@ class EnvironmentGenerator:
def set_fog(self, fog: Optional[Fog]) -> None: def set_fog(self, fog: Optional[Fog]) -> None:
if fog is None: if fog is None:
return return
self.mission.weather.fog_visibility = fog.visibility self.mission.weather.fog_visibility = fog.visibility.meters
self.mission.weather.fog_thickness = fog.thickness self.mission.weather.fog_thickness = fog.thickness
def set_wind(self, wind: WindConditions) -> None: def set_wind(self, wind: WindConditions) -> None:

View File

@ -8,7 +8,6 @@ from dcs.ships import (
Type_052C_Destroyer, Type_052C_Destroyer,
Type_052B_Destroyer, Type_052B_Destroyer,
Type_054A_Frigate, Type_054A_Frigate,
CGN_1144_2_Pyotr_Velikiy,
) )
from game.factions.faction import Faction from game.factions.faction import Faction
@ -27,12 +26,7 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator):
include_frigate = random.choice([True, True, False]) include_frigate = random.choice([True, True, False])
include_dd = random.choice([True, False]) include_dd = random.choice([True, False])
if include_dd: if not any([include_frigate, include_dd]):
include_cc = random.choice([True, False])
else:
include_cc = False
if not any([include_frigate, include_dd, include_cc]):
include_frigate = True include_frigate = True
if include_frigate: if include_frigate:
@ -44,10 +38,6 @@ class ChineseNavyGroupGenerator(ShipGroupGenerator):
self.add_unit(dd_type, "DD1", self.position.x + 2400, self.position.y + 900, self.heading) self.add_unit(dd_type, "DD1", self.position.x + 2400, self.position.y + 900, self.heading)
self.add_unit(dd_type, "DD2", self.position.x + 2400, self.position.y - 900, self.heading) self.add_unit(dd_type, "DD2", self.position.x + 2400, self.position.y - 900, self.heading)
if include_cc:
cc_type = random.choice([CGN_1144_2_Pyotr_Velikiy])
self.add_unit(cc_type, "CC1", self.position.x, self.position.y, self.heading)
self.get_generated_group().points[0].speed = 20 self.get_generated_group().points[0].speed = 20

View File

@ -8,7 +8,6 @@ from dcs.ships import (
FFG_11540_Neustrashimy, FFG_11540_Neustrashimy,
FF_1135M_Rezky, FF_1135M_Rezky,
CG_1164_Moskva, CG_1164_Moskva,
CGN_1144_2_Pyotr_Velikiy,
SSK_877, SSK_877,
SSK_641B SSK_641B
) )
@ -49,8 +48,9 @@ class RussianNavyGroupGenerator(ShipGroupGenerator):
self.add_unit(dd_type, "DD2", self.position.x + 2400, self.position.y - 900, self.heading) self.add_unit(dd_type, "DD2", self.position.x + 2400, self.position.y - 900, self.heading)
if include_cc: if include_cc:
cc_type = random.choice([CG_1164_Moskva, CGN_1144_2_Pyotr_Velikiy]) # Only include the Moskva for now, the Pyotry Velikiy is an unkillable monster.
self.add_unit(cc_type, "CC1", self.position.x, self.position.y, self.heading) # See https://github.com/Khopa/dcs_liberation/issues/567
self.add_unit(CG_1164_Moskva, "CC1", self.position.x, self.position.y, self.heading)
self.get_generated_group().points[0].speed = 20 self.get_generated_group().points[0].speed = 20

View File

@ -3,9 +3,12 @@ from __future__ import annotations
import logging import logging
import operator import operator
import random import random
from dataclasses import dataclass from collections import defaultdict
from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
from enum import Enum, auto
from typing import ( from typing import (
Dict,
Iterable, Iterable,
Iterator, Iterator,
List, List,
@ -18,41 +21,28 @@ from typing import (
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game import db
from game.data.radar_db import UNITS_WITH_RADAR
from game.infos.information import Information from game.infos.information import Information
from game.procurement import AircraftProcurementRequest from game.procurement import AircraftProcurementRequest
from game.theater import ( from game.theater import (
Airfield, Airfield,
ControlPoint, ControlPoint,
Fob,
FrontLine, FrontLine,
MissionTarget, MissionTarget,
OffMapSpawn, OffMapSpawn,
SamGroundObject, SamGroundObject,
TheaterGroundObject, TheaterGroundObject,
) )
# Avoid importing some types that cause circular imports unless type checking.
from game.theater.theatergroundobject import ( from game.theater.theatergroundobject import (
BuildingGroundObject,
EwrGroundObject, EwrGroundObject,
NavalGroundObject, VehicleGroupGroundObject, NavalGroundObject,
VehicleGroupGroundObject,
) )
from game.utils import nm_to_meter from game.utils import Distance, nautical_miles
from gen import Conflict from gen import Conflict
from gen.ato import Package from gen.ato import Package
from gen.flights.ai_flight_planner_db import ( from gen.flights.ai_flight_planner_db import aircraft_for_task
ANTISHIP_CAPABLE,
ANTISHIP_PREFERRED,
CAP_CAPABLE,
CAP_PREFERRED,
CAS_CAPABLE,
CAS_PREFERRED,
RUNWAY_ATTACK_CAPABLE,
RUNWAY_ATTACK_PREFERRED,
SEAD_CAPABLE,
SEAD_PREFERRED,
STRIKE_CAPABLE,
STRIKE_PREFERRED, capable_aircraft_for_task, preferred_aircraft_for_task,
)
from gen.flights.closestairfields import ( from gen.flights.closestairfields import (
ClosestAirfields, ClosestAirfields,
ObjectiveDistanceCache, ObjectiveDistanceCache,
@ -64,11 +54,17 @@ from gen.flights.flight import (
from gen.flights.flightplan import FlightPlanBuilder from gen.flights.flightplan import FlightPlanBuilder
from gen.flights.traveltime import TotEstimator from gen.flights.traveltime import TotEstimator
# Avoid importing some types that cause circular imports unless type checking.
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
from game.inventory import GlobalAircraftInventory from game.inventory import GlobalAircraftInventory
class EscortType(Enum):
AirToAir = auto()
Sead = auto()
@dataclass(frozen=True) @dataclass(frozen=True)
class ProposedFlight: class ProposedFlight:
"""A flight outline proposed by the mission planner. """A flight outline proposed by the mission planner.
@ -85,7 +81,13 @@ class ProposedFlight:
num_aircraft: int num_aircraft: int
#: The maximum distance between the objective and the departure airfield. #: The maximum distance between the objective and the departure airfield.
max_distance: int max_distance: Distance
#: The type of threat this flight defends against if it is an escort. Escort
#: flights will be pruned if the rest of the package is not threatened by
#: the threat they defend against. If this flight is not an escort, this
#: field is None.
escort_type: Optional[EscortType] = field(default=None)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.task} {self.num_aircraft} ship" return f"{self.task} {self.num_aircraft} ship"
@ -123,7 +125,7 @@ class AircraftAllocator:
def find_aircraft_for_flight( def find_aircraft_for_flight(
self, flight: ProposedFlight self, flight: ProposedFlight
) -> Optional[Tuple[ControlPoint, FlyingType]]: ) -> Optional[Tuple[ControlPoint, Type[FlyingType]]]:
"""Finds aircraft suitable for the given mission. """Finds aircraft suitable for the given mission.
Searches for aircraft capable of performing the given mission within the Searches for aircraft capable of performing the given mission within the
@ -142,13 +144,8 @@ class AircraftAllocator:
on subsequent calls. If the found aircraft are not used, the caller is on subsequent calls. If the found aircraft are not used, the caller is
responsible for returning them to the inventory. responsible for returning them to the inventory.
""" """
result = self.find_aircraft_of_type(
flight, preferred_aircraft_for_task(flight.task)
)
if result is not None:
return result
return self.find_aircraft_of_type( return self.find_aircraft_of_type(
flight, capable_aircraft_for_task(flight.task) flight, aircraft_for_task(flight.task)
) )
def find_aircraft_of_type( def find_aircraft_of_type(
@ -178,9 +175,11 @@ class PackageBuilder:
closest_airfields: ClosestAirfields, closest_airfields: ClosestAirfields,
global_inventory: GlobalAircraftInventory, global_inventory: GlobalAircraftInventory,
is_player: bool, is_player: bool,
package_country: str,
start_type: str) -> None: start_type: str) -> None:
self.closest_airfields = closest_airfields self.closest_airfields = closest_airfields
self.is_player = is_player self.is_player = is_player
self.package_country = package_country
self.package = Package(location) self.package = Package(location)
self.allocator = AircraftAllocator(closest_airfields, global_inventory, self.allocator = AircraftAllocator(closest_airfields, global_inventory,
is_player) is_player)
@ -204,15 +203,15 @@ class PackageBuilder:
else: else:
start_type = self.start_type start_type = self.start_type
flight = Flight(self.package, aircraft, plan.num_aircraft, plan.task, flight = Flight(self.package, self.package_country, aircraft, plan.num_aircraft, plan.task,
start_type, departure=airfield, arrival=airfield, start_type, departure=airfield, arrival=airfield,
divert=self.find_divert_field(aircraft, airfield)) divert=self.find_divert_field(aircraft, airfield))
self.package.add_flight(flight) self.package.add_flight(flight)
return True return True
def find_divert_field(self, aircraft: FlyingType, def find_divert_field(self, aircraft: Type[FlyingType],
arrival: ControlPoint) -> Optional[ControlPoint]: arrival: ControlPoint) -> Optional[ControlPoint]:
divert_limit = nm_to_meter(150) divert_limit = nautical_miles(150)
for airfield in self.closest_airfields.airfields_within(divert_limit): for airfield in self.closest_airfields.airfields_within(divert_limit):
if airfield.captured != self.is_player: if airfield.captured != self.is_player:
continue continue
@ -241,8 +240,8 @@ class ObjectiveFinder:
"""Identifies potential objectives for the mission planner.""" """Identifies potential objectives for the mission planner."""
# TODO: Merge into doctrine. # TODO: Merge into doctrine.
AIRFIELD_THREAT_RANGE = nm_to_meter(150) AIRFIELD_THREAT_RANGE = nautical_miles(150)
SAM_THREAT_RANGE = nm_to_meter(100) SAM_THREAT_RANGE = nautical_miles(100)
def __init__(self, game: Game, is_player: bool) -> None: def __init__(self, game: Game, is_player: bool) -> None:
self.game = game self.game = game
@ -266,7 +265,7 @@ class ObjectiveFinder:
if ground_object.name in found_targets: if ground_object.name in found_targets:
continue continue
if not self.object_has_radar(ground_object): if not ground_object.has_radar:
continue continue
# TODO: Yield in order of most threatening. # TODO: Yield in order of most threatening.
@ -349,12 +348,35 @@ class ObjectiveFinder:
found_targets: Set[str] = set() found_targets: Set[str] = set()
for enemy_cp in self.enemy_control_points(): for enemy_cp in self.enemy_control_points():
for ground_object in enemy_cp.ground_objects: for ground_object in enemy_cp.ground_objects:
# TODO: Reuse ground_object.mission_types.
# The mission types for ground objects are currently not
# accurate because we include things like strike and BAI for all
# targets since they have different planning behavior (waypoint
# generation is better for players with strike when the targets
# are stationary, AI behavior against weaker air defenses is
# better with BAI), so that's not a useful filter. Once we have
# better control over planning profiles and target dependent
# loadouts we can clean this up.
if isinstance(ground_object, VehicleGroupGroundObject): if isinstance(ground_object, VehicleGroupGroundObject):
# BAI target, not strike target. # BAI target, not strike target.
continue continue
if isinstance(ground_object, NavalGroundObject): if isinstance(ground_object, NavalGroundObject):
# Anti-ship target, not strike target. # Anti-ship target, not strike target.
continue continue
if isinstance(ground_object, SamGroundObject):
# SAMs are targeted by DEAD. No need to double plan.
continue
is_building = isinstance(ground_object, BuildingGroundObject)
is_fob = isinstance(enemy_cp, Fob)
if is_building and is_fob and ground_object.airbase_group:
# This is the FOB structure itself. Can't be repaired or
# targeted by the player, so shouldn't be targetable by the
# AI.
continue
if ground_object.is_dead: if ground_object.is_dead:
continue continue
if ground_object.name in found_targets: if ground_object.name in found_targets:
@ -368,15 +390,6 @@ class ObjectiveFinder:
for target, _range in targets: for target, _range in targets:
yield target yield target
@staticmethod
def object_has_radar(ground_object: TheaterGroundObject) -> bool:
"""Returns True if the ground object contains a unit with radar."""
for group in ground_object.groups:
for unit in group.units:
if db.unit_type_from_name(unit.type) in UNITS_WITH_RADAR:
return True
return False
def front_lines(self) -> Iterator[FrontLine]: def front_lines(self) -> Iterator[FrontLine]:
"""Iterates over all active front lines in the theater.""" """Iterates over all active front lines in the theater."""
for cp in self.friendly_control_points(): for cp in self.friendly_control_points():
@ -467,25 +480,42 @@ class CoalitionMissionPlanner:
""" """
# TODO: Merge into doctrine, also limit by aircraft. # TODO: Merge into doctrine, also limit by aircraft.
MAX_CAP_RANGE = nm_to_meter(100) MAX_CAP_RANGE = nautical_miles(100)
MAX_CAS_RANGE = nm_to_meter(50) MAX_CAS_RANGE = nautical_miles(50)
MAX_ANTISHIP_RANGE = nm_to_meter(150) MAX_ANTISHIP_RANGE = nautical_miles(150)
MAX_BAI_RANGE = nm_to_meter(150) MAX_BAI_RANGE = nautical_miles(150)
MAX_OCA_RANGE = nm_to_meter(150) MAX_OCA_RANGE = nautical_miles(150)
MAX_SEAD_RANGE = nm_to_meter(150) MAX_SEAD_RANGE = nautical_miles(150)
MAX_STRIKE_RANGE = nm_to_meter(150) MAX_STRIKE_RANGE = nautical_miles(150)
def __init__(self, game: Game, is_player: bool) -> None: def __init__(self, game: Game, is_player: bool) -> None:
self.game = game self.game = game
self.is_player = is_player self.is_player = is_player
self.objective_finder = ObjectiveFinder(self.game, self.is_player) self.objective_finder = ObjectiveFinder(self.game, self.is_player)
self.ato = self.game.blue_ato if is_player else self.game.red_ato self.ato = self.game.blue_ato if is_player else self.game.red_ato
self.threat_zones = self.game.threat_zone_for(not self.is_player)
self.procurement_requests: List[AircraftProcurementRequest] = [] self.procurement_requests: List[AircraftProcurementRequest] = []
def propose_missions(self) -> Iterator[ProposedMission]: def critical_missions(self) -> Iterator[ProposedMission]:
"""Identifies and iterates over potential mission in priority order.""" """Identifies the most important missions to plan this turn.
Non-critical missions that cannot be fulfilled will create purchase
orders for the next turn. Critical missions will create a purchase order
unless the mission can be doubly fulfilled. In other words, the AI will
attempt to have *double* the aircraft it needs for these missions to
ensure that they can be planned again next turn even if all aircraft are
eliminated this turn.
"""
# Find friendly CPs within 100 nmi from an enemy airfield, plan CAP. # Find friendly CPs within 100 nmi from an enemy airfield, plan CAP.
for cp in self.objective_finder.vulnerable_control_points(): for cp in self.objective_finder.vulnerable_control_points():
# Plan three rounds of CAP to give ~90 minutes coverage. Spacing
# these out appropriately is done in stagger_missions.
yield ProposedMission(cp, [
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
])
yield ProposedMission(cp, [
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
])
yield ProposedMission(cp, [ yield ProposedMission(cp, [
ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE), ProposedFlight(FlightType.BARCAP, 2, self.MAX_CAP_RANGE),
]) ])
@ -493,10 +523,15 @@ class CoalitionMissionPlanner:
# Find front lines, plan CAS. # Find front lines, plan CAS.
for front_line in self.objective_finder.front_lines(): for front_line in self.objective_finder.front_lines():
yield ProposedMission(front_line, [ yield ProposedMission(front_line, [
ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE),
ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE), ProposedFlight(FlightType.CAS, 2, self.MAX_CAS_RANGE),
ProposedFlight(FlightType.TARCAP, 2, self.MAX_CAP_RANGE,
EscortType.AirToAir),
]) ])
def propose_missions(self) -> Iterator[ProposedMission]:
"""Identifies and iterates over potential mission in priority order."""
yield from self.critical_missions()
# Find enemy SAM sites with ranges that cover friendly CPs, front lines, # Find enemy SAM sites with ranges that cover friendly CPs, front lines,
# or objects, plan DEAD. # or objects, plan DEAD.
# Find enemy SAM sites with ranges that extend to within 50 nmi of # Find enemy SAM sites with ranges that extend to within 50 nmi of
@ -505,39 +540,55 @@ class CoalitionMissionPlanner:
yield ProposedMission(sam, [ yield ProposedMission(sam, [
ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE), ProposedFlight(FlightType.DEAD, 2, self.MAX_SEAD_RANGE),
# TODO: Max escort range. # TODO: Max escort range.
ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE), ProposedFlight(FlightType.ESCORT, 2, self.MAX_SEAD_RANGE,
EscortType.AirToAir),
]) ])
for group in self.objective_finder.threatening_ships(): for group in self.objective_finder.threatening_ships():
yield ProposedMission(group, [ yield ProposedMission(group, [
ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE), ProposedFlight(FlightType.ANTISHIP, 2, self.MAX_ANTISHIP_RANGE),
# TODO: Max escort range. # TODO: Max escort range.
ProposedFlight(FlightType.ESCORT, 2, self.MAX_ANTISHIP_RANGE), ProposedFlight(FlightType.ESCORT, 2, self.MAX_ANTISHIP_RANGE,
EscortType.AirToAir),
]) ])
for group in self.objective_finder.threatening_vehicle_groups(): for group in self.objective_finder.threatening_vehicle_groups():
yield ProposedMission(group, [ yield ProposedMission(group, [
ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE), ProposedFlight(FlightType.BAI, 2, self.MAX_BAI_RANGE),
# TODO: Max escort range. # TODO: Max escort range.
ProposedFlight(FlightType.ESCORT, 2, self.MAX_BAI_RANGE), ProposedFlight(FlightType.ESCORT, 2, self.MAX_BAI_RANGE,
EscortType.AirToAir),
ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE,
EscortType.Sead),
]) ])
for target in self.objective_finder.oca_targets(min_aircraft=20): for target in self.objective_finder.oca_targets(min_aircraft=20):
yield ProposedMission(target, [ flights = [
ProposedFlight(FlightType.OCA_AIRCRAFT, 2, self.MAX_OCA_RANGE),
ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE), ProposedFlight(FlightType.OCA_RUNWAY, 2, self.MAX_OCA_RANGE),
]
if self.game.settings.default_start_type == "Cold":
# Only schedule if the default start type is Cold. If the player
# has set anything else there are no targets to hit.
flights.append(ProposedFlight(FlightType.OCA_AIRCRAFT, 2,
self.MAX_OCA_RANGE))
flights.extend([
# TODO: Max escort range. # TODO: Max escort range.
ProposedFlight(FlightType.ESCORT, 2, self.MAX_OCA_RANGE), ProposedFlight(FlightType.ESCORT, 2, self.MAX_OCA_RANGE,
ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE), EscortType.AirToAir),
ProposedFlight(FlightType.SEAD, 2, self.MAX_OCA_RANGE,
EscortType.Sead),
]) ])
yield ProposedMission(target, flights)
# Plan strike missions. # Plan strike missions.
for target in self.objective_finder.strike_targets(): for target in self.objective_finder.strike_targets():
yield ProposedMission(target, [ yield ProposedMission(target, [
ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE), ProposedFlight(FlightType.STRIKE, 2, self.MAX_STRIKE_RANGE),
# TODO: Max escort range. # TODO: Max escort range.
ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE), ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE,
ProposedFlight(FlightType.ESCORT, 2, self.MAX_STRIKE_RANGE), EscortType.AirToAir),
ProposedFlight(FlightType.SEAD, 2, self.MAX_STRIKE_RANGE,
EscortType.Sead),
]) ])
def plan_missions(self) -> None: def plan_missions(self) -> None:
@ -545,6 +596,9 @@ class CoalitionMissionPlanner:
for proposed_mission in self.propose_missions(): for proposed_mission in self.propose_missions():
self.plan_mission(proposed_mission) self.plan_mission(proposed_mission)
for critical_mission in self.critical_missions():
self.plan_mission(critical_mission, reserves=True)
self.stagger_missions() self.stagger_missions()
for cp in self.objective_finder.friendly_control_points(): for cp in self.objective_finder.friendly_control_points():
@ -553,47 +607,127 @@ class CoalitionMissionPlanner:
self.message("Unused aircraft", self.message("Unused aircraft",
f"{available} {aircraft.id} from {cp}") f"{available} {aircraft.id} from {cp}")
def plan_mission(self, mission: ProposedMission) -> None: def plan_flight(self, mission: ProposedMission, flight: ProposedFlight,
builder: PackageBuilder, missing_types: Set[FlightType],
for_reserves: bool) -> None:
if not builder.plan_flight(flight):
missing_types.add(flight.task)
purchase_order = AircraftProcurementRequest(
near=mission.location,
range=flight.max_distance,
task_capability=flight.task,
number=flight.num_aircraft
)
if for_reserves:
# Reserves are planned for critical missions, so prioritize
# those orders over aircraft needed for non-critical missions.
self.procurement_requests.insert(0, purchase_order)
else:
self.procurement_requests.append(purchase_order)
def scrub_mission_missing_aircraft(
self, mission: ProposedMission, builder: PackageBuilder,
missing_types: Set[FlightType],
not_attempted: Iterable[ProposedFlight],
reserves: bool) -> None:
# Try to plan the rest of the mission just so we can count the missing
# types to buy.
for flight in not_attempted:
self.plan_flight(mission, flight, builder, missing_types, reserves)
missing_types_str = ", ".join(
sorted([t.name for t in missing_types]))
builder.release_planned_aircraft()
desc = "reserve aircraft" if reserves else "aircraft"
self.message(
"Insufficient aircraft",
f"Not enough {desc} in range for {mission.location.name} "
f"capable of: {missing_types_str}")
def check_needed_escorts(
self, builder: PackageBuilder) -> Dict[EscortType, bool]:
threats = defaultdict(bool)
for flight in builder.package.flights:
if self.threat_zones.threatened_by_aircraft(flight):
threats[EscortType.AirToAir] = True
if self.threat_zones.threatened_by_air_defense(flight):
threats[EscortType.Sead] = True
return threats
def plan_mission(self, mission: ProposedMission,
reserves: bool = False) -> None:
"""Allocates aircraft for a proposed mission and adds it to the ATO.""" """Allocates aircraft for a proposed mission and adds it to the ATO."""
if self.game.settings.perf_ai_parking_start: if self.is_player:
start_type = "Cold" package_country = self.game.player_country
else: else:
start_type = "Warm" package_country = self.game.enemy_country
builder = PackageBuilder( builder = PackageBuilder(
mission.location, mission.location,
self.objective_finder.closest_airfields_to(mission.location), self.objective_finder.closest_airfields_to(mission.location),
self.game.aircraft_inventory, self.game.aircraft_inventory,
self.is_player, self.is_player,
start_type package_country,
self.game.settings.default_start_type
) )
# Attempt to plan all the main elements of the mission first. Escorts
# will be planned separately so we can prune escorts for packages that
# are not expected to encounter that type of threat.
missing_types: Set[FlightType] = set() missing_types: Set[FlightType] = set()
escorts = []
for proposed_flight in mission.flights: for proposed_flight in mission.flights:
if not builder.plan_flight(proposed_flight): if proposed_flight.escort_type is not None:
missing_types.add(proposed_flight.task) # Escorts are planned after the primary elements of the package.
self.procurement_requests.append(AircraftProcurementRequest( # If the package does not need escorts they may be pruned.
near=mission.location, escorts.append(proposed_flight)
range=proposed_flight.max_distance, continue
task_capability=proposed_flight.task, self.plan_flight(mission, proposed_flight, builder, missing_types,
number=proposed_flight.num_aircraft reserves)
))
if missing_types: if missing_types:
missing_types_str = ", ".join( self.scrub_mission_missing_aircraft(mission, builder, missing_types,
sorted([t.name for t in missing_types])) escorts, reserves)
return
# Create flight plans for the main flights of the package so we can
# determine threats. This is done *after* creating all of the flights
# rather than as each flight is added because the flight plan for
# flights that will rendezvous with their package will be affected by
# the other flights in the package. Escorts will not be able to
# contribute to this.
flight_plan_builder = FlightPlanBuilder(self.game, builder.package,
self.is_player)
for flight in builder.package.flights:
flight_plan_builder.populate_flight_plan(flight)
needed_escorts = self.check_needed_escorts(builder)
for escort in escorts:
# This list was generated from the not None set, so this should be
# impossible.
assert escort.escort_type is not None
if needed_escorts[escort.escort_type]:
self.plan_flight(mission, escort, builder, missing_types,
reserves)
# Check again for unavailable aircraft. If the escort was required and
# none were found, scrub the mission.
if missing_types:
self.scrub_mission_missing_aircraft(mission, builder, missing_types,
escorts, reserves)
return
if reserves:
# Mission is planned reserves which will not be used this turn.
# Return reserves to the inventory.
builder.release_planned_aircraft() builder.release_planned_aircraft()
self.message(
"Insufficient aircraft",
f"Not enough aircraft in range for {mission.location.name} "
f"capable of: {missing_types_str}")
return return
package = builder.build() package = builder.build()
flight_plan_builder = FlightPlanBuilder(self.game, package, # Add flight plans for escorts.
self.is_player)
for flight in package.flights: for flight in package.flights:
if not flight.flight_plan.waypoints:
flight_plan_builder.populate_flight_plan(flight) flight_plan_builder.populate_flight_plan(flight)
self.ato.add_package(package) self.ato.add_package(package)
@ -607,10 +741,12 @@ class CoalitionMissionPlanner:
dca_types = { dca_types = {
FlightType.BARCAP, FlightType.BARCAP,
FlightType.INTERCEPTION,
FlightType.TARCAP, FlightType.TARCAP,
} }
previous_cap_end_time: Dict[MissionTarget, timedelta] = defaultdict(
timedelta
)
non_dca_packages = [p for p in self.ato.packages if non_dca_packages = [p for p in self.ato.packages if
p.primary_task not in dca_types] p.primary_task not in dca_types]
@ -623,8 +759,22 @@ class CoalitionMissionPlanner:
for package in self.ato.packages: for package in self.ato.packages:
tot = TotEstimator(package).earliest_tot() tot = TotEstimator(package).earliest_tot()
if package.primary_task in dca_types: if package.primary_task in dca_types:
# All CAP missions should be on station ASAP. previous_end_time = previous_cap_end_time[package.target]
if tot > previous_end_time:
# Can't get there exactly on time, so get there ASAP. This
# will typically only happen for the first CAP at each
# target.
package.time_over_target = tot package.time_over_target = tot
else:
package.time_over_target = previous_end_time
departure_time = package.mission_departure_time
# Should be impossible for CAPs
if departure_time is None:
logging.error(
f"Could not determine mission end time for {package}")
continue
previous_cap_end_time[package.target] = departure_time
else: else:
# But other packages should be spread out a bit. Note that take # But other packages should be spread out a bit. Note that take
# times are delayed, but all aircraft will become active at # times are delayed, but all aircraft will become active at

View File

@ -13,6 +13,7 @@ from dcs.helicopters import (
SA342L, SA342L,
SA342M, SA342M,
UH_1H, UH_1H,
SH_60B
) )
from dcs.planes import ( from dcs.planes import (
AJS37, AJS37,
@ -63,6 +64,7 @@ from dcs.planes import (
P_51D, P_51D,
P_51D_30_NA, P_51D_30_NA,
RQ_1A_Predator, RQ_1A_Predator,
S_3B,
SpitfireLFMkIX, SpitfireLFMkIX,
SpitfireLFMkIXCW, SpitfireLFMkIXCW,
Su_17M4, Su_17M4,
@ -92,443 +94,278 @@ from pydcs_extensions.f22a.f22a import F_22A
from pydcs_extensions.mb339.mb339 import MB_339PAN from pydcs_extensions.mb339.mb339 import MB_339PAN
from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M, Rafale_B from pydcs_extensions.rafale.rafale import Rafale_A_S, Rafale_M, Rafale_B
from pydcs_extensions.su57.su57 import Su_57 from pydcs_extensions.su57.su57 import Su_57
from pydcs_extensions.hercules.hercules import Hercules
# All aircraft lists are in priority order. Aircraft higher in the list will be
# preferred over those lower in the list.
# TODO: These lists really ought to be era (faction) dependent. # TODO: These lists really ought to be era (faction) dependent.
# Factions which have F-5s, F-86s, and A-4s will should prefer F-5s for CAP, but # Factions which have F-5s, F-86s, and A-4s will should prefer F-5s for CAP, but
# factions that also have F-4s should not. # factions that also have F-4s should not.
# Interceptor are the aircraft prioritized for interception tasks
# If none is available, the AI will use regular CAP-capable aircraft instead
INTERCEPT_CAPABLE = [
MiG_21Bis,
MiG_25PD,
MiG_31,
MiG_29S,
MiG_29A,
MiG_29G,
MiG_29K,
JF_17,
J_11A,
Su_27,
Su_30,
Su_33,
M_2000C,
Mirage_2000_5,
Rafale_M,
F_14A_135_GR,
F_14B,
F_15C,
F_16A,
F_16C_50,
FA_18C_hornet,
]
# Used for CAP, Escort, and intercept if there is not a specialised aircraft available # Used for CAP, Escort, and intercept if there is not a specialised aircraft available
CAP_CAPABLE = [ CAP_CAPABLE = [
Su_57,
MiG_15bis, F_22A,
MiG_19P,
MiG_21Bis,
MiG_23MLD,
MiG_25PD,
MiG_29A,
MiG_29G,
MiG_29S,
MiG_31, MiG_31,
F_14B,
F_14A_135_GR,
MiG_25PD,
Rafale_M,
Su_33,
Su_30,
Su_27, Su_27,
J_11A, J_11A,
JF_17,
Su_30,
Su_33,
Su_57,
M_2000C,
Mirage_2000_5,
F_86F_Sabre,
F_4E,
F_5E_3,
F_14A_135_GR,
F_14B,
F_15C, F_15C,
F_15E, MiG_29S,
F_16A, MiG_29K,
MiG_29G,
MiG_29A,
F_16C_50, F_16C_50,
FA_18C_hornet, FA_18C_hornet,
F_22A, F_15E,
F_16A,
F_4E,
JF_17,
MiG_23MLD,
MiG_21Bis,
Mirage_2000_5,
M_2000C,
F_5E_3,
MiG_19P,
A_4E_C,
F_86F_Sabre,
MiG_15bis,
C_101CC, C_101CC,
L_39ZA, L_39ZA,
P_51D_30_NA, P_51D_30_NA,
P_51D, P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
Bf_109K_4,
FW_190D9,
FW_190A8,
P_47D_30, P_47D_30,
P_47D_30bl1, P_47D_30bl1,
P_47D_40, P_47D_40,
I_16, I_16,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
Bf_109K_4,
FW_190D9,
FW_190A8,
A_4E_C,
Rafale_M,
] ]
CAP_PREFERRED = [
MiG_15bis,
MiG_19P,
MiG_21Bis,
MiG_23MLD,
MiG_29A,
MiG_29G,
MiG_29S,
Su_27,
J_11A,
JF_17,
Su_30,
Su_33,
Su_57,
M_2000C,
Mirage_2000_5,
F_86F_Sabre,
F_14A_135_GR,
F_14B,
F_15C,
F_16C_50,
F_22A,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
I_16,
Bf_109K_4,
FW_190D9,
FW_190A8,
Rafale_M,
]
# Used for CAS (Close air support) and BAI (Battlefield Interdiction) # Used for CAS (Close air support) and BAI (Battlefield Interdiction)
CAS_CAPABLE = [ CAS_CAPABLE = [
MiG_15bis,
MiG_29A,
MiG_27K,
MiG_29S,
Su_17M4,
Su_24M,
Su_24MR,
Su_25,
Su_25T,
Su_25TM,
Su_30,
Su_34,
JF_17,
M_2000C,
A_10A,
A_10C,
A_10C_2, A_10C_2,
AV8BNA, A_10C,
B_1B,
F_86F_Sabre, F_14B,
F_5E_3, F_14A_135_GR,
Su_25TM,
Su_25T,
Su_25,
F_15E,
F_16C_50, F_16C_50,
FA_18C_hornet, FA_18C_hornet,
F_15E, Rafale_A_S,
F_22A, Rafale_B,
Tornado_IDS,
Tornado_GR4, Tornado_GR4,
Tornado_IDS,
JF_17,
A_10A,
A_4E_C,
AJS37,
Su_24MR,
Su_24M,
Su_17M4,
AV8BNA,
S_3B,
Su_34,
Su_30,
MiG_29S,
MiG_27K,
MiG_29A,
AH_64D,
AH_64A,
AH_1W,
OH_58D,
SA342M,
SA342L,
Ka_50,
Mi_28N,
Mi_24V,
Mi_8MT,
UH_1H,
MiG_15bis,
M_2000C,
F_5E_3,
F_86F_Sabre,
C_101CC, C_101CC,
MB_339PAN, MB_339PAN,
L_39ZA, L_39ZA,
AJS37, A_20G,
P_47D_40,
SA342M, P_47D_30bl1,
SA342L, P_47D_30,
OH_58D,
AH_64A,
AH_64D,
AH_1W,
UH_1H,
Mi_8MT,
Mi_28N,
Mi_24V,
Ka_50,
P_51D_30_NA, P_51D_30_NA,
P_51D, P_51D,
P_47D_30,
P_47D_30bl1,
P_47D_40,
A_20G,
SpitfireLFMkIXCW, SpitfireLFMkIXCW,
SpitfireLFMkIX, SpitfireLFMkIX,
I_16, I_16,
Bf_109K_4, Bf_109K_4,
FW_190D9, FW_190D9,
FW_190A8, FW_190A8,
A_4E_C,
Rafale_A_S,
Rafale_B,
WingLoong_I, WingLoong_I,
MQ_9_Reaper, MQ_9_Reaper,
RQ_1A_Predator RQ_1A_Predator,
] ]
CAS_PREFERRED = [
Su_17M4,
Su_24M,
Su_24MR,
Su_25,
Su_25T,
Su_25TM,
Su_30,
Su_34,
A_10A, # Aircraft used for SEAD tasks
A_10C,
A_10C_2,
AV8BNA,
Tornado_GR4,
C_101CC,
MB_339PAN,
L_39ZA,
AJS37,
SA342M,
SA342L,
OH_58D,
AH_64A,
AH_64D,
AH_1W,
Mi_28N,
Mi_24V,
Ka_50,
P_47D_30,
P_47D_30bl1,
P_47D_40,
A_20G,
I_16,
A_4E_C,
Rafale_A_S,
Rafale_B,
WingLoong_I,
MQ_9_Reaper,
RQ_1A_Predator
]
# Aircraft used for SEAD / DEAD tasks
SEAD_CAPABLE = [ SEAD_CAPABLE = [
F_4E,
FA_18C_hornet,
F_16C_50,
AV8BNA,
JF_17, JF_17,
Su_24M,
Su_25T,
Su_25TM,
Su_17M4,
Su_30,
Su_34,
MiG_27K,
Tornado_IDS,
Tornado_GR4,
A_4E_C,
Rafale_A_S,
Rafale_B
]
SEAD_PREFERRED = [
F_4E,
Su_25T,
Su_25TM,
Tornado_IDS,
F_16C_50, F_16C_50,
FA_18C_hornet, FA_18C_hornet,
Su_30, Tornado_IDS,
Su_34, Su_25T,
Su_25TM,
Rafale_A_S,
Rafale_B,
F_4E,
A_4E_C,
AV8BNA,
Su_24M, Su_24M,
Su_17M4,
Su_34,
Su_30,
MiG_27K,
Tornado_GR4,
F_117A,
B_17G,
A_20G,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA,
P_51D,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
Bf_109K_4,
FW_190D9,
FW_190A8,
] ]
# Aircraft used for DEAD tasks
DEAD_CAPABLE = [
AJS37,
F_14B,
F_14A_135_GR,
B_1B,
B_52H,
Tu_160,
Tu_95MS,
] + SEAD_CAPABLE
# Aircraft used for Strike mission # Aircraft used for Strike mission
STRIKE_CAPABLE = [ STRIKE_CAPABLE = [
MiG_15bis, F_117A,
MiG_21Bis,
MiG_27K,
MB_339PAN,
Su_17M4,
Su_24M,
Su_24MR,
Su_25,
Su_25T,
Su_25TM,
Su_27,
Su_33,
Su_30,
Su_34,
MiG_29A,
MiG_29G,
MiG_29K,
MiG_29S,
Tu_160,
Tu_22M3,
Tu_95MS,
JF_17,
M_2000C,
A_10C,
A_10C_2,
AV8BNA,
F_86F_Sabre,
F_5E_3,
F_14A_135_GR,
F_14B,
F_15E,
F_16A,
F_16C_50,
FA_18C_hornet,
B_1B, B_1B,
B_52H, B_52H,
F_117A, Tu_160,
Tu_95MS,
Tornado_IDS, Tu_22M3,
F_15E,
AJS37,
Rafale_A_S,
Rafale_B,
Tornado_GR4, Tornado_GR4,
F_16C_50,
FA_18C_hornet,
F_16A,
F_14B,
F_14A_135_GR,
Tornado_IDS,
Su_17M4,
Su_24MR,
Su_24M,
Su_25TM,
Su_25T,
Su_25,
Su_34,
Su_33,
Su_30,
Su_27,
MiG_29S,
MiG_29K,
MiG_29G,
MiG_29A,
JF_17,
A_10C_2,
A_10C,
AV8BNA,
S_3B,
A_4E_C,
M_2000C,
MiG_27K,
MiG_21Bis,
MiG_15bis,
F_5E_3,
F_86F_Sabre,
MB_339PAN,
C_101CC, C_101CC,
L_39ZA, L_39ZA,
AJS37, B_17G,
A_20G,
P_47D_40,
P_47D_30bl1,
P_47D_30,
P_51D_30_NA, P_51D_30_NA,
P_51D, P_51D,
P_47D_30,
P_47D_30bl1,
P_47D_40,
A_20G,
B_17G,
SpitfireLFMkIXCW, SpitfireLFMkIXCW,
SpitfireLFMkIX, SpitfireLFMkIX,
Bf_109K_4, Bf_109K_4,
FW_190D9, FW_190D9,
FW_190A8, FW_190A8,
A_4E_C,
Rafale_A_S,
Rafale_B
] ]
STRIKE_PREFERRED = [
AJS37,
A_20G,
B_17G,
B_1B,
B_52H,
F_117A,
F_15E,
Su_24M,
Su_30,
Su_34,
Tornado_IDS,
Tornado_GR4,
Tu_160,
Tu_22M3,
Tu_95MS,
]
ANTISHIP_CAPABLE = [ ANTISHIP_CAPABLE = [
AJS37, AJS37,
C_101CC,
Su_24M,
Su_17M4,
FA_18C_hornet,
AV8BNA,
JF_17,
Su_30,
Su_34,
Tu_22M3, Tu_22M3,
Tornado_IDS,
Tornado_GR4,
Ju_88A4,
Rafale_A_S,
Rafale_B
]
ANTISHIP_PREFERRED = [
AJS37,
C_101CC,
FA_18C_hornet, FA_18C_hornet,
JF_17,
Rafale_A_S, Rafale_A_S,
Rafale_B, Rafale_B,
Su_24M, Su_24M,
Su_30, Su_17M4,
Su_34,
Tu_22M3,
Ju_88A4
]
RUNWAY_ATTACK_PREFERRED = [
JF_17, JF_17,
Su_30,
Su_34, Su_34,
Su_30,
Tornado_IDS, Tornado_IDS,
Tornado_GR4,
AV8BNA,
S_3B,
A_20G,
Ju_88A4,
C_101CC,
SH_60B,
] ]
RUNWAY_ATTACK_CAPABLE = STRIKE_CAPABLE
# Duplicates some list entries but that's fine.
RUNWAY_ATTACK_CAPABLE = [
JF_17,
Su_34,
Su_30,
Tornado_IDS,
] + STRIKE_CAPABLE
# For any aircraft that isn't necessarily directly involved in strike
# missions in a direct combat sense, but can transport objects and infantry.
TRANSPORT_CAPABLE = [
Hercules,
Mi_8MT,
UH_1H,
]
DRONES = [ DRONES = [
MQ_9_Reaper, MQ_9_Reaper,
@ -537,31 +374,7 @@ DRONES = [
] ]
def preferred_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]: def aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
cap_missions = (FlightType.BARCAP, FlightType.TARCAP)
if task in cap_missions:
return CAP_PREFERRED
elif task == FlightType.ANTISHIP:
return ANTISHIP_PREFERRED
elif task == FlightType.BAI:
return CAS_CAPABLE
elif task == FlightType.CAS:
return CAS_PREFERRED
elif task in (FlightType.DEAD, FlightType.SEAD):
return SEAD_PREFERRED
elif task == FlightType.OCA_AIRCRAFT:
return CAS_PREFERRED
elif task == FlightType.OCA_RUNWAY:
return RUNWAY_ATTACK_PREFERRED
elif task == FlightType.STRIKE:
return STRIKE_PREFERRED
elif task == FlightType.ESCORT:
return CAP_PREFERRED
else:
return []
def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
cap_missions = (FlightType.BARCAP, FlightType.TARCAP) cap_missions = (FlightType.BARCAP, FlightType.TARCAP)
if task in cap_missions: if task in cap_missions:
return CAP_CAPABLE return CAP_CAPABLE
@ -571,8 +384,10 @@ def capable_aircraft_for_task(task: FlightType) -> List[Type[FlyingType]]:
return CAS_CAPABLE return CAS_CAPABLE
elif task == FlightType.CAS: elif task == FlightType.CAS:
return CAS_CAPABLE return CAS_CAPABLE
elif task in (FlightType.DEAD, FlightType.SEAD): elif task == FlightType.SEAD:
return SEAD_CAPABLE return SEAD_CAPABLE
elif task == FlightType.DEAD:
return DEAD_CAPABLE
elif task == FlightType.OCA_AIRCRAFT: elif task == FlightType.OCA_AIRCRAFT:
return CAS_CAPABLE return CAS_CAPABLE
elif task == FlightType.OCA_RUNWAY: elif task == FlightType.OCA_RUNWAY:

View File

@ -1,7 +1,12 @@
"""Objective adjacency lists.""" """Objective adjacency lists."""
from typing import Dict, Iterator, List, Optional from __future__ import annotations
from game.theater import ConflictTheater, ControlPoint, MissionTarget from typing import Dict, Iterator, List, Optional, TYPE_CHECKING
from game.utils import Distance
if TYPE_CHECKING:
from game.theater import ConflictTheater, ControlPoint, MissionTarget
class ClosestAirfields: class ClosestAirfields:
@ -10,18 +15,25 @@ class ClosestAirfields:
def __init__(self, target: MissionTarget, def __init__(self, target: MissionTarget,
all_control_points: List[ControlPoint]) -> None: all_control_points: List[ControlPoint]) -> None:
self.target = target self.target = target
# This cache is configured once on load, so it's important that it is
# complete and deterministic to avoid different behaviors across loads.
# E.g. https://github.com/Khopa/dcs_liberation/issues/819
self.closest_airfields: List[ControlPoint] = sorted( self.closest_airfields: List[ControlPoint] = sorted(
all_control_points, key=lambda c: self.target.distance_to(c) all_control_points, key=lambda c: self.target.distance_to(c)
) )
def airfields_within(self, meters: int) -> Iterator[ControlPoint]: @property
def operational_airfields(self) -> Iterator[ControlPoint]:
return (c for c in self.closest_airfields if c.runway_is_operational())
def airfields_within(self, distance: Distance) -> Iterator[ControlPoint]:
"""Iterates over all airfields within the given range of the target. """Iterates over all airfields within the given range of the target.
Note that this iterates over *all* airfields, not just friendly Note that this iterates over *all* airfields, not just friendly
airfields. airfields.
""" """
for cp in self.closest_airfields: for cp in self.closest_airfields:
if cp.distance_to(self.target) < meters: if cp.distance_to(self.target) < distance.meters:
yield cp yield cp
else: else:
break break

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
from typing import Dict, List, Optional, TYPE_CHECKING, Type from typing import Dict, List, Optional, TYPE_CHECKING, Type
@ -9,7 +10,9 @@ from dcs.point import MovingPoint, PointAction
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game import db from game import db
from game.data.weapons import Weapon
from game.theater.controlpoint import ControlPoint, MissionTarget from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters
if TYPE_CHECKING: if TYPE_CHECKING:
from gen.ato import Package from gen.ato import Package
@ -67,7 +70,7 @@ class FlightWaypointType(Enum):
class FlightWaypoint: class FlightWaypoint:
def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float, def __init__(self, waypoint_type: FlightWaypointType, x: float, y: float,
alt: int = 0) -> None: alt: Distance = meters(0)) -> None:
"""Creates a flight waypoint. """Creates a flight waypoint.
Args: Args:
@ -83,6 +86,9 @@ class FlightWaypoint:
self.alt = alt self.alt = alt
self.alt_type = "BARO" self.alt_type = "BARO"
self.name = "" self.name = ""
# TODO: Merge with pretty_name.
# Only used in the waypoint list in the flight edit page. No sense
# having three names. A short and long form is enough.
self.description = "" self.description = ""
self.targets: List[MissionTarget] = [] self.targets: List[MissionTarget] = []
self.obj_name = "" self.obj_name = ""
@ -105,7 +111,7 @@ class FlightWaypoint:
def from_pydcs(cls, point: MovingPoint, def from_pydcs(cls, point: MovingPoint,
from_cp: ControlPoint) -> "FlightWaypoint": from_cp: ControlPoint) -> "FlightWaypoint":
waypoint = FlightWaypoint(FlightWaypointType.NAV, point.position.x, waypoint = FlightWaypoint(FlightWaypointType.NAV, point.position.x,
point.position.y, point.alt) point.position.y, meters(point.alt))
waypoint.alt_type = point.alt_type waypoint.alt_type = point.alt_type
# Other actions exist... but none of them *should* be the first # Other actions exist... but none of them *should* be the first
# waypoint for a flight. # waypoint for a flight.
@ -130,11 +136,13 @@ class FlightWaypoint:
class Flight: class Flight:
def __init__(self, package: Package, unit_type: Type[FlyingType], def __init__(self, package: Package, country: str, unit_type: Type[FlyingType],
count: int, flight_type: FlightType, start_type: str, count: int, flight_type: FlightType, start_type: str,
departure: ControlPoint, arrival: ControlPoint, departure: ControlPoint, arrival: ControlPoint,
divert: Optional[ControlPoint]) -> None: divert: Optional[ControlPoint],
custom_name: Optional[str] = None) -> None:
self.package = package self.package = package
self.country = country
self.unit_type = unit_type self.unit_type = unit_type
self.count = count self.count = count
self.departure = departure self.departure = departure
@ -143,10 +151,11 @@ class Flight:
self.flight_type = flight_type self.flight_type = flight_type
# TODO: Replace with FlightPlan. # TODO: Replace with FlightPlan.
self.targets: List[MissionTarget] = [] self.targets: List[MissionTarget] = []
self.loadout: Dict[str, str] = {} self.loadout: Dict[int, Optional[Weapon]] = {}
self.start_type = start_type self.start_type = start_type
self.use_custom_loadout = False self.use_custom_loadout = False
self.client_count = 0 self.client_count = 0
self.custom_name = custom_name
# Will be replaced with a more appropriate FlightPlan by # Will be replaced with a more appropriate FlightPlan by
# FlightPlanBuilder, but an empty flight plan the flight begins with an # FlightPlanBuilder, but an empty flight plan the flight begins with an
@ -168,4 +177,12 @@ class Flight:
def __repr__(self): def __repr__(self):
name = db.unit_type_name(self.unit_type) name = db.unit_type_name(self.unit_type)
if self.custom_name:
return f"{self.custom_name} {self.count} x {name}"
return f"[{self.flight_type}] {self.count} x {name}"
def __str__(self):
name = db.unit_get_expanded_info(self.country, self.unit_type, 'name')
if self.custom_name:
return f"{self.custom_name} {self.count} x {name}"
return f"[{self.flight_type}] {self.count} x {name}" return f"[{self.flight_type}] {self.count} x {name}"

View File

@ -17,6 +17,7 @@ from typing import Iterator, List, Optional, Set, TYPE_CHECKING, Tuple
from dcs.mapping import Point from dcs.mapping import Point
from dcs.unit import Unit from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine from game.data.doctrine import Doctrine
from game.theater import ( from game.theater import (
@ -28,12 +29,12 @@ from game.theater import (
TheaterGroundObject, TheaterGroundObject,
) )
from game.theater.theatergroundobject import EwrGroundObject from game.theater.theatergroundobject import EwrGroundObject
from game.utils import nm_to_meter, meter_to_nm from game.utils import Distance, Speed, meters, nautical_miles
from .closestairfields import ObjectiveDistanceCache from .closestairfields import ObjectiveDistanceCache
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
from .traveltime import GroundSpeed, TravelTime from .traveltime import GroundSpeed, TravelTime
from .waypointbuilder import StrikeTarget, WaypointBuilder from .waypointbuilder import StrikeTarget, WaypointBuilder
from ..conflictgen import Conflict from ..conflictgen import Conflict, FRONTLINE_LENGTH
if TYPE_CHECKING: if TYPE_CHECKING:
from game import Game from game import Game
@ -86,7 +87,7 @@ class FlightPlan:
return zip(self.waypoints[:last_index], self.waypoints[1:last_index]) return zip(self.waypoints[:last_index], self.waypoints[1:last_index])
def best_speed_between_waypoints(self, a: FlightWaypoint, def best_speed_between_waypoints(self, a: FlightWaypoint,
b: FlightWaypoint) -> int: b: FlightWaypoint) -> Speed:
"""Desired ground speed between points a and b.""" """Desired ground speed between points a and b."""
factor = 1.0 factor = 1.0
if b.waypoint_type == FlightWaypointType.ASCEND_POINT: if b.waypoint_type == FlightWaypointType.ASCEND_POINT:
@ -105,11 +106,10 @@ class FlightPlan:
# We don't have an exact heightmap, but we should probably be performing # We don't have an exact heightmap, but we should probably be performing
# *some* adjustment for NTTR since the minimum altitude of the map is # *some* adjustment for NTTR since the minimum altitude of the map is
# near 2000 ft MSL. # near 2000 ft MSL.
return int( return GroundSpeed.for_flight(self.flight, min(a.alt, b.alt)) * factor
GroundSpeed.for_flight(self.flight, min(a.alt, b.alt)) * factor)
def speed_between_waypoints(self, a: FlightWaypoint, def speed_between_waypoints(self, a: FlightWaypoint,
b: FlightWaypoint) -> int: b: FlightWaypoint) -> Speed:
return self.best_speed_between_waypoints(a, b) return self.best_speed_between_waypoints(a, b)
@property @property
@ -126,16 +126,17 @@ class FlightPlan:
def bingo_fuel(self) -> int: def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan """Bingo fuel value for the FlightPlan
""" """
distance_to_arrival = meter_to_nm(self.max_distance_from(self.flight.arrival)) distance_to_arrival = self.max_distance_from(self.flight.arrival)
bingo = 1000 # Minimum Emergency Fuel bingo = 1000.0 # Minimum Emergency Fuel
bingo += 500 # Visual Traffic bingo += 500 # Visual Traffic
bingo += 15 * distance_to_arrival bingo += 15 * distance_to_arrival.nautical_miles
# TODO: Per aircraft tweaks. # TODO: Per aircraft tweaks.
if self.flight.divert is not None: if self.flight.divert is not None:
bingo += 10 * meter_to_nm(self.max_distance_from(self.flight.divert)) max_divert_distance = self.max_distance_from(self.flight.divert)
bingo += 10 * max_divert_distance.nautical_miles
return round(bingo / 100) * 100 return round(bingo / 100) * 100
@ -145,13 +146,14 @@ class FlightPlan:
""" """
return self.bingo_fuel + 1000 return self.bingo_fuel + 1000
def max_distance_from(self, cp: ControlPoint) -> int: def max_distance_from(self, cp: ControlPoint) -> Distance:
"""Returns the farthest waypoint of the flight plan from a ControlPoint. """Returns the farthest waypoint of the flight plan from a ControlPoint.
:arg cp The ControlPoint to measure distance from. :arg cp The ControlPoint to measure distance from.
""" """
if not self.waypoints: if not self.waypoints:
return 0 return meters(0)
return max([cp.position.distance_to_point(w.position) for w in self.waypoints]) return max([meters(cp.position.distance_to_point(w.position)) for w in
self.waypoints])
@property @property
def tot_offset(self) -> timedelta: def tot_offset(self) -> timedelta:
@ -242,6 +244,11 @@ class FlightPlan:
else: else:
return timedelta(minutes=5) return timedelta(minutes=5)
@property
def mission_departure_time(self) -> timedelta:
"""The time that the mission is complete and the flight RTBs."""
raise NotImplementedError
@dataclass(frozen=True) @dataclass(frozen=True)
class LoiterFlightPlan(FlightPlan): class LoiterFlightPlan(FlightPlan):
@ -303,7 +310,7 @@ class FormationFlightPlan(LoiterFlightPlan):
return self.split return self.split
@cached_property @cached_property
def best_flight_formation_speed(self) -> int: def best_flight_formation_speed(self) -> Speed:
"""The best speed this flight is capable at all formation waypoints. """The best speed this flight is capable at all formation waypoints.
To ease coordination with other flights, we aim to have a single mission To ease coordination with other flights, we aim to have a single mission
@ -319,7 +326,7 @@ class FormationFlightPlan(LoiterFlightPlan):
return min(speeds) return min(speeds)
def speed_between_waypoints(self, a: FlightWaypoint, def speed_between_waypoints(self, a: FlightWaypoint,
b: FlightWaypoint) -> int: b: FlightWaypoint) -> Speed:
if b in self.package_speed_waypoints: if b in self.package_speed_waypoints:
# Should be impossible, as any package with at least one # Should be impossible, as any package with at least one
# FormationFlightPlan flight needs a formation speed. # FormationFlightPlan flight needs a formation speed.
@ -355,15 +362,27 @@ class FormationFlightPlan(LoiterFlightPlan):
GroundSpeed.for_flight(self.flight, self.hold.alt) GroundSpeed.for_flight(self.flight, self.hold.alt)
) )
@property
def mission_departure_time(self) -> timedelta:
return self.split_time
@dataclass(frozen=True) @dataclass(frozen=True)
class PatrollingFlightPlan(FlightPlan): class PatrollingFlightPlan(FlightPlan):
nav_to: List[FlightWaypoint]
nav_from: List[FlightWaypoint]
patrol_start: FlightWaypoint patrol_start: FlightWaypoint
patrol_end: FlightWaypoint patrol_end: FlightWaypoint
#: Maximum time to remain on station. #: Maximum time to remain on station.
patrol_duration: timedelta patrol_duration: timedelta
#: The engagement range of any Search Then Engage task, or the radius of a
#: Search Then Engage in Zone task. Any enemies of the appropriate type for
#: this mission within this range of the flight's current position (or the
#: center of the zone) will be engaged by the flight.
engagement_distance: Distance
@property @property
def patrol_start_time(self) -> timedelta: def patrol_start_time(self) -> timedelta:
return self.package.time_over_target return self.package.time_over_target
@ -397,6 +416,10 @@ class PatrollingFlightPlan(FlightPlan):
def tot_waypoint(self) -> Optional[FlightWaypoint]: def tot_waypoint(self) -> Optional[FlightWaypoint]:
return self.patrol_start return self.patrol_start
@property
def mission_departure_time(self) -> timedelta:
return self.patrol_end_time
@dataclass(frozen=True) @dataclass(frozen=True)
class BarCapFlightPlan(PatrollingFlightPlan): class BarCapFlightPlan(PatrollingFlightPlan):
@ -405,12 +428,14 @@ class BarCapFlightPlan(PatrollingFlightPlan):
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff
yield from self.nav_to
yield from [ yield from [
self.takeoff,
self.patrol_start, self.patrol_start,
self.patrol_end, self.patrol_end,
self.land,
] ]
yield from self.nav_from
yield self.land
if self.divert is not None: if self.divert is not None:
yield self.divert yield self.divert
@ -423,13 +448,15 @@ class CasFlightPlan(PatrollingFlightPlan):
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff
yield from self.nav_to
yield from [ yield from [
self.takeoff,
self.patrol_start, self.patrol_start,
self.target, self.target,
self.patrol_end, self.patrol_end,
self.land,
] ]
yield from self.nav_from
yield self.land
if self.divert is not None: if self.divert is not None:
yield self.divert yield self.divert
@ -448,12 +475,14 @@ class TarCapFlightPlan(PatrollingFlightPlan):
lead_time: timedelta lead_time: timedelta
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield self.takeoff
yield from self.nav_to
yield from [ yield from [
self.takeoff,
self.patrol_start, self.patrol_start,
self.patrol_end, self.patrol_end,
self.land,
] ]
yield from self.nav_from
yield self.land
if self.divert is not None: if self.divert is not None:
yield self.divert yield self.divert
@ -486,27 +515,27 @@ class TarCapFlightPlan(PatrollingFlightPlan):
class StrikeFlightPlan(FormationFlightPlan): class StrikeFlightPlan(FormationFlightPlan):
takeoff: FlightWaypoint takeoff: FlightWaypoint
hold: FlightWaypoint hold: FlightWaypoint
nav_to: List[FlightWaypoint]
join: FlightWaypoint join: FlightWaypoint
ingress: FlightWaypoint ingress: FlightWaypoint
targets: List[FlightWaypoint] targets: List[FlightWaypoint]
egress: FlightWaypoint egress: FlightWaypoint
split: FlightWaypoint split: FlightWaypoint
nav_from: List[FlightWaypoint]
land: FlightWaypoint land: FlightWaypoint
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield from [ yield self.takeoff
self.takeoff, yield self.hold
self.hold, yield from self.nav_to
self.join, yield self.join
self.ingress yield self.ingress
]
yield from self.targets yield from self.targets
yield from [ yield self.egress
self.egress, yield self.split
self.split, yield from self.nav_from
self.land, yield self.land
]
if self.divert is not None: if self.divert is not None:
yield self.divert yield self.divert
@ -519,7 +548,7 @@ class StrikeFlightPlan(FormationFlightPlan):
} | set(self.targets) } | set(self.targets)
def speed_between_waypoints(self, a: FlightWaypoint, def speed_between_waypoints(self, a: FlightWaypoint,
b: FlightWaypoint) -> int: b: FlightWaypoint) -> Speed:
# FlightWaypoint is only comparable by identity, so adding # FlightWaypoint is only comparable by identity, so adding
# target_area_waypoint to package_speed_waypoints is useless. # target_area_waypoint to package_speed_waypoints is useless.
if b.waypoint_type == FlightWaypointType.TARGET_GROUP_LOC: if b.waypoint_type == FlightWaypointType.TARGET_GROUP_LOC:
@ -537,7 +566,8 @@ class StrikeFlightPlan(FormationFlightPlan):
def target_area_waypoint(self) -> FlightWaypoint: def target_area_waypoint(self) -> FlightWaypoint:
return FlightWaypoint(FlightWaypointType.TARGET_GROUP_LOC, return FlightWaypoint(FlightWaypointType.TARGET_GROUP_LOC,
self.package.target.position.x, self.package.target.position.x,
self.package.target.position.y, 0) self.package.target.position.y,
meters(0))
@property @property
def travel_time_to_target(self) -> timedelta: def travel_time_to_target(self) -> timedelta:
@ -562,7 +592,7 @@ class StrikeFlightPlan(FormationFlightPlan):
return total return total
@property @property
def mission_speed(self) -> int: def mission_speed(self) -> Speed:
return GroundSpeed.for_flight(self.flight, self.ingress.alt) return GroundSpeed.for_flight(self.flight, self.ingress.alt)
@property @property
@ -604,20 +634,22 @@ class StrikeFlightPlan(FormationFlightPlan):
@dataclass(frozen=True) @dataclass(frozen=True)
class SweepFlightPlan(LoiterFlightPlan): class SweepFlightPlan(LoiterFlightPlan):
takeoff: FlightWaypoint takeoff: FlightWaypoint
nav_to: List[FlightWaypoint]
sweep_start: FlightWaypoint sweep_start: FlightWaypoint
sweep_end: FlightWaypoint sweep_end: FlightWaypoint
nav_from: List[FlightWaypoint]
land: FlightWaypoint land: FlightWaypoint
divert: Optional[FlightWaypoint] divert: Optional[FlightWaypoint]
lead_time: timedelta lead_time: timedelta
def iter_waypoints(self) -> Iterator[FlightWaypoint]: def iter_waypoints(self) -> Iterator[FlightWaypoint]:
yield from [ yield self.takeoff
self.takeoff, yield self.hold
self.hold, yield from self.nav_to
self.sweep_start, yield self.sweep_start
self.sweep_end, yield self.sweep_end
self.land, yield from self.nav_from
] yield self.land
if self.divert is not None: if self.divert is not None:
yield self.divert yield self.divert
@ -660,6 +692,9 @@ class SweepFlightPlan(LoiterFlightPlan):
GroundSpeed.for_flight(self.flight, self.hold.alt) GroundSpeed.for_flight(self.flight, self.hold.alt)
) )
def mission_departure_time(self) -> timedelta:
return self.sweep_end_time
@dataclass(frozen=True) @dataclass(frozen=True)
class CustomFlightPlan(FlightPlan): class CustomFlightPlan(FlightPlan):
@ -690,6 +725,10 @@ class CustomFlightPlan(FlightPlan):
self, waypoint: FlightWaypoint) -> Optional[timedelta]: self, waypoint: FlightWaypoint) -> Optional[timedelta]:
return None return None
@property
def mission_departure_time(self) -> timedelta:
return self.package.time_over_target
class FlightPlanBuilder: class FlightPlanBuilder:
"""Generates flight plans for flights.""" """Generates flight plans for flights."""
@ -710,6 +749,7 @@ class FlightPlanBuilder:
else: else:
faction = self.game.enemy_faction faction = self.game.enemy_faction
self.doctrine: Doctrine = faction.doctrine self.doctrine: Doctrine = faction.doctrine
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def populate_flight_plan( def populate_flight_plan(
self, flight: Flight, self, flight: Flight,
@ -755,12 +795,79 @@ class FlightPlanBuilder:
f"{task} flight plan generation not implemented") f"{task} flight plan generation not implemented")
def regenerate_package_waypoints(self) -> None: def regenerate_package_waypoints(self) -> None:
ingress_point = self._ingress_point() # The simple case is where the target is greater than the ingress
egress_point = self._egress_point() # distance into the threat zone and the target is not near the departure
# airfield. In this case, we can plan the shortest route from the
# departure airfield to the target, use the last non-threatened point as
# the join point, and plan the IP inside the threatened area.
#
# When the target is near the edge of the threat zone the IP may need to
# be placed outside the zone.
#
# +--------------+ +---------------+
# | | | |
# | | IP---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
#
# Here we want to place the IP first and route the flight to the IP
# rather than routing to the target and placing the IP based on the join
# point.
#
# The other case that we need to handle is when the target is close to
# the origin airfield. In this case we also need to set up the IP first,
# but depending on the placement of the IP we may need to place the join
# point in a retreating position.
#
# A messy (and very unlikely) case that we can't do much about:
#
# +--------------+ +---------------+
# | | | |
# | IP-+---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
from gen.ato import PackageWaypoints
target = self.package.target.position
join_point = self.preferred_join_point()
if join_point is None:
# The whole path from the origin airfield to the target is
# threatened. Need to retreat out of the threat area.
join_point = self.retreat_point(self.package_airfield().position)
attack_heading = join_point.heading_between_point(target)
ingress_point = self._ingress_point(attack_heading)
join_distance = meters(join_point.distance_to_point(target))
ingress_distance = meters(ingress_point.distance_to_point(target))
if join_distance < ingress_distance:
# The second case described above. The ingress point is farther from
# the target than the join point. Use the fallback behavior for now.
self.legacy_package_waypoints_impl()
return
# The first case described above. The ingress and join points are placed
# reasonably relative to each other.
egress_point = self._egress_point(attack_heading)
self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point),
ingress_point,
egress_point,
WaypointBuilder.perturb(join_point),
)
def retreat_point(self, origin: Point) -> Point:
return self.threat_zones.closest_boundary(origin)
def legacy_package_waypoints_impl(self) -> None:
from gen.ato import PackageWaypoints
ingress_point = self._ingress_point(
self._target_heading_to_package_airfield())
egress_point = self._egress_point(
self._target_heading_to_package_airfield())
join_point = self._rendezvous_point(ingress_point) join_point = self._rendezvous_point(ingress_point)
split_point = self._rendezvous_point(egress_point) split_point = self._rendezvous_point(egress_point)
from gen.ato import PackageWaypoints
self.package.waypoints = PackageWaypoints( self.package.waypoints = PackageWaypoints(
join_point, join_point,
ingress_point, ingress_point,
@ -768,6 +875,14 @@ class FlightPlanBuilder:
split_point, split_point,
) )
def preferred_join_point(self) -> Optional[Point]:
path = self.game.navmesh_for(self.is_player).shortest_path(
self.package_airfield().position, self.package.target.position)
for point in reversed(path):
if not self.threat_zones.threatened(point):
return point
return None
def generate_strike(self, flight: Flight) -> StrikeFlightPlan: def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
"""Generates a strike flight plan. """Generates a strike flight plan.
@ -862,20 +977,25 @@ class FlightPlanBuilder:
if isinstance(location, FrontLine): if isinstance(location, FrontLine):
raise InvalidObjectiveLocation(flight.flight_type, location) raise InvalidObjectiveLocation(flight.flight_type, location)
start, end = self.racetrack_for_objective(location) start, end = self.racetrack_for_objective(location, barcap=True)
patrol_alt = random.randint( patrol_alt = meters(random.randint(
self.doctrine.min_patrol_altitude, int(self.doctrine.min_patrol_altitude.meters),
self.doctrine.max_patrol_altitude int(self.doctrine.max_patrol_altitude.meters)
) ))
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder = WaypointBuilder(flight, self.game, self.is_player)
start, end = builder.race_track(start, end, patrol_alt) start, end = builder.race_track(start, end, patrol_alt)
return BarCapFlightPlan( return BarCapFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
patrol_duration=self.doctrine.cap_duration, patrol_duration=self.doctrine.cap_duration,
engagement_distance=self.doctrine.cap_engagement_range,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(flight.departure.position, start.position,
patrol_alt),
nav_from=builder.nav_path(end.position, flight.arrival.position,
patrol_alt),
patrol_start=start, patrol_start=start,
patrol_end=end, patrol_end=end,
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
@ -888,33 +1008,40 @@ class FlightPlanBuilder:
Args: Args:
flight: The flight to generate the flight plan for. flight: The flight to generate the flight plan for.
""" """
assert self.package.waypoints is not None
target = self.package.target.position target = self.package.target.position
heading = self._heading_to_package_airfield(target) heading = self.package.waypoints.join.heading_between_point(target)
start = target.point_from_heading(heading, start = target.point_from_heading(heading,
-self.doctrine.sweep_distance) -self.doctrine.sweep_distance.meters)
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder = WaypointBuilder(flight, self.game, self.is_player)
start, end = builder.sweep(start, target, start, end = builder.sweep(start, target,
self.doctrine.ingress_altitude) self.doctrine.ingress_altitude)
hold = builder.hold(self._hold_point(flight))
return SweepFlightPlan( return SweepFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
lead_time=timedelta(minutes=5), lead_time=timedelta(minutes=5),
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
hold=builder.hold(self._hold_point(flight)), hold=hold,
hold_duration=timedelta(minutes=5), hold_duration=timedelta(minutes=5),
nav_to=builder.nav_path(hold.position, start.position,
self.doctrine.ingress_altitude),
nav_from=builder.nav_path(end.position, flight.arrival.position,
self.doctrine.ingress_altitude),
sweep_start=start, sweep_start=start,
sweep_end=end, sweep_end=end,
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert) divert=builder.divert(flight.divert)
) )
def racetrack_for_objective(self, def racetrack_for_objective(self, location: MissionTarget,
location: MissionTarget) -> Tuple[Point, Point]: barcap: bool) -> Tuple[Point, Point]:
closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) closest_cache = ObjectiveDistanceCache.get_closest_airfields(location)
for airfield in closest_cache.closest_airfields: for airfield in closest_cache.operational_airfields:
# If the mission is a BARCAP of an enemy airfield, find the *next* # If the mission is a BARCAP of an enemy airfield, find the *next*
# closest enemy airfield. # closest enemy airfield.
if airfield == self.package.target: if airfield == self.package.target:
@ -929,11 +1056,28 @@ class FlightPlanBuilder:
closest_airfield.position closest_airfield.position
) )
min_distance_from_enemy = nm_to_meter(20) position = ShapelyPoint(self.package.target.position.x,
distance_to_airfield = int(closest_airfield.position.distance_to_point( self.package.target.position.y)
if barcap:
# BARCAPs should remain far enough back from the enemy that their
# commit range does not enter the enemy's threat zone. Include a 5nm
# buffer.
distance_to_no_fly = meters(
position.distance(self.threat_zones.all)
) - self.doctrine.cap_engagement_range - nautical_miles(5)
else:
# Other race tracks (TARCAPs, currently) just try to keep some
# distance from the nearest enemy airbase, but since they are by
# definition in enemy territory they can't avoid the threat zone
# without being useless.
min_distance_from_enemy = nautical_miles(20)
distance_to_airfield = meters(
closest_airfield.position.distance_to_point(
self.package.target.position self.package.target.position
)) ))
distance_to_no_fly = distance_to_airfield - min_distance_from_enemy distance_to_no_fly = distance_to_airfield - min_distance_from_enemy
min_cap_distance = min(self.doctrine.cap_min_distance_from_cp, min_cap_distance = min(self.doctrine.cap_min_distance_from_cp,
distance_to_no_fly) distance_to_no_fly)
max_cap_distance = min(self.doctrine.cap_max_distance_from_cp, max_cap_distance = min(self.doctrine.cap_max_distance_from_cp,
@ -941,16 +1085,17 @@ class FlightPlanBuilder:
end = location.position.point_from_heading( end = location.position.point_from_heading(
heading, heading,
random.randint(min_cap_distance, max_cap_distance) random.randint(int(min_cap_distance.meters),
int(max_cap_distance.meters))
) )
diameter = random.randint( diameter = random.randint(
self.doctrine.cap_min_track_length, int(self.doctrine.cap_min_track_length.meters),
self.doctrine.cap_max_track_length int(self.doctrine.cap_max_track_length.meters)
) )
start = end.point_from_heading(heading - 180, diameter) start = end.point_from_heading(heading - 180, diameter)
return start, end return start, end
def racetrack_for_frontline(self, def racetrack_for_frontline(self, origin: Point,
front_line: FrontLine) -> Tuple[Point, Point]: front_line: FrontLine) -> Tuple[Point, Point]:
ally_cp, enemy_cp = front_line.control_points ally_cp, enemy_cp = front_line.control_points
@ -960,7 +1105,8 @@ class FlightPlanBuilder:
) )
center = ingress.point_from_heading(heading, distance / 2) center = ingress.point_from_heading(heading, distance / 2)
orbit_center = center.point_from_heading( orbit_center = center.point_from_heading(
heading - 90, random.randint(nm_to_meter(6), nm_to_meter(15)) heading - 90, random.randint(int(nautical_miles(6).meters),
int(nautical_miles(15).meters))
) )
combat_width = distance / 2 combat_width = distance / 2
@ -970,10 +1116,12 @@ class FlightPlanBuilder:
combat_width = 35000 combat_width = 35000
radius = combat_width * 1.25 radius = combat_width * 1.25
orbit0p = orbit_center.point_from_heading(heading, radius) start = orbit_center.point_from_heading(heading, radius)
orbit1p = orbit_center.point_from_heading(heading + 180, radius) end = orbit_center.point_from_heading(heading + 180, radius)
return orbit0p, orbit1p if end.distance_to_point(origin) < start.distance_to_point(origin):
start, end = end, start
return start, end
def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan:
"""Generate a CAP flight plan for the given front line. """Generate a CAP flight plan for the given front line.
@ -983,16 +1131,19 @@ class FlightPlanBuilder:
""" """
location = self.package.target location = self.package.target
patrol_alt = random.randint(self.doctrine.min_patrol_altitude, patrol_alt = meters(
self.doctrine.max_patrol_altitude) random.randint(int(self.doctrine.min_patrol_altitude.meters),
int(self.doctrine.max_patrol_altitude.meters)))
# Create points # Create points
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder = WaypointBuilder(flight, self.game, self.is_player)
if isinstance(location, FrontLine): if isinstance(location, FrontLine):
orbit0p, orbit1p = self.racetrack_for_frontline(location) orbit0p, orbit1p = self.racetrack_for_frontline(
flight.departure.position, location)
else: else:
orbit0p, orbit1p = self.racetrack_for_objective(location) orbit0p, orbit1p = self.racetrack_for_objective(location,
barcap=False)
start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) start, end = builder.race_track(orbit0p, orbit1p, patrol_alt)
return TarCapFlightPlan( return TarCapFlightPlan(
@ -1004,7 +1155,12 @@ class FlightPlanBuilder:
# requests an escort the CAP flight will remain on station for the # requests an escort the CAP flight will remain on station for the
# duration of the escorted mission, or until it is winchester/bingo. # duration of the escorted mission, or until it is winchester/bingo.
patrol_duration=self.doctrine.cap_duration, patrol_duration=self.doctrine.cap_duration,
engagement_distance=self.doctrine.cap_engagement_range,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(flight.departure.position, orbit0p,
patrol_alt),
nav_from=builder.nav_path(orbit1p, flight.arrival.position,
patrol_alt),
patrol_start=start, patrol_start=start,
patrol_end=end, patrol_end=end,
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
@ -1099,22 +1255,29 @@ class FlightPlanBuilder:
def generate_escort(self, flight: Flight) -> StrikeFlightPlan: def generate_escort(self, flight: Flight) -> StrikeFlightPlan:
assert self.package.waypoints is not None assert self.package.waypoints is not None
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder = WaypointBuilder(flight, self.game, self.is_player)
ingress, target, egress = builder.escort( ingress, target, egress = builder.escort(
self.package.waypoints.ingress, self.package.target, self.package.waypoints.ingress, self.package.target,
self.package.waypoints.egress) self.package.waypoints.egress)
hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join)
split = builder.split(self.package.waypoints.split)
return StrikeFlightPlan( return StrikeFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
hold=builder.hold(self._hold_point(flight)), hold=hold,
hold_duration=timedelta(minutes=5), hold_duration=timedelta(minutes=5),
join=builder.join(self.package.waypoints.join), nav_to=builder.nav_path(hold.position, join.position,
self.doctrine.ingress_altitude),
join=join,
ingress=ingress, ingress=ingress,
targets=[target], targets=[target],
egress=egress, egress=egress,
split=builder.split(self.package.waypoints.split), split=split,
nav_from=builder.nav_path(split.position, flight.arrival.position,
self.doctrine.ingress_altitude),
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert) divert=builder.divert(flight.divert)
) )
@ -1137,15 +1300,25 @@ class FlightPlanBuilder:
center = ingress.point_from_heading(heading, distance / 2) center = ingress.point_from_heading(heading, distance / 2)
egress = ingress.point_from_heading(heading, distance) egress = ingress.point_from_heading(heading, distance)
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) ingress_distance = ingress.distance_to_point(flight.departure.position)
egress_distance = egress.distance_to_point(flight.departure.position)
if egress_distance < ingress_distance:
ingress, egress = egress, ingress
builder = WaypointBuilder(flight, self.game, self.is_player)
return CasFlightPlan( return CasFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
patrol_duration=self.doctrine.cas_duration, patrol_duration=self.doctrine.cas_duration,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
nav_to=builder.nav_path(flight.departure.position, ingress,
self.doctrine.ingress_altitude),
nav_from=builder.nav_path(egress, flight.arrival.position,
self.doctrine.ingress_altitude),
patrol_start=builder.ingress(FlightWaypointType.INGRESS_CAS, patrol_start=builder.ingress(FlightWaypointType.INGRESS_CAS,
ingress, location), ingress, location),
engagement_distance=meters(FRONTLINE_LENGTH) / 2,
target=builder.cas(center), target=builder.cas(center),
patrol_end=builder.egress(egress, location), patrol_end=builder.egress(egress, location),
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
@ -1188,12 +1361,13 @@ class FlightPlanBuilder:
# point, plan the hold point such that it retreats from the origin # point, plan the hold point such that it retreats from the origin
# airfield. # airfield.
return join.point_from_heading(target.heading_between_point(origin), return join.point_from_heading(target.heading_between_point(origin),
self.doctrine.push_distance) self.doctrine.push_distance.meters)
heading_to_join = origin.heading_between_point(join) heading_to_join = origin.heading_between_point(join)
hold_point = origin.point_from_heading(heading_to_join, hold_point = origin.point_from_heading(
self.doctrine.push_distance) heading_to_join, self.doctrine.push_distance.meters)
if hold_point.distance_to_point(join) >= self.doctrine.push_distance: hold_distance = meters(hold_point.distance_to_point(join))
if hold_distance >= self.doctrine.push_distance:
# Hold point is between the origin airfield and the join point and # Hold point is between the origin airfield and the join point and
# spaced sufficiently. # spaced sufficiently.
return hold_point return hold_point
@ -1205,10 +1379,10 @@ class FlightPlanBuilder:
# properly. # properly.
origin_to_join = origin.distance_to_point(join) origin_to_join = origin.distance_to_point(join)
cos_theta = ( cos_theta = (
(self.doctrine.hold_distance ** 2 + (self.doctrine.hold_distance.meters ** 2 +
origin_to_join ** 2 - origin_to_join ** 2 -
self.doctrine.join_distance ** 2) / self.doctrine.join_distance.meters ** 2) /
(2 * self.doctrine.hold_distance * origin_to_join) (2 * self.doctrine.hold_distance.meters * origin_to_join)
) )
try: try:
theta = math.acos(cos_theta) theta = math.acos(cos_theta)
@ -1217,10 +1391,10 @@ class FlightPlanBuilder:
# hold point away from the target. # hold point away from the target.
return origin.point_from_heading( return origin.point_from_heading(
target.heading_between_point(origin), target.heading_between_point(origin),
self.doctrine.hold_distance) self.doctrine.hold_distance.meters)
return origin.point_from_heading(heading_to_join - theta, return origin.point_from_heading(heading_to_join - theta,
self.doctrine.hold_distance) self.doctrine.hold_distance.meters)
# TODO: Make a model for the waypoint builder and use that in the UI. # TODO: Make a model for the waypoint builder and use that in the UI.
def generate_rtb_waypoint(self, flight: Flight, def generate_rtb_waypoint(self, flight: Flight,
@ -1231,7 +1405,7 @@ class FlightPlanBuilder:
flight: The flight to generate the landing waypoint for. flight: The flight to generate the landing waypoint for.
arrival: Arrival airfield or carrier. arrival: Arrival airfield or carrier.
""" """
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) builder = WaypointBuilder(flight, self.game, self.is_player)
return builder.land(arrival) return builder.land(arrival)
def strike_flightplan( def strike_flightplan(
@ -1239,8 +1413,7 @@ class FlightPlanBuilder:
ingress_type: FlightWaypointType, ingress_type: FlightWaypointType,
targets: Optional[List[StrikeTarget]] = None) -> StrikeFlightPlan: targets: Optional[List[StrikeTarget]] = None) -> StrikeFlightPlan:
assert self.package.waypoints is not None assert self.package.waypoints is not None
builder = WaypointBuilder(self.game.conditions, flight, self.doctrine, builder = WaypointBuilder(flight, self.game, self.is_player, targets)
targets)
target_waypoints: List[FlightWaypoint] = [] target_waypoints: List[FlightWaypoint] = []
if targets is not None: if targets is not None:
@ -1251,18 +1424,26 @@ class FlightPlanBuilder:
target_waypoints.append( target_waypoints.append(
self.target_area_waypoint(flight, location, builder)) self.target_area_waypoint(flight, location, builder))
hold = builder.hold(self._hold_point(flight))
join = builder.join(self.package.waypoints.join)
split = builder.split(self.package.waypoints.split)
return StrikeFlightPlan( return StrikeFlightPlan(
package=self.package, package=self.package,
flight=flight, flight=flight,
takeoff=builder.takeoff(flight.departure), takeoff=builder.takeoff(flight.departure),
hold=builder.hold(self._hold_point(flight)), hold=hold,
hold_duration=timedelta(minutes=5), hold_duration=timedelta(minutes=5),
join=builder.join(self.package.waypoints.join), nav_to=builder.nav_path(hold.position, join.position,
self.doctrine.ingress_altitude),
join=join,
ingress=builder.ingress(ingress_type, ingress=builder.ingress(ingress_type,
self.package.waypoints.ingress, location), self.package.waypoints.ingress, location),
targets=target_waypoints, targets=target_waypoints,
egress=builder.egress(self.package.waypoints.egress, location), egress=builder.egress(self.package.waypoints.egress, location),
split=builder.split(self.package.waypoints.split), split=split,
nav_from=builder.nav_path(split.position, flight.arrival.position,
self.doctrine.ingress_altitude),
land=builder.land(flight.arrival), land=builder.land(flight.arrival),
divert=builder.divert(flight.divert) divert=builder.divert(flight.divert)
) )
@ -1272,13 +1453,13 @@ class FlightPlanBuilder:
return attack_transition.point_from_heading( return attack_transition.point_from_heading(
self.package.target.position.heading_between_point( self.package.target.position.heading_between_point(
self.package_airfield().position), self.package_airfield().position),
self.doctrine.join_distance) self.doctrine.join_distance.meters)
def _advancing_rendezvous_point(self, attack_transition: Point) -> Point: def _advancing_rendezvous_point(self, attack_transition: Point) -> Point:
"""Creates a rendezvous point that advances toward the target.""" """Creates a rendezvous point that advances toward the target."""
heading = self._heading_to_package_airfield(attack_transition) heading = self._heading_to_package_airfield(attack_transition)
return attack_transition.point_from_heading( return attack_transition.point_from_heading(
heading, -self.doctrine.join_distance) heading, -self.doctrine.join_distance.meters)
def _rendezvous_should_retreat(self, attack_transition: Point) -> bool: def _rendezvous_should_retreat(self, attack_transition: Point) -> bool:
transition_target_distance = attack_transition.distance_to_point( transition_target_distance = attack_transition.distance_to_point(
@ -1303,16 +1484,14 @@ class FlightPlanBuilder:
return self._retreating_rendezvous_point(attack_transition) return self._retreating_rendezvous_point(attack_transition)
return self._advancing_rendezvous_point(attack_transition) return self._advancing_rendezvous_point(attack_transition)
def _ingress_point(self) -> Point: def _ingress_point(self, heading: int) -> Point:
heading = self._target_heading_to_package_airfield()
return self.package.target.position.point_from_heading( return self.package.target.position.point_from_heading(
heading - 180 + 25, self.doctrine.ingress_egress_distance heading - 180 + 15, self.doctrine.ingress_egress_distance.meters
) )
def _egress_point(self) -> Point: def _egress_point(self, heading: int) -> Point:
heading = self._target_heading_to_package_airfield()
return self.package.target.position.point_from_heading( return self.package.target.position.point_from_heading(
heading - 180 - 25, self.doctrine.ingress_egress_distance heading - 180 - 15, self.doctrine.ingress_egress_distance.meters
) )
def _target_heading_to_package_airfield(self) -> int: def _target_heading_to_package_airfield(self) -> int:
@ -1338,7 +1517,7 @@ class FlightPlanBuilder:
cache = ObjectiveDistanceCache.get_closest_airfields( cache = ObjectiveDistanceCache.get_closest_airfields(
self.package.target self.package.target
) )
for airfield in cache.closest_airfields: for airfield in cache.operational_airfields:
for flight in self.package.flights: for flight in self.package.flights:
if flight.departure == airfield: if flight.departure == airfield:
return airfield return airfield

View File

@ -3,12 +3,19 @@ from __future__ import annotations
import logging import logging
import math import math
from datetime import timedelta from datetime import timedelta
from typing import Optional, TYPE_CHECKING from typing import TYPE_CHECKING
from dcs.mapping import Point from dcs.mapping import Point
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from game.utils import meter_to_nm from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
kph,
mach,
meters,
)
from gen.flights.flight import Flight from gen.flights.flight import Flight
if TYPE_CHECKING: if TYPE_CHECKING:
@ -18,7 +25,7 @@ if TYPE_CHECKING:
class GroundSpeed: class GroundSpeed:
@classmethod @classmethod
def for_flight(cls, flight: Flight, altitude: int) -> int: def for_flight(cls, flight: Flight, altitude: Distance) -> Speed:
if not issubclass(flight.unit_type, FlyingType): if not issubclass(flight.unit_type, FlyingType):
raise TypeError("Flight has non-flying unit") raise TypeError("Flight has non-flying unit")
@ -27,64 +34,29 @@ class GroundSpeed:
# on fuel, but mission speed will be fast enough to keep the flight # on fuel, but mission speed will be fast enough to keep the flight
# safer. # safer.
c_sound_sea_level = 661.5 # DCS's max speed is in kph at 0 MSL.
max_speed = kph(flight.unit_type.max_speed)
# DCS's max speed is in kph at 0 MSL. Convert to knots. if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL:
max_speed = flight.unit_type.max_speed * 0.539957
if max_speed > c_sound_sea_level:
# Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and # Aircraft is supersonic. Limit to mach 0.8 to conserve fuel and
# account for heavily loaded jets. # account for heavily loaded jets.
return int(cls.from_mach(0.8, altitude)) return mach(0.8, altitude)
# For subsonic aircraft, assume the aircraft can reasonably perform at # For subsonic aircraft, assume the aircraft can reasonably perform at
# 80% of its maximum, and that it can maintain the same mach at altitude # 80% of its maximum, and that it can maintain the same mach at altitude
# as it can at sea level. This probably isn't great assumption, but # as it can at sea level. This probably isn't great assumption, but
# might. be sufficient given the wiggle room. We can come up with # might. be sufficient given the wiggle room. We can come up with
# another heuristic if needed. # another heuristic if needed.
mach = max_speed * 0.8 / c_sound_sea_level cruise_mach = max_speed.mach() * 0.8
return int(cls.from_mach(mach, altitude)) # knots return mach(cruise_mach, altitude)
@staticmethod
def from_mach(mach: float, altitude_m: int) -> float:
"""Returns the ground speed in knots for the given mach and altitude.
Args:
mach: The mach number to convert to ground speed.
altitude_m: The altitude in meters.
Returns:
The ground speed corresponding to the given altitude and mach number
in knots.
"""
# https://www.grc.nasa.gov/WWW/K-12/airplane/atmos.html
altitude_ft = altitude_m * 3.28084
if altitude_ft <= 36152:
temperature_f = 59 - 0.00356 * altitude_ft
else:
# There's another formula for altitudes over 82k feet, but we better
# not be planning waypoints that high...
temperature_f = -70
temperature_k = (temperature_f + 459.67) * (5 / 9)
# https://www.engineeringtoolbox.com/specific-heat-ratio-d_602.html
# Dependent on temperature, but varies very little (+/-0.001)
# between -40F and 180F.
heat_capacity_ratio = 1.4
# https://www.grc.nasa.gov/WWW/K-12/airplane/sound.html
gas_constant = 286 # m^2/s^2/K
c_sound = math.sqrt(heat_capacity_ratio * gas_constant * temperature_k)
# c_sound is in m/s, convert to knots.
return (c_sound * 1.944) * mach
class TravelTime: class TravelTime:
@staticmethod @staticmethod
def between_points(a: Point, b: Point, speed: float) -> timedelta: def between_points(a: Point, b: Point, speed: Speed) -> timedelta:
error_factor = 1.1 error_factor = 1.1
distance = meter_to_nm(a.distance_to_point(b)) distance = meters(a.distance_to_point(b))
return timedelta(hours=distance / speed * error_factor) return timedelta(
hours=distance.nautical_miles / speed.knots * error_factor)
# TODO: Most if not all of this should move into FlightPlan. # TODO: Most if not all of this should move into FlightPlan.

View File

@ -1,20 +1,31 @@
from __future__ import annotations from __future__ import annotations
import random
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Tuple, Union from typing import (
Iterable,
Iterator,
List,
Optional,
TYPE_CHECKING,
Tuple,
Union,
)
from dcs.mapping import Point from dcs.mapping import Point
from dcs.unit import Unit from dcs.unit import Unit
from dcs.unitgroup import VehicleGroup from dcs.unitgroup import VehicleGroup
from game.data.doctrine import Doctrine if TYPE_CHECKING:
from game import Game
from game.theater import ( from game.theater import (
ControlPoint, ControlPoint,
MissionTarget, MissionTarget,
OffMapSpawn, OffMapSpawn,
TheaterGroundObject, TheaterGroundObject,
) )
from game.weather import Conditions from game.utils import Distance, meters, nautical_miles
from .flight import Flight, FlightWaypoint, FlightWaypointType from .flight import Flight, FlightWaypoint, FlightWaypointType
@ -25,12 +36,13 @@ class StrikeTarget:
class WaypointBuilder: class WaypointBuilder:
def __init__(self, conditions: Conditions, flight: Flight, def __init__(self, flight: Flight, game: Game, player: bool,
doctrine: Doctrine,
targets: Optional[List[StrikeTarget]] = None) -> None: targets: Optional[List[StrikeTarget]] = None) -> None:
self.conditions = conditions
self.flight = flight self.flight = flight
self.doctrine = doctrine self.conditions = game.conditions
self.doctrine = game.faction_for(player).doctrine
self.threat_zones = game.threat_zone_for(not player)
self.navmesh = game.navmesh_for(player)
self.targets = targets self.targets = targets
@property @property
@ -53,7 +65,9 @@ class WaypointBuilder:
FlightWaypointType.NAV, FlightWaypointType.NAV,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.rendezvous_altitude meters(
500
) if self.is_helo else self.doctrine.rendezvous_altitude
) )
waypoint.name = "NAV" waypoint.name = "NAV"
waypoint.alt_type = "BARO" waypoint.alt_type = "BARO"
@ -64,7 +78,7 @@ class WaypointBuilder:
FlightWaypointType.TAKEOFF, FlightWaypointType.TAKEOFF,
position.x, position.x,
position.y, position.y,
0 meters(0)
) )
waypoint.name = "TAKEOFF" waypoint.name = "TAKEOFF"
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
@ -84,7 +98,9 @@ class WaypointBuilder:
FlightWaypointType.NAV, FlightWaypointType.NAV,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.rendezvous_altitude meters(
500
) if self.is_helo else self.doctrine.rendezvous_altitude
) )
waypoint.name = "NAV" waypoint.name = "NAV"
waypoint.alt_type = "BARO" waypoint.alt_type = "BARO"
@ -95,7 +111,7 @@ class WaypointBuilder:
FlightWaypointType.LANDING_POINT, FlightWaypointType.LANDING_POINT,
position.x, position.x,
position.y, position.y,
0 meters(0)
) )
waypoint.name = "LANDING" waypoint.name = "LANDING"
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
@ -116,12 +132,12 @@ class WaypointBuilder:
position = divert.position position = divert.position
if isinstance(divert, OffMapSpawn): if isinstance(divert, OffMapSpawn):
if self.is_helo: if self.is_helo:
altitude = 500 altitude = meters(500)
else: else:
altitude = self.doctrine.rendezvous_altitude altitude = self.doctrine.rendezvous_altitude
altitude_type = "BARO" altitude_type = "BARO"
else: else:
altitude = 0 altitude = meters(0)
altitude_type = "RADIO" altitude_type = "RADIO"
waypoint = FlightWaypoint( waypoint = FlightWaypoint(
@ -142,7 +158,9 @@ class WaypointBuilder:
FlightWaypointType.LOITER, FlightWaypointType.LOITER,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.rendezvous_altitude meters(
500
) if self.is_helo else self.doctrine.rendezvous_altitude
) )
waypoint.pretty_name = "Hold" waypoint.pretty_name = "Hold"
waypoint.description = "Wait until push time" waypoint.description = "Wait until push time"
@ -154,7 +172,9 @@ class WaypointBuilder:
FlightWaypointType.JOIN, FlightWaypointType.JOIN,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.ingress_altitude meters(
500
) if self.is_helo else self.doctrine.ingress_altitude
) )
waypoint.pretty_name = "Join" waypoint.pretty_name = "Join"
waypoint.description = "Rendezvous with package" waypoint.description = "Rendezvous with package"
@ -166,7 +186,9 @@ class WaypointBuilder:
FlightWaypointType.SPLIT, FlightWaypointType.SPLIT,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.ingress_altitude meters(
500
) if self.is_helo else self.doctrine.ingress_altitude
) )
waypoint.pretty_name = "Split" waypoint.pretty_name = "Split"
waypoint.description = "Depart from package" waypoint.description = "Depart from package"
@ -179,7 +201,9 @@ class WaypointBuilder:
ingress_type, ingress_type,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.ingress_altitude meters(
500
) if self.is_helo else self.doctrine.ingress_altitude
) )
waypoint.pretty_name = "INGRESS on " + objective.name waypoint.pretty_name = "INGRESS on " + objective.name
waypoint.description = "INGRESS on " + objective.name waypoint.description = "INGRESS on " + objective.name
@ -193,7 +217,9 @@ class WaypointBuilder:
FlightWaypointType.EGRESS, FlightWaypointType.EGRESS,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else self.doctrine.ingress_altitude meters(
500
) if self.is_helo else self.doctrine.ingress_altitude
) )
waypoint.pretty_name = "EGRESS from " + target.name waypoint.pretty_name = "EGRESS from " + target.name
waypoint.description = "EGRESS from " + target.name waypoint.description = "EGRESS from " + target.name
@ -218,7 +244,7 @@ class WaypointBuilder:
FlightWaypointType.TARGET_POINT, FlightWaypointType.TARGET_POINT,
target.target.position.x, target.target.position.x,
target.target.position.y, target.target.position.y,
0 meters(0)
) )
waypoint.description = description waypoint.description = description
waypoint.pretty_name = description waypoint.pretty_name = description
@ -249,7 +275,7 @@ class WaypointBuilder:
FlightWaypointType.TARGET_GROUP_LOC, FlightWaypointType.TARGET_GROUP_LOC,
location.position.x, location.position.x,
location.position.y, location.position.y,
0 meters(0)
) )
waypoint.description = name waypoint.description = name
waypoint.pretty_name = name waypoint.pretty_name = name
@ -274,7 +300,7 @@ class WaypointBuilder:
FlightWaypointType.CAS, FlightWaypointType.CAS,
position.x, position.x,
position.y, position.y,
500 if self.is_helo else 1000 meters(500) if self.is_helo else meters(1000)
) )
waypoint.alt_type = "RADIO" waypoint.alt_type = "RADIO"
waypoint.description = "Provide CAS" waypoint.description = "Provide CAS"
@ -283,12 +309,12 @@ class WaypointBuilder:
return waypoint return waypoint
@staticmethod @staticmethod
def race_track_start(position: Point, altitude: int) -> FlightWaypoint: def race_track_start(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a racetrack start waypoint. """Creates a racetrack start waypoint.
Args: Args:
position: Position of the waypoint. position: Position of the waypoint.
altitude: Altitude of the racetrack in meters. altitude: Altitude of the racetrack.
""" """
waypoint = FlightWaypoint( waypoint = FlightWaypoint(
FlightWaypointType.PATROL_TRACK, FlightWaypointType.PATROL_TRACK,
@ -302,12 +328,12 @@ class WaypointBuilder:
return waypoint return waypoint
@staticmethod @staticmethod
def race_track_end(position: Point, altitude: int) -> FlightWaypoint: def race_track_end(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a racetrack end waypoint. """Creates a racetrack end waypoint.
Args: Args:
position: Position of the waypoint. position: Position of the waypoint.
altitude: Altitude of the racetrack in meters. altitude: Altitude of the racetrack.
""" """
waypoint = FlightWaypoint( waypoint = FlightWaypoint(
FlightWaypointType.PATROL, FlightWaypointType.PATROL,
@ -321,7 +347,7 @@ class WaypointBuilder:
return waypoint return waypoint
def race_track(self, start: Point, end: Point, def race_track(self, start: Point, end: Point,
altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: altitude: Distance) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates two waypoint for a racetrack orbit. """Creates two waypoint for a racetrack orbit.
Args: Args:
@ -333,7 +359,7 @@ class WaypointBuilder:
self.race_track_end(end, altitude)) self.race_track_end(end, altitude))
@staticmethod @staticmethod
def sweep_start(position: Point, altitude: int) -> FlightWaypoint: def sweep_start(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a sweep start waypoint. """Creates a sweep start waypoint.
Args: Args:
@ -352,7 +378,7 @@ class WaypointBuilder:
return waypoint return waypoint
@staticmethod @staticmethod
def sweep_end(position: Point, altitude: int) -> FlightWaypoint: def sweep_end(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a sweep end waypoint. """Creates a sweep end waypoint.
Args: Args:
@ -371,7 +397,7 @@ class WaypointBuilder:
return waypoint return waypoint
def sweep(self, start: Point, end: Point, def sweep(self, start: Point, end: Point,
altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: altitude: Distance) -> Tuple[FlightWaypoint, FlightWaypoint]:
"""Creates two waypoint for a racetrack orbit. """Creates two waypoint for a racetrack orbit.
Args: Args:
@ -404,7 +430,9 @@ class WaypointBuilder:
FlightWaypointType.TARGET_GROUP_LOC, FlightWaypointType.TARGET_GROUP_LOC,
target.position.x, target.position.x,
target.position.y, target.position.y,
500 if self.is_helo else self.doctrine.ingress_altitude meters(
500
) if self.is_helo else self.doctrine.ingress_altitude
) )
waypoint.name = "TARGET" waypoint.name = "TARGET"
waypoint.description = "Escort the package" waypoint.description = "Escort the package"
@ -412,3 +440,80 @@ class WaypointBuilder:
egress = self.egress(egress, target) egress = self.egress(egress, target)
return ingress, waypoint, egress return ingress, waypoint, egress
@staticmethod
def nav(position: Point, altitude: Distance) -> FlightWaypoint:
"""Creates a navigation point.
Args:
position: Position of the waypoint.
altitude: Altitude of the waypoint.
"""
waypoint = FlightWaypoint(
FlightWaypointType.NAV,
position.x,
position.y,
altitude
)
waypoint.name = "NAV"
waypoint.description = "NAV"
waypoint.pretty_name = "Nav"
return waypoint
def nav_path(self, a: Point, b: Point,
altitude: Distance) -> List[FlightWaypoint]:
path = self.clean_nav_points(self.navmesh.shortest_path(a, b))
return [self.nav(self.perturb(p), altitude) for p in path]
def clean_nav_points(self, points: Iterable[Point]) -> Iterator[Point]:
# Examine a sliding window of three waypoints. `current` is the waypoint
# being checked for prunability. `previous` is the last emitted waypoint
# before `current`. `nxt` is the waypoint after `current`.
previous: Optional[Point] = None
current: Optional[Point] = None
for nxt in points:
if current is None:
current = nxt
continue
if previous is None:
previous = current
current = nxt
continue
if self.nav_point_prunable(previous, current, nxt):
current = nxt
continue
yield current
previous = current
current = nxt
def nav_point_prunable(self, previous: Point, current: Point,
nxt: Point) -> bool:
previous_threatened = self.threat_zones.path_threatened(previous,
current)
next_threatened = self.threat_zones.path_threatened(current, nxt)
pruned_threatened = self.threat_zones.path_threatened(previous, nxt)
previous_distance = meters(previous.distance_to_point(current))
distance = meters(current.distance_to_point(nxt))
distance_without = previous_distance + distance
if distance > distance_without:
# Don't prune paths to make them longer.
return False
# We could shorten the path by removing the intermediate
# waypoint. Do so if the new path isn't higher threat.
if not pruned_threatened:
# The new path is not threatened, so safe to prune.
return True
# The new path is threatened. Only allow if both paths were
# threatened anyway.
return previous_threatened and next_threatened
@staticmethod
def perturb(point: Point) -> Point:
deviation = nautical_miles(1)
x_adj = random.randint(int(-deviation.meters), int(deviation.meters))
y_adj = random.randint(int(-deviation.meters), int(deviation.meters))
return Point(point.x + x_adj, point.y + y_adj)

View File

@ -35,7 +35,7 @@ from game.theater.theatergroundobject import (
LhaGroundObject, ShipGroundObject, MissileSiteGroundObject, LhaGroundObject, ShipGroundObject, MissileSiteGroundObject,
) )
from game.unitmap import UnitMap from game.unitmap import UnitMap
from game.utils import knots_to_kph, kph_to_mps, mps_to_kph from game.utils import knots, mps
from .radios import RadioFrequency, RadioRegistry from .radios import RadioFrequency, RadioRegistry
from .runways import RunwayData from .runways import RunwayData
from .tacan import TacanBand, TacanChannel, TacanRegistry from .tacan import TacanBand, TacanChannel, TacanRegistry
@ -300,13 +300,13 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator):
wind = self.game.conditions.weather.wind.at_0m wind = self.game.conditions.weather.wind.at_0m
brc = wind.direction + 180 brc = wind.direction + 180
# Aim for 25kts over the deck. # Aim for 25kts over the deck.
carrier_speed = knots_to_kph(25) - mps_to_kph(wind.speed) carrier_speed = knots(25) - mps(wind.speed)
for attempt in range(5): for attempt in range(5):
point = group.points[0].position.point_from_heading( point = group.points[0].position.point_from_heading(
brc, 100000 - attempt * 20000) brc, 100000 - attempt * 20000)
if self.game.theater.is_in_sea(point): if self.game.theater.is_in_sea(point):
group.points[0].speed = kph_to_mps(carrier_speed) group.points[0].speed = carrier_speed.meters_per_second
group.add_waypoint(point, carrier_speed) group.add_waypoint(point, carrier_speed.kph)
return brc return brc
return None return None

View File

@ -33,8 +33,7 @@ from dcs.mission import Mission
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from tabulate import tabulate from tabulate import tabulate
from game.utils import meter_to_nm from game.utils import meters
from . import units
from .aircraft import AIRCRAFT_DATA, FlightData from .aircraft import AIRCRAFT_DATA, FlightData
from .airsupportgen import AwacsInfo, TankerInfo from .airsupportgen import AwacsInfo, TankerInfo
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
@ -95,6 +94,23 @@ class KneeboardPageWriter:
def write(self, path: Path) -> None: def write(self, path: Path) -> None:
self.image.save(path) self.image.save(path)
@staticmethod
def wrap_line(inputstr: str, max_length: int) -> str:
if len(inputstr) <= max_length:
return inputstr
tokens = inputstr.split(" ")
output = ""
segments = []
for token in tokens:
combo = output + " " + token
if len(combo) > max_length:
combo = output + "\n" + token
segments.append(combo)
output = ""
else:
output = combo
return "".join(segments + [output]).strip()
class KneeboardPage: class KneeboardPage:
"""Base class for all kneeboard pages.""" """Base class for all kneeboard pages."""
@ -111,6 +127,9 @@ class NumberedWaypoint:
class FlightPlanBuilder: class FlightPlanBuilder:
WAYPOINT_DESC_MAX_LEN = 25
def __init__(self, start_time: datetime.datetime) -> None: def __init__(self, start_time: datetime.datetime) -> None:
self.start_time = start_time self.start_time = start_time
self.rows: List[List[str]] = [] self.rows: List[List[str]] = []
@ -152,8 +171,10 @@ class FlightPlanBuilder:
def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None: def add_waypoint_row(self, waypoint: NumberedWaypoint) -> None:
self.rows.append([ self.rows.append([
str(waypoint.number), str(waypoint.number),
KneeboardPageWriter.wrap_line(
waypoint.waypoint.pretty_name, waypoint.waypoint.pretty_name,
str(int(units.meters_to_feet(waypoint.waypoint.alt))), FlightPlanBuilder.WAYPOINT_DESC_MAX_LEN),
str(int(waypoint.waypoint.alt.feet)),
self._waypoint_distance(waypoint.waypoint), self._waypoint_distance(waypoint.waypoint),
self._ground_speed(waypoint.waypoint), self._ground_speed(waypoint.waypoint),
self._format_time(waypoint.waypoint.tot), self._format_time(waypoint.waypoint.tot),
@ -170,10 +191,10 @@ class FlightPlanBuilder:
if self.last_waypoint is None: if self.last_waypoint is None:
return "-" return "-"
distance = meter_to_nm(self.last_waypoint.position.distance_to_point( distance = meters(self.last_waypoint.position.distance_to_point(
waypoint.position waypoint.position
)) ))
return f"{distance} NM" return f"{distance.nautical_miles:.1f} NM"
def _ground_speed(self, waypoint: FlightWaypoint) -> str: def _ground_speed(self, waypoint: FlightWaypoint) -> str:
if self.last_waypoint is None: if self.last_waypoint is None:
@ -189,19 +210,11 @@ class FlightPlanBuilder:
else: else:
return "-" return "-"
distance = meter_to_nm(self.last_waypoint.position.distance_to_point( distance = meters(self.last_waypoint.position.distance_to_point(
waypoint.position waypoint.position
)) ))
duration = (waypoint.tot - last_time).total_seconds() / 3600 duration = (waypoint.tot - last_time).total_seconds() / 3600
try: return f"{int(distance.nautical_miles / duration)} kt"
return f"{int(distance / duration)} kt"
except ZeroDivisionError:
# TODO: Improve resolution of unit conversions.
# When waypoints are very close to each other they can end up with
# identical TOTs because our unit conversion functions truncate to
# int. When waypoints have the same TOT the duration will be zero.
# https://github.com/Khopa/dcs_liberation/issues/557
return "-"
def build(self) -> List[List[str]]: def build(self) -> List[List[str]]:
return self.rows return self.rows
@ -268,10 +281,8 @@ class BriefingPage(KneeboardPage):
self.format_frequency(tanker.freq), self.format_frequency(tanker.freq),
]) ])
writer.table(comm_ladder, headers=["Callsign","Task", "Type", "TACAN", "FREQ"]) writer.table(comm_ladder, headers=["Callsign","Task", "Type", "TACAN", "FREQ"])
writer.heading("JTAC") writer.heading("JTAC")
jtacs = [] jtacs = []
for jtac in self.jtacs: for jtac in self.jtacs:

View File

@ -1,16 +1,21 @@
from game import db
import random import random
import time
from typing import List
ALPHA_MILITARY = ["Alpha","Bravo","Charlie","Delta","Echo","Foxtrot", from dcs.country import Country
"Golf","Hotel","India","Juliet","Kilo","Lima","Mike", from dcs.unittype import UnitType
"November","Oscar","Papa","Quebec","Romeo","Sierra",
"Tango","Uniform","Victor","Whisky","XRay","Yankee",
"Zulu","Zero"]
class NameGenerator: from game import db
number = 0
ANIMALS = [ from gen.flights.flight import Flight
ALPHA_MILITARY = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike",
"November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra",
"Tango", "Uniform", "Victor", "Whisky", "XRay", "Yankee",
"Zulu", "Zero"]
ANIMALS = [
"SHARK", "TORTOISE", "BAT", "PANGOLIN", "AARDWOLF", "SHARK", "TORTOISE", "BAT", "PANGOLIN", "AARDWOLF",
"MONKEY", "BUFFALO", "DOG", "BOBCAT", "LYNX", "PANTHER", "TIGER", "MONKEY", "BUFFALO", "DOG", "BOBCAT", "LYNX", "PANTHER", "TIGER",
"LION", "OWL", "BUTTERFLY", "BISON", "DUCK", "COBRA", "MAMBA", "LION", "OWL", "BUTTERFLY", "BISON", "DUCK", "COBRA", "MAMBA",
@ -38,47 +43,92 @@ class NameGenerator:
"ANACONDA" "ANACONDA"
] ]
def __init__(self): class NameGenerator:
self.number = 0 number = 0
self.ANIMALS = NameGenerator.ANIMALS.copy() infantry_number = 0
aircraft_number = 0
def reset(self): ANIMALS = ANIMALS
self.number = 0 existing_alphas: List[str] = []
self.ANIMALS = NameGenerator.ANIMALS.copy()
def next_unit_name(self, country, parent_base_id, unit_type): @classmethod
self.number += 1 def reset(cls):
return "unit|{}|{}|{}|{}|".format(country.id, self.number, parent_base_id, db.unit_type_name(unit_type)) cls.number = 0
cls.infantry_number = 0
cls.ANIMALS = ANIMALS
cls.existing_alphas = []
def next_infantry_name(self, country, parent_base_id, unit_type): @classmethod
self.number += 1 def reset_numbers(cls):
return "infantry|{}|{}|{}|{}|".format(country.id, self.number, parent_base_id, db.unit_type_name(unit_type)) cls.number = 0
cls.infantry_number = 0
cls.aircraft_number = 0
def next_basedefense_name(self): @classmethod
def next_aircraft_name(cls, country: Country, parent_base_id: int, flight: Flight):
cls.aircraft_number += 1
try:
if flight.custom_name:
name_str = flight.custom_name
else:
name_str = "{} {}".format(
flight.package.target.name, flight.flight_type)
except AttributeError: # Here to maintain save compatibility with 2.3
name_str = "{} {}".format(
flight.package.target.name, flight.flight_type)
return "{}|{}|{}|{}|{}|".format(name_str, country.id, cls.aircraft_number, parent_base_id, db.unit_type_name(flight.unit_type))
@classmethod
def next_unit_name(cls, country: Country, parent_base_id: int, unit_type: UnitType):
cls.number += 1
return "unit|{}|{}|{}|{}|".format(country.id, cls.number, parent_base_id, db.unit_type_name(unit_type))
@classmethod
def next_infantry_name(cls, country: Country, parent_base_id: int, unit_type: UnitType):
cls.infantry_number += 1
return "infantry|{}|{}|{}|{}|".format(country.id, cls.infantry_number, parent_base_id, db.unit_type_name(unit_type))
@staticmethod
def next_basedefense_name():
return "basedefense_aa|0|0|" return "basedefense_aa|0|0|"
def next_awacs_name(self, country): @classmethod
self.number += 1 def next_awacs_name(cls, country: Country):
return "awacs|{}|{}|0|".format(country.id, self.number) cls.number += 1
return "awacs|{}|{}|0|".format(country.id, cls.number)
def next_tanker_name(self, country, unit_type): @classmethod
self.number += 1 def next_tanker_name(cls, country: Country, unit_type: UnitType):
return "tanker|{}|{}|0|{}".format(country.id, self.number, db.unit_type_name(unit_type)) cls.number += 1
return "tanker|{}|{}|0|{}".format(country.id, cls.number, db.unit_type_name(unit_type))
def next_carrier_name(self, country): @classmethod
self.number += 1 def next_carrier_name(cls, country: Country):
return "carrier|{}|{}|0|".format(country.id, self.number) cls.number += 1
return "carrier|{}|{}|0|".format(country.id, cls.number)
def random_objective_name(self): @classmethod
if len(self.ANIMALS) == 0: def random_objective_name(cls):
return random.choice(ALPHA_MILITARY).upper() + "#" + str(random.randint(0, 100)) if len(cls.ANIMALS) == 0:
for i in range(10):
new_name_generated = True
alpha_mil_name = random.choice(ALPHA_MILITARY).upper() + "#" + str(random.randint(0, 100))
for existing_name in cls.existing_alphas:
if existing_name == alpha_mil_name:
new_name_generated = False
if new_name_generated:
cls.existing_alphas.append(alpha_mil_name)
return alpha_mil_name
# At this point, give up trying - something has gone wrong and we haven't been able to make a new name in 10 tries.
# We'll just make a longer name using the current unix epoch in nanoseconds. That should be unique... right?
last_chance_name = alpha_mil_name + str(time.time_ns())
cls.existing_alphas.append(last_chance_name)
return last_chance_name
else: else:
animal = random.choice(self.ANIMALS) animal = random.choice(cls.ANIMALS)
self.ANIMALS.remove(animal) cls.ANIMALS.remove(animal)
return animal return animal
namegen = NameGenerator() namegen = NameGenerator

34
gen/sam/aaa_ks19.py Normal file
View File

@ -0,0 +1,34 @@
import random
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
from pydcs_extensions.highdigitsams import highdigitsams
class KS19Generator(AirDefenseGroupGenerator):
"""
This generate a KS 19 flak artillery group (KS-19 from the High Digit SAM mod)
"""
name = "KS-19 AAA Site"
price = 98
def generate(self):
spacing = random.randint(10, 40)
self.add_unit(highdigitsams.AAA_SON_9_Fire_Can, "TR", self.position.x - 20, self.position.y - 20, self.heading)
index = 0
for i in range(3):
for j in range(3):
index = index + 1
self.add_unit(highdigitsams.AAA_100mm_KS_19, "AAA#" + str(index),
self.position.x + spacing * i,
self.position.y + spacing * j, self.heading)
@classmethod
def range(cls) -> AirDefenseRange:
return AirDefenseRange.Short

View File

@ -1,5 +1,9 @@
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import Iterator, List
from dcs.unitgroup import VehicleGroup
from game import Game from game import Game
from gen.sam.group_generator import GroupGenerator from gen.sam.group_generator import GroupGenerator
@ -21,6 +25,25 @@ class AirDefenseGroupGenerator(GroupGenerator, ABC):
ground_object.skynet_capable = True ground_object.skynet_capable = True
super().__init__(game, ground_object) super().__init__(game, ground_object)
self.auxiliary_groups: List[VehicleGroup] = []
def add_auxiliary_group(self, name_suffix: str) -> VehicleGroup:
group = VehicleGroup(self.game.next_group_id(),
"|".join([self.go.group_name, name_suffix]))
self.auxiliary_groups.append(group)
return group
def get_generated_group(self) -> VehicleGroup:
raise RuntimeError(
"Deprecated call to AirDefenseGroupGenerator.get_generated_group "
"misses auxiliary groups. Use AirDefenseGroupGenerator.groups "
"instead.")
@property
def groups(self) -> Iterator[VehicleGroup]:
yield self.vg
yield from self.auxiliary_groups
@classmethod @classmethod
@abstractmethod @abstractmethod
def range(cls) -> AirDefenseRange: def range(cls) -> AirDefenseRange:

View File

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import math import math
import random import random
from typing import TYPE_CHECKING, Type from typing import TYPE_CHECKING, Type
from dcs import unitgroup from dcs import unitgroup
from dcs.mapping import Point
from dcs.point import PointAction from dcs.point import PointAction
from dcs.unit import Vehicle, Ship from dcs.unit import Ship, Vehicle
from dcs.unittype import VehicleType from dcs.unittype import VehicleType
from game.factions.faction import Faction from game.factions.faction import Faction
@ -40,12 +42,17 @@ class GroupGenerator:
def add_unit(self, unit_type: Type[VehicleType], name: str, pos_x: float, def add_unit(self, unit_type: Type[VehicleType], name: str, pos_x: float,
pos_y: float, heading: int) -> Vehicle: pos_y: float, heading: int) -> Vehicle:
return self.add_unit_to_group(self.vg, unit_type, name,
Point(pos_x, pos_y), heading)
def add_unit_to_group(self, group: unitgroup.VehicleGroup,
unit_type: Type[VehicleType], name: str,
position: Point, heading: int) -> Vehicle:
unit = Vehicle(self.game.next_unit_id(), unit = Vehicle(self.game.next_unit_id(),
f"{self.go.group_name}|{name}", unit_type.id) f"{group.name}|{name}", unit_type.id)
unit.position.x = pos_x unit.position = position
unit.position.y = pos_y
unit.heading = heading unit.heading = heading
self.vg.add_unit(unit) group.add_unit(unit)
return unit return unit
def get_circular_position(self, num_units, launcher_distance, coverage=90): def get_circular_position(self, num_units, launcher_distance, coverage=90):

View File

@ -11,6 +11,7 @@ from game.theater.theatergroundobject import SamGroundObject
from gen.sam.aaa_bofors import BoforsGenerator from gen.sam.aaa_bofors import BoforsGenerator
from gen.sam.aaa_flak import FlakGenerator from gen.sam.aaa_flak import FlakGenerator
from gen.sam.aaa_flak18 import Flak18Generator from gen.sam.aaa_flak18 import Flak18Generator
from gen.sam.aaa_ks19 import KS19Generator
from gen.sam.aaa_ww2_ally_flak import AllyWW2FlakGenerator from gen.sam.aaa_ww2_ally_flak import AllyWW2FlakGenerator
from gen.sam.aaa_zsu57 import ZSU57Generator from gen.sam.aaa_zsu57 import ZSU57Generator
from gen.sam.aaa_zu23_insurgent import ZU23InsurgentGenerator from gen.sam.aaa_zu23_insurgent import ZU23InsurgentGenerator
@ -48,11 +49,12 @@ from gen.sam.sam_roland import RolandGenerator
from gen.sam.sam_sa10 import ( from gen.sam.sam_sa10 import (
SA10Generator, SA10Generator,
Tier2SA10Generator, Tier2SA10Generator,
Tier3SA10Generator, Tier3SA10Generator, SA10BGenerator, SA12Generator, SA20Generator, SA20BGenerator, SA23Generator,
) )
from gen.sam.sam_sa11 import SA11Generator from gen.sam.sam_sa11 import SA11Generator
from gen.sam.sam_sa13 import SA13Generator from gen.sam.sam_sa13 import SA13Generator
from gen.sam.sam_sa15 import SA15Generator from gen.sam.sam_sa15 import SA15Generator
from gen.sam.sam_sa17 import SA17Generator
from gen.sam.sam_sa19 import SA19Generator from gen.sam.sam_sa19 import SA19Generator
from gen.sam.sam_sa2 import SA2Generator from gen.sam.sam_sa2 import SA2Generator
from gen.sam.sam_sa3 import SA3Generator from gen.sam.sam_sa3 import SA3Generator
@ -100,7 +102,15 @@ SAM_MAP: Dict[str, Type[AirDefenseGroupGenerator]] = {
"EarlyColdWarFlakGenerator": EarlyColdWarFlakGenerator, "EarlyColdWarFlakGenerator": EarlyColdWarFlakGenerator,
"FreyaGenerator": FreyaGenerator, "FreyaGenerator": FreyaGenerator,
"AllyWW2FlakGenerator": AllyWW2FlakGenerator, "AllyWW2FlakGenerator": AllyWW2FlakGenerator,
"ZSU57Generator": ZSU57Generator "ZSU57Generator": ZSU57Generator,
"KS19Generator": KS19Generator,
"SA10BGenerator": SA10BGenerator,
"SA12Generator": SA12Generator,
"SA17Generator": SA17Generator,
"SA20Generator": SA20Generator,
"SA20BGenerator": SA20BGenerator,
"SA23Generator": SA23Generator,
} }
@ -171,19 +181,19 @@ def get_faction_possible_ewrs_generator(faction: Faction) -> List[Type[GroupGene
def _generate_anti_air_from( def _generate_anti_air_from(
generators: Sequence[Type[AirDefenseGroupGenerator]], game: Game, generators: Sequence[Type[AirDefenseGroupGenerator]], game: Game,
ground_object: SamGroundObject) -> Optional[VehicleGroup]: ground_object: SamGroundObject) -> List[VehicleGroup]:
if not generators: if not generators:
return None return []
sam_generator_class = random.choice(generators) sam_generator_class = random.choice(generators)
generator = sam_generator_class(game, ground_object) generator = sam_generator_class(game, ground_object)
generator.generate() generator.generate()
return generator.get_generated_group() return list(generator.groups)
def generate_anti_air_group( def generate_anti_air_group(
game: Game, ground_object: SamGroundObject, faction: Faction, game: Game, ground_object: SamGroundObject, faction: Faction,
ranges: Optional[Iterable[Set[AirDefenseRange]]] = None ranges: Optional[Iterable[Set[AirDefenseRange]]] = None
) -> Optional[VehicleGroup]: ) -> List[VehicleGroup]:
""" """
This generate a SAM group This generate a SAM group
:param game: The Game. :param game: The Game.
@ -212,11 +222,11 @@ def generate_anti_air_group(
for range_options in ranges: for range_options in ranges:
generators_for_range = [g for g in generators if generators_for_range = [g for g in generators if
g.range() in range_options] g.range() in range_options]
group = _generate_anti_air_from(generators_for_range, game, groups = _generate_anti_air_from(generators_for_range, game,
ground_object) ground_object)
if group is not None: if groups:
return group return groups
return None return []
def generate_ewr_group(game: Game, ground_object: TheaterGroundObject, def generate_ewr_group(game: Game, ground_object: TheaterGroundObject,

View File

@ -1,5 +1,6 @@
import random import random
from dcs.mapping import Point
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import ( from gen.sam.airdefensegroupgenerator import (
@ -22,7 +23,9 @@ class HawkGenerator(AirDefenseGroupGenerator):
self.add_unit(AirDefence.SAM_Hawk_TR_AN_MPQ_46, "TR", self.position.x + 40, self.position.y, self.heading) self.add_unit(AirDefence.SAM_Hawk_TR_AN_MPQ_46, "TR", self.position.x + 40, self.position.y, self.heading)
# Triple A for close range defense # Triple A for close range defense
self.add_unit(AirDefence.AAA_Vulcan_M163, "AAA", self.position.x + 20, self.position.y+30, self.heading) aa_group = self.add_auxiliary_group("AA")
self.add_unit_to_group(aa_group, AirDefence.AAA_Vulcan_M163, "AAA",
self.position + Point(20, 30), self.heading)
num_launchers = random.randint(3, 6) num_launchers = random.randint(3, 6)
positions = self.get_circular_position(num_launchers, launcher_distance=120, coverage=180) positions = self.get_circular_position(num_launchers, launcher_distance=120, coverage=180)

View File

@ -1,5 +1,6 @@
import random import random
from dcs.mapping import Point
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import ( from gen.sam.airdefensegroupgenerator import (
@ -21,8 +22,13 @@ class HQ7Generator(AirDefenseGroupGenerator):
self.add_unit(AirDefence.HQ_7_Self_Propelled_LN, "LN", self.position.x + 20, self.position.y, self.heading) self.add_unit(AirDefence.HQ_7_Self_Propelled_LN, "LN", self.position.x + 20, self.position.y, self.heading)
# Triple A for close range defense # Triple A for close range defense
self.add_unit(AirDefence.AAA_ZU_23_on_Ural_375, "AAA1", self.position.x + 20, self.position.y+30, self.heading) aa_group = self.add_auxiliary_group("AA")
self.add_unit(AirDefence.AAA_ZU_23_on_Ural_375, "AAA2", self.position.x - 20, self.position.y-30, self.heading) self.add_unit_to_group(aa_group, AirDefence.AAA_ZU_23_on_Ural_375,
"AAA1", self.position + Point(20, 30),
self.heading)
self.add_unit_to_group(aa_group, AirDefence.AAA_ZU_23_on_Ural_375,
"AAA2", self.position - Point(20, 30),
self.heading)
num_launchers = random.randint(0, 3) num_launchers = random.randint(0, 3)
if num_launchers > 0: if num_launchers > 0:

View File

@ -1,5 +1,6 @@
import random import random
from dcs.mapping import Point
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import ( from gen.sam.airdefensegroupgenerator import (
@ -30,10 +31,12 @@ class PatriotGenerator(AirDefenseGroupGenerator):
self.add_unit(AirDefence.SAM_Patriot_LN_M901, "LN#" + str(i), position[0], position[1], position[2]) self.add_unit(AirDefence.SAM_Patriot_LN_M901, "LN#" + str(i), position[0], position[1], position[2])
# Short range protection for high value site # Short range protection for high value site
aa_group = self.add_auxiliary_group("AA")
num_launchers = random.randint(3, 4) num_launchers = random.randint(3, 4)
positions = self.get_circular_position(num_launchers, launcher_distance=200, coverage=360) positions = self.get_circular_position(num_launchers, launcher_distance=200, coverage=360)
for i, position in enumerate(positions): for i, (x, y, heading) in enumerate(positions):
self.add_unit(AirDefence.AAA_Vulcan_M163, "SPAAA#" + str(i), position[0], position[1], position[2]) self.add_unit_to_group(aa_group, AirDefence.AAA_Vulcan_M163,
f"SPAAA#{i}", Point(x, y), heading)
@classmethod @classmethod
def range(cls) -> AirDefenseRange: def range(cls) -> AirDefenseRange:

View File

@ -1,11 +1,16 @@
import random import random
from dcs.mapping import Point
from dcs.unittype import VehicleType
from dcs.vehicles import AirDefence from dcs.vehicles import AirDefence
from game import Game
from game.theater import SamGroundObject
from gen.sam.airdefensegroupgenerator import ( from gen.sam.airdefensegroupgenerator import (
AirDefenseRange, AirDefenseRange,
AirDefenseGroupGenerator, AirDefenseGroupGenerator,
) )
from pydcs_extensions.highdigitsams import highdigitsams
class SA10Generator(AirDefenseGroupGenerator): class SA10Generator(AirDefenseGroupGenerator):
@ -16,20 +21,30 @@ class SA10Generator(AirDefenseGroupGenerator):
name = "SA-10/S-300PS Battery" name = "SA-10/S-300PS Battery"
price = 550 price = 550
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = AirDefence.SAM_SA_10_S_300PS_SR_5N66M
self.sr2 = AirDefence.SAM_SA_10_S_300PS_SR_64H6E
self.cp = AirDefence.SAM_SA_10_S_300PS_CP_54K6
self.tr1 = AirDefence.SAM_SA_10_S_300PS_TR_30N6
self.tr2 = AirDefence.SAM_SA_10_S_300PS_TR_30N6
self.ln1 = AirDefence.SAM_SA_10_S_300PS_LN_5P85C
self.ln2 = AirDefence.SAM_SA_10_S_300PS_LN_5P85D
def generate(self): def generate(self):
# Search Radar # Search Radar
self.add_unit(AirDefence.SAM_SA_10_S_300PS_SR_5N66M, "SR1", self.position.x, self.position.y + 40, self.heading) self.add_unit(self.sr1, "SR1", self.position.x, self.position.y + 40, self.heading)
# Search radar for missiles (optionnal) # Search radar for missiles (optionnal)
self.add_unit(AirDefence.SAM_SA_10_S_300PS_SR_64H6E, "SR2", self.position.x - 40, self.position.y, self.heading) self.add_unit(self.sr2, "SR2", self.position.x - 40, self.position.y, self.heading)
# Command Post # Command Post
self.add_unit(AirDefence.SAM_SA_10_S_300PS_CP_54K6, "CP", self.position.x, self.position.y, self.heading) self.add_unit(self.cp, "CP", self.position.x, self.position.y, self.heading)
# 2 Tracking radars # 2 Tracking radars
self.add_unit(AirDefence.SAM_SA_10_S_300PS_TR_30N6, "TR1", self.position.x - 40, self.position.y - 40, self.heading) self.add_unit(self.tr1, "TR1", self.position.x - 40, self.position.y - 40, self.heading)
self.add_unit(AirDefence.SAM_SA_10_S_300PS_TR_30N6, "TR2", self.position.x + 40, self.position.y - 40, self.add_unit(self.tr2, "TR2", self.position.x + 40, self.position.y - 40,
self.heading) self.heading)
# 2 different launcher type (C & D) # 2 different launcher type (C & D)
@ -37,9 +52,9 @@ class SA10Generator(AirDefenseGroupGenerator):
positions = self.get_circular_position(num_launchers, launcher_distance=100, coverage=360) positions = self.get_circular_position(num_launchers, launcher_distance=100, coverage=360)
for i, position in enumerate(positions): for i, position in enumerate(positions):
if i%2 == 0: if i%2 == 0:
self.add_unit(AirDefence.SAM_SA_10_S_300PS_LN_5P85C, "LN#" + str(i), position[0], position[1], position[2]) self.add_unit(self.ln1, "LN#" + str(i), position[0], position[1], position[2])
else: else:
self.add_unit(AirDefence.SAM_SA_10_S_300PS_LN_5P85D, "LN#" + str(i), position[0], position[1], position[2]) self.add_unit(self.ln2, "LN#" + str(i), position[0], position[1], position[2])
self.generate_defensive_groups() self.generate_defensive_groups()
@ -49,47 +64,126 @@ class SA10Generator(AirDefenseGroupGenerator):
def generate_defensive_groups(self) -> None: def generate_defensive_groups(self) -> None:
# AAA for defending against close targets. # AAA for defending against close targets.
aa_group = self.add_auxiliary_group("AA")
num_launchers = random.randint(6, 8) num_launchers = random.randint(6, 8)
positions = self.get_circular_position( positions = self.get_circular_position(
num_launchers, launcher_distance=210, coverage=360) num_launchers, launcher_distance=210, coverage=360)
for i, position in enumerate(positions): for i, (x, y, heading) in enumerate(positions):
self.add_unit(AirDefence.SPAAA_ZSU_23_4_Shilka, "AA#" + str(i), self.add_unit_to_group(aa_group, AirDefence.SPAAA_ZSU_23_4_Shilka,
position[0], position[1], position[2]) f"AA#{i}", Point(x, y), heading)
class Tier2SA10Generator(SA10Generator): class Tier2SA10Generator(SA10Generator):
def generate_defensive_groups(self) -> None: def generate_defensive_groups(self) -> None:
# Create AAA the way the main group does.
super().generate_defensive_groups()
# SA-15 for both shorter range targets and point defense. # SA-15 for both shorter range targets and point defense.
pd_group = self.add_auxiliary_group("PD")
num_launchers = random.randint(2, 4) num_launchers = random.randint(2, 4)
positions = self.get_circular_position( positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=360) num_launchers, launcher_distance=140, coverage=360)
for i, position in enumerate(positions): for i, (x, y, heading) in enumerate(positions):
self.add_unit(AirDefence.SAM_SA_15_Tor_9A331, "PD#" + str(i), self.add_unit_to_group(pd_group, AirDefence.SAM_SA_15_Tor_9A331,
position[0], position[1], position[2]) f"PD#{i}", Point(x, y), heading)
# AAA for defending against close targets.
num_launchers = random.randint(6, 8)
positions = self.get_circular_position(
num_launchers, launcher_distance=210, coverage=360)
for i, position in enumerate(positions):
self.add_unit(AirDefence.SPAAA_ZSU_23_4_Shilka, "AA#" + str(i),
position[0], position[1], position[2])
class Tier3SA10Generator(SA10Generator): class Tier3SA10Generator(SA10Generator):
def generate_defensive_groups(self) -> None: def generate_defensive_groups(self) -> None:
# SA-15 for both shorter range targets and point defense.
num_launchers = random.randint(2, 4)
positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=360)
for i, position in enumerate(positions):
self.add_unit(AirDefence.SAM_SA_15_Tor_9A331, "PD#" + str(i),
position[0], position[1], position[2])
# AAA for defending against close targets. # AAA for defending against close targets.
aa_group = self.add_auxiliary_group("AA")
num_launchers = random.randint(6, 8) num_launchers = random.randint(6, 8)
positions = self.get_circular_position( positions = self.get_circular_position(
num_launchers, launcher_distance=210, coverage=360) num_launchers, launcher_distance=210, coverage=360)
for i, position in enumerate(positions): for i, (x, y, heading) in enumerate(positions):
self.add_unit(AirDefence.SAM_SA_19_Tunguska_2S6, "AA#" + str(i), self.add_unit_to_group(aa_group, AirDefence.SAM_SA_19_Tunguska_2S6,
position[0], position[1], position[2]) f"AA#{i}", Point(x, y), heading)
# SA-15 for both shorter range targets and point defense.
pd_group = self.add_auxiliary_group("PD")
num_launchers = random.randint(2, 4)
positions = self.get_circular_position(
num_launchers, launcher_distance=140, coverage=360)
for i, (x, y, heading) in enumerate(positions):
self.add_unit_to_group(pd_group, AirDefence.SAM_SA_15_Tor_9A331,
f"PD#{i}", Point(x, y), heading)
class SA10BGenerator(Tier3SA10Generator):
price = 700
name = "SA-10B/S-300PS Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = highdigitsams.SAM_SA_10B_S_300PS_40B6MD_SR
self.sr2 = highdigitsams.SAM_SA_10B_S_300PS_64H6E_SR
self.cp = highdigitsams.SAM_SA_10B_S_300PS_54K6_CP
self.tr1 = highdigitsams.SAM_SA_10B_S_300PS_30N6_TR
self.tr2 = highdigitsams.SAM_SA_10B_S_300PS_40B6M_TR
self.ln1 = highdigitsams.SAM_SA_10B_S_300PS_5P85SE_LN
self.ln2 = highdigitsams.SAM_SA_10B_S_300PS_5P85SU_LN
class SA12Generator(Tier3SA10Generator):
price = 750
name = "SA-12/S-300V Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = highdigitsams.SAM_SA_12_S_300V_9S15_SR
self.sr2 = highdigitsams.SAM_SA_12_S_300V_9S19_SR
self.cp = highdigitsams.SAM_SA_12_S_300V_9S457_CP
self.tr1 = highdigitsams.SAM_SA_12_S_300V_9S32_TR
self.tr2 = highdigitsams.SAM_SA_12_S_300V_9S32_TR
self.ln1 = highdigitsams.SAM_SA_12_S_300V_9A82_LN
self.ln2 = highdigitsams.SAM_SA_12_S_300V_9A83_LN
class SA20Generator(Tier3SA10Generator):
price = 800
name = "SA-20/S-300PMU-1 Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E
self.sr2 = highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E
self.cp = highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6
self.tr1 = highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E
self.tr2 = highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck
self.ln1 = highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE
self.ln2 = highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE
class SA20BGenerator(Tier3SA10Generator):
price = 850
name = "SA-20B/S-300PMU-2 Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E
self.sr2 = highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E
self.cp = highdigitsams.SAM_SA_20B_S_300PMU2_CP_54K6E2
self.tr1 = highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck
self.tr2 = highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck
self.ln1 = highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2
self.ln2 = highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2
class SA23Generator(Tier3SA10Generator):
price = 950
name = "SA-23/S-300VM Battery"
def __init__(self, game: Game, ground_object: SamGroundObject):
super().__init__(game, ground_object)
self.sr1 = highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR
self.sr2 = highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR
self.cp = highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP
self.tr1 = highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR
self.tr2 = highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR
self.ln1 = highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN
self.ln2 = highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN

30
gen/sam/sam_sa17.py Normal file
View File

@ -0,0 +1,30 @@
from dcs.vehicles import AirDefence
from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
from pydcs_extensions.highdigitsams import highdigitsams
class SA17Generator(AirDefenseGroupGenerator):
"""
This generate a SA-17 group
"""
name = "SA-17 Grizzly Battery"
price = 180
def generate(self):
self.add_unit(AirDefence.SAM_SA_11_Buk_SR_9S18M1, "SR", self.position.x + 20, self.position.y, self.heading)
self.add_unit(AirDefence.SAM_SA_11_Buk_CC_9S470M1, "CC", self.position.x, self.position.y, self.heading)
positions = self.get_circular_position(3, launcher_distance=140, coverage=180)
for i, position in enumerate(positions):
self.add_unit(highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2, "LN#" + str(i), position[0], position[1],
position[2])
@classmethod
def range(cls) -> AirDefenseRange:
return AirDefenseRange.Medium

2
pydcs

@ -1 +1 @@
Subproject commit 059c88c91b5be4b5b6406249a52527c3ccea3db9 Subproject commit 84f116c35879b05470ad7f30b0c5b0bca26088a5

View File

@ -132,31 +132,31 @@ class Hercules(PlaneType):
charge_total = 1680 charge_total = 1680
chaff_charge_size = 1 chaff_charge_size = 1
flare_charge_size = 1 flare_charge_size = 1
radio_frequency = 305 radio_frequency = 118
panel_radio = { panel_radio = {
1: { 1: {
"channels": { "channels": {
1: 305, 1: 118,
2: 264, 2: 119,
4: 256, 4: 121,
8: 257, 8: 125,
16: 261, 16: 133,
17: 261, 17: 134,
9: 255, 9: 126,
18: 251, 18: 135,
5: 254, 5: 122,
10: 262, 10: 127,
20: 266, 20: 143,
11: 259, 11: 128,
3: 265, 3: 120,
6: 250, 6: 123,
12: 268, 12: 129,
13: 269, 13: 130,
7: 270, 7: 124,
14: 260, 14: 131,
19: 253, 19: 136,
15: 263 15: 132
}, },
}, },
} }

View File

@ -1,6 +1,102 @@
from dcs import unittype from dcs import unittype
class AAA_SON_9_Fire_Can(unittype.VehicleType):
id = "Fire Can radar"
name = "AAA SON-9 Fire Can"
detection_range = 35000
threat_range = 0
air_weapon_dist = 0
class AAA_100mm_KS_19(unittype.VehicleType):
id = "KS19"
name = "AAA 100mm KS-19"
detection_range = 0
threat_range = 15000
air_weapon_dist = 15000
class SAM_SA_10B_S_300PS_54K6_CP(unittype.VehicleType):
id = "S-300PS SA-10B 54K6 cp"
name = "SAM SA-10B S-300PS 54K6 CP"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_10B_S_300PS_5P85SE_LN(unittype.VehicleType):
id = "S-300PS 5P85SE_mod ln"
name = "SAM SA-10B S-300PS 5P85SE LN "
detection_range = 0
threat_range = 75000
air_weapon_dist = 75000
class SAM_SA_10B_S_300PS_5P85SU_LN(unittype.VehicleType):
id = "S-300PS 5P85SU_mod ln"
name = "SAM SA-10B S-300PS 5P85SU LN "
detection_range = 0
threat_range = 75000
air_weapon_dist = 75000
class SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE(unittype.VehicleType):
id = "S-300PS 5P85CE ln"
name = "SAM SA-10 (5V55RUD) S-300PS LN 5P85CE"
detection_range = 0
threat_range = 90000
air_weapon_dist = 90000
class SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE(unittype.VehicleType):
id = "S-300PS 5P85DE ln"
name = "SAM SA-10 (5V55RUD) S-300PS LN 5P85DE"
detection_range = 0
threat_range = 90000
air_weapon_dist = 90000
class SAM_SA_10B_S_300PS_30N6_TR(unittype.VehicleType):
id = "S-300PS 30N6 TRAILER tr"
name = "SAM SA-10B S-300PS 30N6 TR"
detection_range = 160000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_10B_S_300PS_40B6M_TR(unittype.VehicleType):
id = "S-300PS SA-10B 40B6M MAST tr"
name = "SAM SA-10B S-300PS 40B6M TR"
detection_range = 160000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_10B_S_300PS_40B6MD_SR(unittype.VehicleType):
id = "S-300PS SA-10B 40B6MD MAST sr"
name = "SAM SA-10B S-300PS 40B6MD SR"
detection_range = 60000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_10B_S_300PS_64H6E_SR(unittype.VehicleType):
id = "S-300PS 64H6E TRAILER sr"
name = "SAM SA-10B S-300PS 64H6E SR"
detection_range = 160000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_20_S_300PMU1_CP_54K6(unittype.VehicleType):
id = "S-300PMU1 54K6 cp"
name = "SAM SA-20 S-300PMU1 CP 54K6"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_20_S_300PMU1_TR_30N6E(unittype.VehicleType): class SAM_SA_20_S_300PMU1_TR_30N6E(unittype.VehicleType):
id = "S-300PMU1 40B6M tr" id = "S-300PMU1 40B6M tr"
name = "SAM SA-20 S-300PMU1 TR 30N6E" name = "SAM SA-20 S-300PMU1 TR 30N6E"
@ -33,6 +129,110 @@ class SAM_SA_20_S_300PMU1_SR_64N6E(unittype.VehicleType):
air_weapon_dist = 0 air_weapon_dist = 0
class SAM_SA_20_S_300PMU1_LN_5P85CE(unittype.VehicleType):
id = "S-300PMU1 5P85CE ln"
name = "SAM SA-20 S-300PMU1 LN 5P85CE"
detection_range = 0
threat_range = 150000
air_weapon_dist = 150000
class SAM_SA_20_S_300PMU1_LN_5P85DE(unittype.VehicleType):
id = "S-300PMU1 5P85DE ln"
name = "SAM SA-20 S-300PMU1 LN 5P85DE"
detection_range = 0
threat_range = 150000
air_weapon_dist = 150000
class SAM_SA_20B_S_300PMU2_CP_54K6E2(unittype.VehicleType):
id = "S-300PMU2 54K6E2 cp"
name = "SAM SA-20B S-300PMU2 CP 54K6E2"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_20B_S_300PMU2_TR_92H6E_truck(unittype.VehicleType):
id = "S-300PMU2 92H6E tr"
name = "SAM SA-20B S-300PMU2 TR 92H6E(truck)"
detection_range = 270000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_20B_S_300PMU2_SR_64N6E2(unittype.VehicleType):
id = "S-300PMU2 64H6E2 sr"
name = "SAM SA-20B S-300PMU2 SR 64N6E2"
detection_range = 330000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_20B_S_300PMU2_LN_5P85SE2(unittype.VehicleType):
id = "S-300PMU2 5P85SE2 ln"
name = "SAM SA-20B S-300PMU2 LN 5P85SE2"
detection_range = 0
threat_range = 200000
air_weapon_dist = 200000
class SAM_SA_12_S_300V_9S457_CP(unittype.VehicleType):
id = "S-300V 9S457 cp"
name = "SAM SA-12 S-300V 9S457 CP"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_12_S_300V_9A82_LN(unittype.VehicleType):
id = "S-300V 9A82 ln"
name = "SAM SA-12 S-300V 9A82 LN"
detection_range = 0
threat_range = 100000
air_weapon_dist = 100000
class SAM_SA_12_S_300V_9A83_LN(unittype.VehicleType):
id = "S-300V 9A83 ln"
name = "SAM SA-12 S-300V 9A83 LN"
detection_range = 0
threat_range = 75000
air_weapon_dist = 75000
class SAM_SA_12_S_300V_9S15_SR(unittype.VehicleType):
id = "S-300V 9S15 sr"
name = "SAM SA-12 S-300V 9S15 SR"
detection_range = 240000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_12_S_300V_9S19_SR(unittype.VehicleType):
id = "S-300V 9S19 sr"
name = "SAM SA-12 S-300V 9S19 SR"
detection_range = 175000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_12_S_300V_9S32_TR(unittype.VehicleType):
id = "S-300V 9S32 tr"
name = "SAM SA-12 S-300V 9S32 TR"
detection_range = 150000
threat_range = 0
air_weapon_dist = 0
class SAM_SA_23_S_300VM_9S457ME_CP(unittype.VehicleType):
id = "S-300VM 9S457ME cp"
name = "SAM SA-23 S-300VM 9S457ME CP"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_23_S_300VM_9S15M2_SR(unittype.VehicleType): class SAM_SA_23_S_300VM_9S15M2_SR(unittype.VehicleType):
id = "S-300VM 9S15M2 sr" id = "S-300VM 9S15M2 sr"
name = "SAM SA-23 S-300VM 9S15M2 SR" name = "SAM SA-23 S-300VM 9S15M2 SR"
@ -57,38 +257,6 @@ class SAM_SA_23_S_300VM_9S32ME_TR(unittype.VehicleType):
air_weapon_dist = 0 air_weapon_dist = 0
class SAM_SA_20_S_300PMU1_LN_5P85CE(unittype.VehicleType):
id = "S-300PMU1 5P85CE ln"
name = "SAM SA-20 S-300PMU1 LN 5P85CE"
detection_range = 0
threat_range = 150000
air_weapon_dist = 150000
class SAM_SA_20_S_300PMU1_LN_5P85DE(unittype.VehicleType):
id = "S-300PMU1 5P85DE ln"
name = "SAM SA-20 S-300PMU1 LN 5P85DE"
detection_range = 0
threat_range = 150000
air_weapon_dist = 150000
class SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE(unittype.VehicleType):
id = "S-300PS 5P85CE ln"
name = "SAM SA-10 (5V55RUD) S-300PS LN 5P85CE"
detection_range = 0
threat_range = 90000
air_weapon_dist = 90000
class SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE(unittype.VehicleType):
id = "S-300PS 5P85DE ln"
name = "SAM SA-10 (5V55RUD) S-300PS LN 5P85DE"
detection_range = 0
threat_range = 90000
air_weapon_dist = 90000
class SAM_SA_23_S_300VM_9A83ME_LN(unittype.VehicleType): class SAM_SA_23_S_300VM_9A83ME_LN(unittype.VehicleType):
id = "S-300VM 9A83ME ln" id = "S-300VM 9A83ME ln"
name = "SAM SA-23 S-300VM 9A83ME LN" name = "SAM SA-23 S-300VM 9A83ME LN"
@ -137,22 +305,6 @@ class SAM_SA_3__V_601P__LN_5P73(unittype.VehicleType):
air_weapon_dist = 18000 air_weapon_dist = 18000
class SAM_SA_20_S_300PMU1_CP_54K6(unittype.VehicleType):
id = "S-300PMU1 54K6 cp"
name = "SAM SA-20 S-300PMU1 CP 54K6"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_23_S_300VM_9S457ME_CP(unittype.VehicleType):
id = "S-300VM 9S457ME cp"
name = "SAM SA-23 S-300VM 9S457ME CP"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class SAM_SA_24_Igla_S_manpad(unittype.VehicleType): class SAM_SA_24_Igla_S_manpad(unittype.VehicleType):
id = "SA-24 Igla-S manpad" id = "SA-24 Igla-S manpad"
name = "SAM SA-24 Igla-S manpad" name = "SAM SA-24 Igla-S manpad"
@ -167,3 +319,20 @@ class SAM_SA_14_Strela_3_manpad(unittype.VehicleType):
detection_range = 5000 detection_range = 5000
threat_range = 4500 threat_range = 4500
air_weapon_dist = 4500 air_weapon_dist = 4500
class Polyana_D4M1_C2_node(unittype.VehicleType):
id = "polyana-d4m1 cp"
name = "Polyana-D4M1 C2 node"
detection_range = 0
threat_range = 0
air_weapon_dist = 0
class _34Ya6E_Gazetchik_E_decoy(unittype.VehicleType):
id = "34Ya6E Gazetchik E decoy"
name = "34Ya6E Gazetchik E decoy"
detection_range = 20000
threat_range = 0
air_weapon_dist = 0

View File

@ -43,25 +43,46 @@ MODDED_VEHICLES = [
frenchpack.DIM__TOYOTA_GREEN, frenchpack.DIM__TOYOTA_GREEN,
frenchpack.DIM__TOYOTA_DESERT, frenchpack.DIM__TOYOTA_DESERT,
frenchpack.DIM__KAMIKAZE, frenchpack.DIM__KAMIKAZE,
highdigitsams.AAA_SON_9_Fire_Can,
highdigitsams.AAA_100mm_KS_19,
highdigitsams.SAM_SA_10B_S_300PS_54K6_CP,
highdigitsams.SAM_SA_10B_S_300PS_5P85SE_LN,
highdigitsams.SAM_SA_10B_S_300PS_5P85SU_LN,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE,
highdigitsams.SAM_SA_10B_S_300PS_30N6_TR,
highdigitsams.SAM_SA_10B_S_300PS_40B6M_TR,
highdigitsams.SAM_SA_10B_S_300PS_40B6MD_SR,
highdigitsams.SAM_SA_10B_S_300PS_64H6E_SR,
highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6,
highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E, highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E,
highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck, highdigitsams.SAM_SA_20_S_300PMU1_TR_30N6E_truck,
highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E, highdigitsams.SAM_SA_20_S_300PMU1_SR_5N66E,
highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E, highdigitsams.SAM_SA_20_S_300PMU1_SR_64N6E,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE,
highdigitsams.SAM_SA_20B_S_300PMU2_CP_54K6E2,
highdigitsams.SAM_SA_20B_S_300PMU2_TR_92H6E_truck,
highdigitsams.SAM_SA_20B_S_300PMU2_SR_64N6E2,
highdigitsams.SAM_SA_20B_S_300PMU2_LN_5P85SE2,
highdigitsams.SAM_SA_12_S_300V_9S457_CP,
highdigitsams.SAM_SA_12_S_300V_9A82_LN,
highdigitsams.SAM_SA_12_S_300V_9A83_LN,
highdigitsams.SAM_SA_12_S_300V_9S15_SR,
highdigitsams.SAM_SA_12_S_300V_9S19_SR,
highdigitsams.SAM_SA_12_S_300V_9S32_TR,
highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP,
highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR, highdigitsams.SAM_SA_23_S_300VM_9S15M2_SR,
highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR, highdigitsams.SAM_SA_23_S_300VM_9S19M2_SR,
highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR, highdigitsams.SAM_SA_23_S_300VM_9S32ME_TR,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85CE,
highdigitsams.SAM_SA_20_S_300PMU1_LN_5P85DE,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85CE,
highdigitsams.SAM_SA_10__5V55RUD__S_300PS_LN_5P85DE,
highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN, highdigitsams.SAM_SA_23_S_300VM_9A83ME_LN,
highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN, highdigitsams.SAM_SA_23_S_300VM_9A82ME_LN,
highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2, highdigitsams.SAM_SA_17_Buk_M1_2_LN_9A310M1_2,
highdigitsams.SAM_SA_2__V759__LN_SM_90, highdigitsams.SAM_SA_2__V759__LN_SM_90,
highdigitsams.SAM_HQ_2_LN_SM_90, highdigitsams.SAM_HQ_2_LN_SM_90,
highdigitsams.SAM_SA_3__V_601P__LN_5P73, highdigitsams.SAM_SA_3__V_601P__LN_5P73,
highdigitsams.SAM_SA_20_S_300PMU1_CP_54K6,
highdigitsams.SAM_SA_23_S_300VM_9S457ME_CP,
highdigitsams.SAM_SA_24_Igla_S_manpad, highdigitsams.SAM_SA_24_Igla_S_manpad,
highdigitsams.SAM_SA_14_Strela_3_manpad highdigitsams.SAM_SA_14_Strela_3_manpad,
highdigitsams.Polyana_D4M1_C2_node,
highdigitsams._34Ya6E_Gazetchik_E_decoy
] ]

View File

@ -60,7 +60,7 @@ class Dialog:
"""Opens the dialog to edit the given flight.""" """Opens the dialog to edit the given flight."""
cls.edit_flight_dialog = QEditFlightDialog( cls.edit_flight_dialog = QEditFlightDialog(
cls.game_model, cls.game_model,
package_model.package, package_model,
flight, flight,
parent=parent parent=parent
) )

View File

@ -1,5 +1,5 @@
"""Visibility options for the game map.""" """Visibility options for the game map."""
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Iterator, Optional, Union from typing import Iterator, Optional, Union
@ -7,6 +7,7 @@ from typing import Iterator, Optional, Union
class DisplayRule: class DisplayRule:
name: str name: str
_value: bool _value: bool
debug_only: bool = field(default=False)
@property @property
def menu_text(self) -> str: def menu_text(self) -> str:
@ -29,8 +30,9 @@ class DisplayRule:
class DisplayGroup: class DisplayGroup:
def __init__(self, name: Optional[str]) -> None: def __init__(self, name: Optional[str], debug_only: bool = False) -> None:
self.name = name self.name = name
self.debug_only = debug_only
def __iter__(self) -> Iterator[DisplayRule]: def __iter__(self) -> Iterator[DisplayRule]:
# Python 3.6 enforces that __dict__ is order preserving by default. # Python 3.6 enforces that __dict__ is order preserving by default.
@ -47,6 +49,46 @@ class FlightPathOptions(DisplayGroup):
self.all = DisplayRule("Show All Flight Paths", True) self.all = DisplayRule("Show All Flight Paths", True)
class ThreatZoneOptions(DisplayGroup):
def __init__(self, coalition_name: str) -> None:
super().__init__(f"{coalition_name} Threat Zones")
self.none = DisplayRule(
f"Hide {coalition_name.lower()} threat zones", True)
self.all = DisplayRule(
f"Show full {coalition_name.lower()} threat zones", False)
self.aircraft = DisplayRule(
f"Show {coalition_name.lower()} aircraft threat tones", False)
self.air_defenses = DisplayRule(
f"Show {coalition_name.lower()} air defenses threat zones", False)
class NavMeshOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Navmeshes", debug_only=True)
self.hide = DisplayRule("DEBUG Hide Navmeshes", True)
self.blue_navmesh = DisplayRule("DEBUG Show blue navmesh", False)
self.red_navmesh = DisplayRule("DEBUG Show red navmesh", False)
class PathDebugFactionOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Faction for path debugging", debug_only=True)
self.blue = DisplayRule("Debug blue paths", True)
self.red = DisplayRule("Debug red paths", False)
class PathDebugOptions(DisplayGroup):
def __init__(self) -> None:
super().__init__("Shortest paths", debug_only=True)
self.hide = DisplayRule("DEBUG Hide paths", True)
self.shortest_path = DisplayRule("DEBUG Show shortest path", False)
self.barcap = DisplayRule("DEBUG Show BARCAP plan", False)
self.cas = DisplayRule("DEBUG Show CAS plan", False)
self.sweep = DisplayRule("DEBUG Show fighter sweep plan", False)
self.strike = DisplayRule("DEBUG Show strike plan", False)
self.tarcap = DisplayRule("DEBUG Show TARCAP plan", False)
class DisplayOptions: class DisplayOptions:
ground_objects = DisplayRule("Ground Objects", True) ground_objects = DisplayRule("Ground Objects", True)
control_points = DisplayRule("Control Points", True) control_points = DisplayRule("Control Points", True)
@ -57,14 +99,27 @@ class DisplayOptions:
map_poly = DisplayRule("Map Polygon Debug Mode", False) map_poly = DisplayRule("Map Polygon Debug Mode", False)
waypoint_info = DisplayRule("Waypoint Information", True) waypoint_info = DisplayRule("Waypoint Information", True)
culling = DisplayRule("Display Culling Zones", False) culling = DisplayRule("Display Culling Zones", False)
actual_frontline_pos = DisplayRule("Display Actual Frontline Location",
False)
barcap_commit_range = DisplayRule("Display selected BARCAP commit range",
False)
flight_paths = FlightPathOptions() flight_paths = FlightPathOptions()
actual_frontline_pos = DisplayRule("Display Actual Frontline Location", False) blue_threat_zones = ThreatZoneOptions("Blue")
red_threat_zones = ThreatZoneOptions("Red")
navmeshes = NavMeshOptions()
path_debug_faction = PathDebugFactionOptions()
path_debug = PathDebugOptions()
@classmethod @classmethod
def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]: def menu_items(cls) -> Iterator[Union[DisplayGroup, DisplayRule]]:
debug = False # Set to True to enable debug options.
# Python 3.6 enforces that __dict__ is order preserving by default. # Python 3.6 enforces that __dict__ is order preserving by default.
for value in cls.__dict__.values(): for value in cls.__dict__.values():
if isinstance(value, DisplayRule): if isinstance(value, DisplayRule):
if value.debug_only and not debug:
continue
yield value yield value
elif isinstance(value, DisplayGroup): elif isinstance(value, DisplayGroup):
if value.debug_only and not debug:
continue
yield value yield value

View File

@ -7,11 +7,18 @@ from pathlib import Path
from typing import Optional from typing import Optional
import dcs import dcs
from dcs.weapons_data import weapon_ids
from PySide2 import QtWidgets from PySide2 import QtWidgets
from PySide2.QtGui import QPixmap from PySide2.QtGui import QPixmap
from PySide2.QtWidgets import QApplication, QSplashScreen from PySide2.QtWidgets import QApplication, QSplashScreen
from game import Game, db, persistency, VERSION from game import Game, db, persistency, VERSION
from game.data.weapons import (
WEAPON_FALLBACK_MAP,
WEAPON_INTRODUCTION_YEARS,
Weapon,
)
from game.settings import Settings from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings from game.theater.start_generator import GameGenerator, GeneratorSettings
from qt_ui import ( from qt_ui import (
@ -67,6 +74,8 @@ def run_ui(game: Optional[Game] = None) -> None:
uiconstants.load_event_icons() uiconstants.load_event_icons()
uiconstants.load_aircraft_icons() uiconstants.load_aircraft_icons()
uiconstants.load_vehicle_icons() uiconstants.load_vehicle_icons()
uiconstants.load_aircraft_banners()
uiconstants.load_vehicle_banners()
# Replace DCS Mission scripting file to allow DCS Liberation to work # Replace DCS Mission scripting file to allow DCS Liberation to work
try: try:
@ -103,6 +112,11 @@ def parse_args() -> argparse.Namespace:
raise argparse.ArgumentTypeError("path does not exist") raise argparse.ArgumentTypeError("path does not exist")
return path return path
parser.add_argument(
"--warn-missing-weapon-data", action="store_true",
help="Emits a warning for weapons without date or fallback information."
)
new_game = subparsers.add_parser("new-game") new_game = subparsers.add_parser("new-game")
new_game.add_argument( new_game.add_argument(
@ -163,6 +177,15 @@ def create_game(campaign_path: Path, blue: str, red: str,
return generator.generate() return generator.generate()
def lint_weapon_data() -> None:
for clsid in weapon_ids:
weapon = Weapon.from_clsid(clsid)
if weapon not in WEAPON_INTRODUCTION_YEARS:
logging.warning(f"{weapon} has no introduction date")
if weapon not in WEAPON_FALLBACK_MAP:
logging.warning(f"{weapon} has no fallback")
def main(): def main():
logging_config.init_logging(VERSION) logging_config.init_logging(VERSION)
@ -172,6 +195,11 @@ def main():
game: Optional[Game] = None game: Optional[Game] = None
args = parse_args() args = parse_args()
# TODO: Flesh out data and then make unconditional.
if args.warn_missing_weapon_data:
lint_weapon_data()
if args.subcommand == "new-game": if args.subcommand == "new-game":
game = create_game(args.campaign, args.blue, args.red, game = create_game(args.campaign, args.blue, args.red,
args.supercarrier, args.auto_procurement, args.supercarrier, args.auto_procurement,

View File

@ -100,6 +100,8 @@ class PackageModel(QAbstractListModel):
#: Emitted when this package is being deleted from the ATO. #: Emitted when this package is being deleted from the ATO.
deleted = Signal() deleted = Signal()
tot_changed = Signal()
def __init__(self, package: Package) -> None: def __init__(self, package: Package) -> None:
super().__init__() super().__init__()
self.package = package self.package = package
@ -139,6 +141,8 @@ class PackageModel(QAbstractListModel):
"""Adds the given flight to the package.""" """Adds the given flight to the package."""
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self.package.add_flight(flight) self.package.add_flight(flight)
# update_tot is not called here because the new flight does not have a
# flight plan yet. Will be called manually by the caller.
self.endInsertRows() self.endInsertRows()
def delete_flight_at_index(self, index: QModelIndex) -> None: def delete_flight_at_index(self, index: QModelIndex) -> None:
@ -155,15 +159,27 @@ class PackageModel(QAbstractListModel):
self.beginRemoveRows(QModelIndex(), index, index) self.beginRemoveRows(QModelIndex(), index, index)
self.package.remove_flight(flight) self.package.remove_flight(flight)
self.endRemoveRows() self.endRemoveRows()
self.update_tot()
def flight_at_index(self, index: QModelIndex) -> Flight: def flight_at_index(self, index: QModelIndex) -> Flight:
"""Returns the flight located at the given index.""" """Returns the flight located at the given index."""
return self.package.flights[index.row()] return self.package.flights[index.row()]
def update_tot(self, tot: datetime.timedelta) -> None: def set_tot(self, tot: datetime.timedelta) -> None:
self.package.time_over_target = tot self.package.time_over_target = tot
self.update_tot()
# For some reason this is needed to make the UI update quickly.
self.layoutChanged.emit() self.layoutChanged.emit()
def set_asap(self, asap: bool) -> None:
self.package.auto_asap = asap
self.update_tot()
def update_tot(self) -> None:
if self.package.auto_asap:
self.package.set_tot_asap()
self.tot_changed.emit()
@property @property
def mission_target(self) -> MissionTarget: def mission_target(self) -> MissionTarget:
"""Returns the mission target of the package.""" """Returns the mission target of the package."""

View File

@ -72,7 +72,9 @@ COLORS: Dict[str, QColor] = {
CP_SIZE = 12 CP_SIZE = 12
AIRCRAFT_BANNERS: Dict[str, QPixmap] = {}
AIRCRAFT_ICONS: Dict[str, QPixmap] = {} AIRCRAFT_ICONS: Dict[str, QPixmap] = {}
VEHICLE_BANNERS: Dict[str, QPixmap] = {}
VEHICLES_ICONS: Dict[str, QPixmap] = {} VEHICLES_ICONS: Dict[str, QPixmap] = {}
ICONS: Dict[str, QPixmap] = {} ICONS: Dict[str, QPixmap] = {}
@ -130,6 +132,8 @@ def load_icons():
ICONS["ship_blue"] = QPixmap("./resources/ui/ground_assets/ship_blue.png") ICONS["ship_blue"] = QPixmap("./resources/ui/ground_assets/ship_blue.png")
ICONS["missile"] = QPixmap("./resources/ui/ground_assets/missile.png") ICONS["missile"] = QPixmap("./resources/ui/ground_assets/missile.png")
ICONS["missile_blue"] = QPixmap("./resources/ui/ground_assets/missile_blue.png") ICONS["missile_blue"] = QPixmap("./resources/ui/ground_assets/missile_blue.png")
ICONS["nothreat"] = QPixmap("./resources/ui/ground_assets/nothreat.png")
ICONS["nothreat_blue"] = QPixmap("./resources/ui/ground_assets/nothreat_blue.png")
ICONS["Generator"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/generator.png") ICONS["Generator"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/generator.png")
ICONS["Missile"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/missile.png") ICONS["Missile"] = QPixmap("./resources/ui/misc/"+get_theme_icons()+"/missile.png")
@ -171,15 +175,25 @@ def load_event_icons():
EVENT_ICONS[image[:-4]] = QPixmap(os.path.join("./resources/ui/events/", image)) EVENT_ICONS[image[:-4]] = QPixmap(os.path.join("./resources/ui/events/", image))
def load_aircraft_icons(): def load_aircraft_icons():
for aircraft in os.listdir("./resources/ui/units/aircrafts/"): for aircraft in os.listdir("./resources/ui/units/aircrafts/icons/"):
if aircraft.endswith(".jpg"): if aircraft.endswith(".jpg"):
AIRCRAFT_ICONS[aircraft[:-7]] = QPixmap(os.path.join("./resources/ui/units/aircrafts/", aircraft)) AIRCRAFT_ICONS[aircraft[:-7]] = QPixmap(os.path.join("./resources/ui/units/aircrafts/icons/", aircraft))
AIRCRAFT_ICONS["F-16C_50"] = AIRCRAFT_ICONS["F-16C"] AIRCRAFT_ICONS["F-16C_50"] = AIRCRAFT_ICONS["F-16C"]
AIRCRAFT_ICONS["FA-18C_hornet"] = AIRCRAFT_ICONS["FA-18C"] AIRCRAFT_ICONS["FA-18C_hornet"] = AIRCRAFT_ICONS["FA-18C"]
AIRCRAFT_ICONS["A-10C_2"] = AIRCRAFT_ICONS["A-10C"] AIRCRAFT_ICONS["A-10C_2"] = AIRCRAFT_ICONS["A-10C"]
def load_vehicle_icons(): def load_vehicle_icons():
for vehicle in os.listdir("./resources/ui/units/vehicles/"): for vehicle in os.listdir("./resources/ui/units/vehicles/icons/"):
if vehicle.endswith(".jpg"): if vehicle.endswith(".jpg"):
VEHICLES_ICONS[vehicle[:-7]] = QPixmap(os.path.join("./resources/ui/units/vehicles/", vehicle)) VEHICLES_ICONS[vehicle[:-7]] = QPixmap(os.path.join("./resources/ui/units/vehicles/icons/", vehicle))
def load_aircraft_banners():
for aircraft in os.listdir("./resources/ui/units/aircrafts/banners/"):
if aircraft.endswith(".jpg"):
AIRCRAFT_BANNERS[aircraft[:-7]] = QPixmap(os.path.join("./resources/ui/units/aircrafts/banners/", aircraft))
def load_vehicle_banners():
for aircraft in os.listdir("./resources/ui/units/vehicles/banners/"):
if aircraft.endswith(".jpg"):
VEHICLE_BANNERS[aircraft[:-7]] = QPixmap(os.path.join("./resources/ui/units/vehicles/banners/", aircraft))

View File

@ -35,7 +35,7 @@ class QBudgetBox(QGroupBox):
:param budget: Current money available :param budget: Current money available
:param reward: Planned reward for next turn :param reward: Planned reward for next turn
""" """
self.money_amount.setText(str(budget) + "M (+" + str(reward) + "M)") self.money_amount.setText(str(budget) + "M (+" + str(round(reward,2)) + "M)")
def setGame(self, game): def setGame(self, game):
if game is None: if game is None:

View File

@ -1,12 +1,18 @@
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout, QFrame, QGridLayout
from PySide2.QtGui import QPixmap from PySide2.QtGui import QPixmap
from PySide2.QtWidgets import (
from game.weather import Conditions, TimeOfDay, Weather QFrame,
from game.utils import meter_to_nm, mps_to_knots QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
)
from dcs.weather import Weather as PydcsWeather from dcs.weather import Weather as PydcsWeather
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game.utils import mps
from game.weather import Conditions, TimeOfDay
class QTimeTurnWidget(QGroupBox): class QTimeTurnWidget(QGroupBox):
""" """
@ -163,20 +169,20 @@ class QWeatherWidget(QGroupBox):
def updateWinds(self): def updateWinds(self):
"""Updates the UI with the current conditions wind info. """Updates the UI with the current conditions wind info.
""" """
windGlSpeed = mps_to_knots(self.conditions.weather.wind.at_0m.speed or 0) windGlSpeed = mps(self.conditions.weather.wind.at_0m.speed or 0)
windGlDir = str(self.conditions.weather.wind.at_0m.direction or 0).rjust(3, '0') windGlDir = str(self.conditions.weather.wind.at_0m.direction or 0).rjust(3, '0')
self.windGLSpeedLabel.setText('{}kts'.format(windGlSpeed)) self.windGLSpeedLabel.setText(f'{int(windGlSpeed.knots)}kts')
self.windGLDirLabel.setText('{}º'.format(windGlDir)) self.windGLDirLabel.setText(f'{windGlDir}º')
windFL08Speed = mps_to_knots(self.conditions.weather.wind.at_2000m.speed or 0) windFL08Speed = mps(self.conditions.weather.wind.at_2000m.speed or 0)
windFL08Dir = str(self.conditions.weather.wind.at_2000m.direction or 0).rjust(3, '0') windFL08Dir = str(self.conditions.weather.wind.at_2000m.direction or 0).rjust(3, '0')
self.windFL08SpeedLabel.setText('{}kts'.format(windFL08Speed)) self.windFL08SpeedLabel.setText(f'{int(windFL08Speed.knots)}kts')
self.windFL08DirLabel.setText('{}º'.format(windFL08Dir)) self.windFL08DirLabel.setText(f'{windFL08Dir}º')
windFL26Speed = mps_to_knots(self.conditions.weather.wind.at_8000m.speed or 0) windFL26Speed = mps(self.conditions.weather.wind.at_8000m.speed or 0)
windFL26Dir = str(self.conditions.weather.wind.at_8000m.direction or 0).rjust(3, '0') windFL26Dir = str(self.conditions.weather.wind.at_8000m.direction or 0).rjust(3, '0')
self.windFL26SpeedLabel.setText('{}kts'.format(windFL26Speed)) self.windFL26SpeedLabel.setText(f'{int(windFL26Speed.knots)}kts')
self.windFL26DirLabel.setText('{}º'.format(windFL26Dir)) self.windFL26DirLabel.setText(f'{windFL26Dir}º')
def updateForecast(self): def updateForecast(self):
"""Updates the Forecast Text and icon with the current conditions wind info. """Updates the Forecast Text and icon with the current conditions wind info.
@ -223,11 +229,10 @@ class QWeatherWidget(QGroupBox):
if not fog: if not fog:
self.forecastFog.setText('No fog') self.forecastFog.setText('No fog')
else: else:
visvibilityNm = round(meter_to_nm(fog.visibility), 1) visibility = round(fog.visibility.nautical_miles, 1)
self.forecastFog.setText('Fog vis: {}nm'.format(visvibilityNm)) self.forecastFog.setText(f'Fog vis: {visibility}nm')
icon = [time, ('cloudy' if cloudDensity > 1 else None), 'fog'] icon = [time, ('cloudy' if cloudDensity > 1 else None), 'fog']
icon_key = "Weather_{}".format('-'.join(filter(None.__ne__, icon))) icon_key = "Weather_{}".format('-'.join(filter(None.__ne__, icon)))
icon = CONST.ICONS.get(icon_key) or CONST.ICONS['Weather_night-partly-cloudy'] icon = CONST.ICONS.get(icon_key) or CONST.ICONS['Weather_night-partly-cloudy']
self.weather_icon.setPixmap(icon) self.weather_icon.setPixmap(icon)

View File

@ -1,3 +1,6 @@
import logging
import timeit
from datetime import timedelta
from typing import List, Optional from typing import List, Optional
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
@ -130,9 +133,12 @@ class QTopPanel(QFrame):
self.subwindow.show() self.subwindow.show()
def passTurn(self): def passTurn(self):
start = timeit.default_timer()
self.game.pass_turn(no_action=True) self.game.pass_turn(no_action=True)
GameUpdateSignal.get_instance().updateGame(self.game) GameUpdateSignal.get_instance().updateGame(self.game)
self.proceedButton.setEnabled(True) self.proceedButton.setEnabled(True)
end = timeit.default_timer()
logging.info("Skipping turn took %s", timedelta(seconds=end - start))
def negative_start_packages(self) -> List[Package]: def negative_start_packages(self) -> List[Package]:
packages = [] packages = []

View File

@ -60,16 +60,15 @@ class FlightDelegate(QStyledItemDelegate):
def first_row_text(self, index: QModelIndex) -> str: def first_row_text(self, index: QModelIndex) -> str:
flight = self.flight(index) flight = self.flight(index)
task = flight.flight_type
count = flight.count
name = db.unit_type_name(flight.unit_type)
estimator = TotEstimator(self.package) estimator = TotEstimator(self.package)
delay = estimator.mission_start_time(flight) delay = estimator.mission_start_time(flight)
return f"[{task}] {count} x {name} in {delay}" return f"{flight} in {delay}"
def second_row_text(self, index: QModelIndex) -> str: def second_row_text(self, index: QModelIndex) -> str:
flight = self.flight(index) flight = self.flight(index)
origin = flight.from_cp.name origin = flight.from_cp.name
if flight.arrival != flight.departure:
return f"From {origin} to {flight.arrival.name}"
return f"From {origin}" return f"From {origin}"
def paint(self, painter: QPainter, option: QStyleOptionViewItem, def paint(self, painter: QPainter, option: QStyleOptionViewItem,

View File

@ -5,12 +5,50 @@ from PySide2.QtWidgets import QComboBox
from dcs.unittype import FlyingType from dcs.unittype import FlyingType
from gen.flights.flight import FlightType
import gen.flights.ai_flight_planner_db
from game import Game, db
class QAircraftTypeSelector(QComboBox): class QAircraftTypeSelector(QComboBox):
"""Combo box for selecting among the given aircraft types.""" """Combo box for selecting among the given aircraft types."""
def __init__(self, aircraft_types: Iterable[Type[FlyingType]]) -> None: def __init__(self, aircraft_types: Iterable[Type[FlyingType]], country: str, mission_type: str) -> None:
super().__init__() super().__init__()
for aircraft in aircraft_types:
self.addItem(f"{aircraft.id}", userData=aircraft)
self.model().sort(0) self.model().sort(0)
self.setSizeAdjustPolicy(self.AdjustToContents)
self.country = country
self.updateItems(mission_type, aircraft_types)
def updateItems(self, mission_type: str, aircraft_types):
current_aircraft = self.currentData()
self.clear()
for aircraft in aircraft_types:
if mission_type in [FlightType.BARCAP, FlightType.ESCORT, FlightType.INTERCEPTION, FlightType.SWEEP, FlightType.TARCAP]:
if aircraft in gen.flights.ai_flight_planner_db.CAP_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.CAS, FlightType.BAI, FlightType.OCA_AIRCRAFT]:
if aircraft in gen.flights.ai_flight_planner_db.CAS_CAPABLE or aircraft in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.SEAD]:
if aircraft in gen.flights.ai_flight_planner_db.SEAD_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.DEAD]:
if aircraft in gen.flights.ai_flight_planner_db.DEAD_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.STRIKE]:
if aircraft in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE or aircraft in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.ANTISHIP]:
if aircraft in gen.flights.ai_flight_planner_db.ANTISHIP_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
elif mission_type in [FlightType.OCA_RUNWAY]:
if aircraft in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE:
self.addItem(f"{db.unit_get_expanded_info(self.country, aircraft, 'name')}", userData=aircraft)
current_aircraft_index = self.findData(current_aircraft)
if current_aircraft_index != -1:
self.setCurrentIndex(current_aircraft_index)
if self.count() == 0:
self.addItem("No capable aircraft available", userData=None)

View File

@ -27,6 +27,7 @@ class QOriginAirfieldSelector(QComboBox):
self.aircraft = aircraft self.aircraft = aircraft
self.rebuild_selector() self.rebuild_selector()
self.currentIndexChanged.connect(self.index_changed) self.currentIndexChanged.connect(self.index_changed)
self.setSizeAdjustPolicy(self.AdjustToContents)
def change_aircraft(self, aircraft: FlyingType) -> None: def change_aircraft(self, aircraft: FlyingType) -> None:
if self.aircraft == aircraft: if self.aircraft == aircraft:

View File

@ -2,6 +2,7 @@ from PySide2.QtGui import QStandardItem, QStandardItemModel
from game import Game from game import Game
from game.theater import ControlPointType from game.theater import ControlPointType
from game.utils import Distance
from gen import BuildingGroundObject, Conflict, FlightWaypointType from gen import BuildingGroundObject, Conflict, FlightWaypointType
from gen.flights.flight import FlightWaypoint from gen.flights.flight import FlightWaypoint
from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox from qt_ui.widgets.combos.QFilteredComboBox import QFilteredComboBox
@ -59,7 +60,7 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox):
FlightWaypointType.CUSTOM, FlightWaypointType.CUSTOM,
pos.x, pos.x,
pos.y, pos.y,
800) Distance.from_meters(800))
wpt.name = "Frontline " + cp.name + "/" + ecp.name + " [CAS]" wpt.name = "Frontline " + cp.name + "/" + ecp.name + " [CAS]"
wpt.alt_type = "RADIO" wpt.alt_type = "RADIO"
wpt.pretty_name = wpt.name wpt.pretty_name = wpt.name
@ -70,12 +71,12 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox):
for cp in self.game.theater.controlpoints: for cp in self.game.theater.controlpoints:
if (self.include_enemy and not cp.captured) or (self.include_friendly and cp.captured): if (self.include_enemy and not cp.captured) or (self.include_friendly and cp.captured):
for ground_object in cp.ground_objects: for ground_object in cp.ground_objects:
if not ground_object.is_dead and not isinstance(ground_object, BuildingGroundObject): if not ground_object.is_dead and isinstance(ground_object, BuildingGroundObject):
wpt = FlightWaypoint( wpt = FlightWaypoint(
FlightWaypointType.CUSTOM, FlightWaypointType.CUSTOM,
ground_object.position.x, ground_object.position.x,
ground_object.position.y, ground_object.position.y,
0 Distance.from_meters(0)
) )
wpt.alt_type = "RADIO" wpt.alt_type = "RADIO"
wpt.name = ground_object.waypoint_name wpt.name = ground_object.waypoint_name
@ -99,7 +100,7 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox):
FlightWaypointType.CUSTOM, FlightWaypointType.CUSTOM,
u.position.x, u.position.x,
u.position.y, u.position.y,
0 Distance.from_meters(0)
) )
wpt.alt_type = "RADIO" wpt.alt_type = "RADIO"
wpt.name = wpt.name = "[" + str(ground_object.obj_name) + "] : " + u.type + " #" + str(j) wpt.name = wpt.name = "[" + str(ground_object.obj_name) + "] : " + u.type + " #" + str(j)
@ -120,7 +121,7 @@ class QPredefinedWaypointSelectionComboBox(QFilteredComboBox):
FlightWaypointType.CUSTOM, FlightWaypointType.CUSTOM,
cp.position.x, cp.position.x,
cp.position.y, cp.position.y,
0 Distance.from_meters(0)
) )
wpt.alt_type = "RADIO" wpt.alt_type = "RADIO"
wpt.name = cp.name wpt.name = cp.name

View File

@ -58,6 +58,7 @@ class QFrontLine(QGraphicsLineItem):
new_package_action.triggered.connect(self.open_new_package_dialog) new_package_action.triggered.connect(self.open_new_package_dialog)
menu.addAction(new_package_action) menu.addAction(new_package_action)
if self.game_model.game.settings.enable_frontline_cheats:
cheat_forward = QAction(f"CHEAT: Advance Frontline") cheat_forward = QAction(f"CHEAT: Advance Frontline")
cheat_forward.triggered.connect(self.cheat_forward) cheat_forward.triggered.connect(self.cheat_forward)
menu.addAction(cheat_forward) menu.addAction(cheat_forward)

View File

@ -3,10 +3,11 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
import math import math
from functools import singledispatchmethod
from typing import Iterable, Iterator, List, Optional, Tuple from typing import Iterable, Iterator, List, Optional, Tuple
from PySide2 import QtWidgets, QtCore from PySide2 import QtCore, QtWidgets
from PySide2.QtCore import QPointF, Qt, QLineF, QRectF from PySide2.QtCore import QLineF, QPointF, QRectF, Qt
from PySide2.QtGui import ( from PySide2.QtGui import (
QBrush, QBrush,
QColor, QColor,
@ -14,30 +15,52 @@ from PySide2.QtGui import (
QPen, QPen,
QPixmap, QPixmap,
QPolygonF, QPolygonF,
QWheelEvent, ) QWheelEvent,
)
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QFrame, QFrame,
QGraphicsItem, QGraphicsItem,
QGraphicsOpacityEffect, QGraphicsOpacityEffect,
QGraphicsScene, QGraphicsScene,
QGraphicsView, QGraphicsSceneMouseEvent, QGraphicsSceneMouseEvent,
QGraphicsView,
) )
from dcs import Point from dcs import Point
from dcs.planes import F_16C_50
from dcs.mapping import point_from_heading from dcs.mapping import point_from_heading
from dcs.unitgroup import Group
from shapely.geometry import (
LineString,
MultiPolygon,
Point as ShapelyPoint,
Polygon,
)
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game import Game, db from game import Game
from game.navmesh import NavMesh
from game.theater import ControlPoint, Enum from game.theater import ControlPoint, Enum
from game.theater.conflicttheater import FrontLine, ReferencePoint from game.theater.conflicttheater import FrontLine, ReferencePoint
from game.theater.theatergroundobject import ( from game.theater.theatergroundobject import (
TheaterGroundObject, TheaterGroundObject,
) )
from game.utils import meter_to_feet, nm_to_meter, meter_to_nm from game.utils import Distance, meters, nautical_miles
from game.weather import TimeOfDay from game.weather import TimeOfDay
from gen import Conflict from gen import Conflict, Package
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType from gen.flights.flight import (
from gen.flights.flightplan import FlightPlan Flight,
from qt_ui.displayoptions import DisplayOptions FlightType,
FlightWaypoint,
FlightWaypointType,
)
from gen.flights.flightplan import (
BarCapFlightPlan,
FlightPlan,
FlightPlanBuilder,
InvalidObjectiveLocation,
)
from gen.flights.traveltime import TotEstimator
from qt_ui.displayoptions import DisplayOptions, ThreatZoneOptions
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.widgets.map.QFrontLine import QFrontLine from qt_ui.widgets.map.QFrontLine import QFrontLine
from qt_ui.widgets.map.QLiberationScene import QLiberationScene from qt_ui.widgets.map.QLiberationScene import QLiberationScene
@ -45,7 +68,7 @@ from qt_ui.widgets.map.QMapControlPoint import QMapControlPoint
from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject from qt_ui.widgets.map.QMapGroundObject import QMapGroundObject
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
MAX_SHIP_DISTANCE = 80 MAX_SHIP_DISTANCE = nautical_miles(80)
def binomial(i: int, n: int) -> float: def binomial(i: int, n: int) -> float:
"""Binomial coefficient""" """Binomial coefficient"""
@ -157,6 +180,9 @@ class QLiberationMap(QGraphicsView):
self.nm_to_pixel_ratio: int = 0 self.nm_to_pixel_ratio: int = 0
self.navmesh_highlight: Optional[QPolygonF] = None
self.shortest_path_segments: List[QLineF] = []
def init_scene(self): def init_scene(self):
scene = QLiberationScene(self) scene = QLiberationScene(self)
@ -171,7 +197,7 @@ class QLiberationMap(QGraphicsView):
self.game = game self.game = game
if self.game is not None: if self.game is not None:
logging.debug("Reloading Map Canvas") logging.debug("Reloading Map Canvas")
self.nm_to_pixel_ratio = self.km_to_pixel(float(nm_to_meter(1)) / 1000.0) self.nm_to_pixel_ratio = self.distance_to_pixels(nautical_miles(1))
self.reload_scene() self.reload_scene()
""" """
@ -239,29 +265,6 @@ class QLiberationMap(QGraphicsView):
def update_reference_point(point: ReferencePoint, change: Point) -> None: def update_reference_point(point: ReferencePoint, change: Point) -> None:
point.image_coordinates += change point.image_coordinates += change
@staticmethod
def aa_ranges(ground_object: TheaterGroundObject) -> Tuple[int, int]:
detection_range = 0
threat_range = 0
for g in ground_object.groups:
for u in g.units:
unit = db.unit_type_from_name(u.type)
if unit is None:
logging.error(f"Unknown unit type {u.type}")
continue
# Some units in pydcs have detection_range and threat_range
# defined, but explicitly set to None.
unit_detection_range = getattr(unit, "detection_range", None)
if unit_detection_range is not None:
detection_range = max(detection_range, unit_detection_range)
unit_threat_range = getattr(unit, "threat_range", None)
if unit_threat_range is not None:
threat_range = max(threat_range, unit_threat_range)
return detection_range, threat_range
def display_culling(self, scene: QGraphicsScene) -> None: def display_culling(self, scene: QGraphicsScene) -> None:
"""Draws the culling distance rings on the map""" """Draws the culling distance rings on the map"""
culling_points = self.game_model.game.get_culling_points() culling_points = self.game_model.game.get_culling_points()
@ -273,19 +276,186 @@ class QLiberationMap(QGraphicsView):
radius = distance_point[0] - transformed[0] radius = distance_point[0] - transformed[0]
scene.addEllipse(transformed[0]-radius, transformed[1]-radius, 2*radius, 2*radius, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"]) scene.addEllipse(transformed[0]-radius, transformed[1]-radius, 2*radius, 2*radius, CONST.COLORS["transparent"], CONST.COLORS["light_green_transparent"])
def draw_shapely_poly(self, scene: QGraphicsScene, poly: Polygon, pen: QPen,
brush: QBrush) -> Optional[QPolygonF]:
if poly.is_empty:
return None
points = []
for x, y in poly.exterior.coords:
x, y = self._transform_point(Point(x, y))
points.append(QPointF(x, y))
return scene.addPolygon(QPolygonF(points), pen, brush)
def draw_threat_zone(self, scene: QGraphicsScene, poly: Polygon,
player: bool) -> None:
if player:
brush = QColor(0, 132, 255, 100)
else:
brush = QColor(227, 32, 0, 100)
self.draw_shapely_poly(scene, poly, CONST.COLORS["transparent"], brush)
def display_threat_zones(self, scene: QGraphicsScene,
options: ThreatZoneOptions, player: bool) -> None:
"""Draws the threat zones on the map."""
threat_zones = self.game.threat_zone_for(player)
if options.all:
threat_poly = threat_zones.all
elif options.aircraft:
threat_poly = threat_zones.airbases
elif options.air_defenses:
threat_poly = threat_zones.air_defenses
else:
return
if isinstance(threat_poly, MultiPolygon):
polys = threat_poly.geoms
else:
polys = [threat_poly]
for poly in polys:
self.draw_threat_zone(scene, poly, player)
def draw_navmesh_neighbor_line(self, scene: QGraphicsScene, poly: Polygon,
begin: ShapelyPoint) -> None:
vertex = Point(begin.x, begin.y)
centroid = poly.centroid
direction = Point(centroid.x, centroid.y)
end = vertex.point_from_heading(vertex.heading_between_point(direction),
nautical_miles(2).meters)
scene.addLine(QLineF(QPointF(*self._transform_point(vertex)),
QPointF(*self._transform_point(end))),
CONST.COLORS["yellow"])
@singledispatchmethod
def draw_navmesh_border(self, intersection, scene: QGraphicsScene,
poly: Polygon) -> None:
raise NotImplementedError("draw_navmesh_border not implemented for %s",
intersection.__class__.__name__)
@draw_navmesh_border.register
def draw_navmesh_point_border(self, intersection: ShapelyPoint,
scene: QGraphicsScene, poly: Polygon) -> None:
# Draw a line from the vertex toward the center of the polygon.
self.draw_navmesh_neighbor_line(scene, poly, intersection)
@draw_navmesh_border.register
def draw_navmesh_edge_border(self, intersection: LineString,
scene: QGraphicsScene, poly: Polygon) -> None:
# Draw a line from the center of the edge toward the center of the
# polygon.
edge_center = intersection.interpolate(0.5, normalized=True)
self.draw_navmesh_neighbor_line(scene, poly, edge_center)
def display_navmesh(self, scene: QGraphicsScene, player: bool) -> None:
for navpoly in self.game.navmesh_for(player).polys:
self.draw_shapely_poly(scene, navpoly.poly, CONST.COLORS["black"],
CONST.COLORS["transparent"])
position = self._transform_point(
Point(navpoly.poly.centroid.x, navpoly.poly.centroid.y))
text = scene.addSimpleText(f"Navmesh {navpoly.ident}",
self.waypoint_info_font)
text.setBrush(QColor(255, 255, 255))
text.setPen(QColor(255, 255, 255))
text.moveBy(position[0] + 8, position[1])
text.setZValue(2)
for border in navpoly.neighbors.values():
self.draw_navmesh_border(border, scene, navpoly.poly)
def highlight_mouse_navmesh(self, scene: QGraphicsScene, navmesh: NavMesh,
mouse_position: Point) -> None:
if self.navmesh_highlight is not None:
try:
scene.removeItem(self.navmesh_highlight)
except RuntimeError:
pass
navpoly = navmesh.localize(mouse_position)
if navpoly is None:
return
self.navmesh_highlight = self.draw_shapely_poly(
scene, navpoly.poly, CONST.COLORS["transparent"],
CONST.COLORS["light_green_transparent"])
def draw_shortest_path(self, scene: QGraphicsScene, navmesh: NavMesh,
destination: Point, player: bool) -> None:
for line in self.shortest_path_segments:
try:
scene.removeItem(line)
except RuntimeError:
pass
if player:
origin = self.game.theater.player_points()[0]
else:
origin = self.game.theater.enemy_points()[0]
prev_pos = self._transform_point(origin.position)
try:
path = navmesh.shortest_path(origin.position, destination)
except ValueError:
return
for waypoint in path[1:]:
new_pos = self._transform_point(waypoint)
flight_path_pen = self.flight_path_pen(player, selected=True)
# Draw the line to the *middle* of the waypoint.
offset = self.WAYPOINT_SIZE // 2
self.shortest_path_segments.append(scene.addLine(
prev_pos[0] + offset, prev_pos[1] + offset,
new_pos[0] + offset, new_pos[1] + offset,
flight_path_pen
))
self.shortest_path_segments.append(scene.addEllipse(
new_pos[0], new_pos[1], self.WAYPOINT_SIZE,
self.WAYPOINT_SIZE, flight_path_pen, flight_path_pen
))
prev_pos = new_pos
def draw_test_flight_plan(self, scene: QGraphicsScene, task: FlightType,
point_near_target: Point, player: bool) -> None:
for line in self.shortest_path_segments:
try:
scene.removeItem(line)
except RuntimeError:
pass
self.clear_flight_paths(scene)
target = self.game.theater.closest_target(point_near_target)
if player:
origin = self.game.theater.player_points()[0]
else:
origin = self.game.theater.enemy_points()[0]
package = Package(target)
flight = Flight(package, F_16C_50, 2, task, start_type="Warm",
departure=origin, arrival=origin, divert=None)
package.add_flight(flight)
planner = FlightPlanBuilder(self.game, package, is_player=player)
try:
planner.populate_flight_plan(flight)
except InvalidObjectiveLocation:
return
package.time_over_target = TotEstimator(package).earliest_tot()
self.draw_flight_plan(scene, flight, selected=True)
@staticmethod @staticmethod
def should_display_ground_objects_at(cp: ControlPoint) -> bool: def should_display_ground_objects_at(cp: ControlPoint) -> bool:
return ((DisplayOptions.sam_ranges and cp.captured) or return ((DisplayOptions.sam_ranges and cp.captured) or
(DisplayOptions.enemy_sam_ranges and not cp.captured)) (DisplayOptions.enemy_sam_ranges and not cp.captured))
def draw_threat_range(self, scene: QGraphicsScene, ground_object: TheaterGroundObject, cp: ControlPoint) -> None: def draw_threat_range(self, scene: QGraphicsScene, group: Group, ground_object: TheaterGroundObject, cp: ControlPoint) -> None:
go_pos = self._transform_point(ground_object.position) go_pos = self._transform_point(ground_object.position)
detection_range, threat_range = self.aa_ranges( detection_range = ground_object.detection_range(group)
ground_object threat_range = ground_object.threat_range(group)
)
if threat_range: if threat_range:
threat_pos = self._transform_point(Point(ground_object.position.x+threat_range, threat_pos = self._transform_point(
ground_object.position.y+threat_range)) ground_object.position + Point(threat_range.meters,
threat_range.meters))
threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos)) threat_radius = Point(*go_pos).distance_to_point(Point(*threat_pos))
# Add threat range circle # Add threat range circle
@ -294,8 +464,9 @@ class QLiberationMap(QGraphicsView):
if detection_range and DisplayOptions.detection_range: if detection_range and DisplayOptions.detection_range:
# Add detection range circle # Add detection range circle
detection_pos = self._transform_point(Point(ground_object.position.x+detection_range, detection_pos = self._transform_point(
ground_object.position.y+detection_range)) ground_object.position + Point(detection_range.meters,
detection_range.meters))
detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos)) detection_radius = Point(*go_pos).distance_to_point(Point(*detection_pos))
scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6, scene.addEllipse(go_pos[0] - detection_radius/2 + 7, go_pos[1] - detection_radius/2 + 6,
detection_radius, detection_radius, self.detection_pen(cp.captured)) detection_radius, detection_radius, self.detection_pen(cp.captured))
@ -313,7 +484,8 @@ class QLiberationMap(QGraphicsView):
should_display = self.should_display_ground_objects_at(cp) should_display = self.should_display_ground_objects_at(cp)
if ground_object.might_have_aa and should_display: if ground_object.might_have_aa and should_display:
self.draw_threat_range(scene, ground_object, cp) for group in ground_object.groups:
self.draw_threat_range(scene, group, ground_object, cp)
added_objects.append(ground_object.obj_name) added_objects.append(ground_object.obj_name)
def reload_scene(self): def reload_scene(self):
@ -329,6 +501,16 @@ class QLiberationMap(QGraphicsView):
if DisplayOptions.culling and self.game.settings.perf_culling: if DisplayOptions.culling and self.game.settings.perf_culling:
self.display_culling(scene) self.display_culling(scene)
self.display_threat_zones(scene, DisplayOptions.blue_threat_zones,
player=True)
self.display_threat_zones(scene, DisplayOptions.red_threat_zones,
player=False)
if DisplayOptions.navmeshes.blue_navmesh:
self.display_navmesh(scene, player=True)
if DisplayOptions.navmeshes.red_navmesh:
self.display_navmesh(scene, player=False)
for cp in self.game.theater.controlpoints: for cp in self.game.theater.controlpoints:
pos = self._transform_point(cp.position) pos = self._transform_point(cp.position)
@ -428,8 +610,30 @@ class QLiberationMap(QGraphicsView):
flight.flight_plan) flight.flight_plan)
prev_pos = tuple(new_pos) prev_pos = tuple(new_pos)
def draw_waypoint(self, scene: QGraphicsScene, position: Tuple[int, int], if selected and DisplayOptions.barcap_commit_range:
player: bool, selected: bool) -> None: self.draw_barcap_commit_range(scene, flight)
def draw_barcap_commit_range(self, scene: QGraphicsScene,
flight: Flight) -> None:
if flight.flight_type is not FlightType.BARCAP:
return
if not isinstance(flight.flight_plan, BarCapFlightPlan):
return
start = flight.flight_plan.patrol_start
end = flight.flight_plan.patrol_end
line = LineString([
ShapelyPoint(start.x, start.y),
ShapelyPoint(end.x, end.y),
])
doctrine = self.game.faction_for(flight.departure.captured).doctrine
bubble = line.buffer(doctrine.cap_engagement_range.meters)
self.flight_path_items.append(self.draw_shapely_poly(
scene, bubble, CONST.COLORS["yellow"], CONST.COLORS["transparent"]
))
def draw_waypoint(self, scene: QGraphicsScene,
position: Tuple[float, float], player: bool,
selected: bool) -> None:
waypoint_pen = self.waypoint_pen(player, selected) waypoint_pen = self.waypoint_pen(player, selected)
waypoint_brush = self.waypoint_brush(player, selected) waypoint_brush = self.waypoint_brush(player, selected)
self.flight_path_items.append(scene.addEllipse( self.flight_path_items.append(scene.addEllipse(
@ -441,7 +645,7 @@ class QLiberationMap(QGraphicsView):
waypoint: FlightWaypoint, position: Tuple[int, int], waypoint: FlightWaypoint, position: Tuple[int, int],
flight_plan: FlightPlan) -> None: flight_plan: FlightPlan) -> None:
altitude = meter_to_feet(waypoint.alt) altitude = int(waypoint.alt.feet)
altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL" altitude_type = "AGL" if waypoint.alt_type == "RADIO" else "MSL"
prefix = "TOT" prefix = "TOT"
@ -472,8 +676,8 @@ class QLiberationMap(QGraphicsView):
item.setZValue(2) item.setZValue(2)
self.flight_path_items.append(item) self.flight_path_items.append(item)
def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[int, int], def draw_flight_path(self, scene: QGraphicsScene, pos0: Tuple[float, float],
pos1: Tuple[int, int], player: bool, pos1: Tuple[float, float], player: bool,
selected: bool) -> None: selected: bool) -> None:
flight_path_pen = self.flight_path_pen(player, selected) flight_path_pen = self.flight_path_pen(player, selected)
# Draw the line to the *middle* of the waypoint. # Draw the line to the *middle* of the waypoint.
@ -567,7 +771,7 @@ class QLiberationMap(QGraphicsView):
BIG_LINE = 5 BIG_LINE = 5
SMALL_LINE = 2 SMALL_LINE = 2
dist = self.km_to_pixel(nm_to_meter(scale_distance_nm)/1000.0) dist = self.distance_to_pixels(nautical_miles(scale_distance_nm))
self.scene().addRect(POS_X, POS_Y-PADDING, PADDING*2 + dist, BIG_LINE*2+3*PADDING, pen=CONST.COLORS["black"], brush=CONST.COLORS["black"]) self.scene().addRect(POS_X, POS_Y-PADDING, PADDING*2 + dist, BIG_LINE*2+3*PADDING, pen=CONST.COLORS["black"], brush=CONST.COLORS["black"])
l = self.scene().addLine(POS_X + PADDING, POS_Y + BIG_LINE*2, POS_X + PADDING + dist, POS_Y + BIG_LINE*2) l = self.scene().addLine(POS_X + PADDING, POS_Y + BIG_LINE*2, POS_X + PADDING + dist, POS_Y + BIG_LINE*2)
@ -663,12 +867,12 @@ class QLiberationMap(QGraphicsView):
Point(offset.x / scale.x, offset.y / scale.y)) Point(offset.x / scale.x, offset.y / scale.y))
return point_a.world_coordinates - scaled return point_a.world_coordinates - scaled
def km_to_pixel(self, km): def distance_to_pixels(self, distance: Distance) -> int:
p1 = Point(0, 0) p1 = Point(0, 0)
p2 = Point(0, 1000*km) p2 = Point(0, distance.meters)
p1a = Point(*self._transform_point(p1)) p1a = Point(*self._transform_point(p1))
p2a = Point(*self._transform_point(p2)) p2a = Point(*self._transform_point(p2))
return p1a.distance_to_point(p2a) return int(p1a.distance_to_point(p2a))
def highlight_color(self, transparent: Optional[bool] = False) -> QColor: def highlight_color(self, transparent: Optional[bool] = False) -> QColor:
return QColor(255, 255, 0, 20 if transparent else 255) return QColor(255, 255, 0, 20 if transparent else 255)
@ -742,7 +946,7 @@ class QLiberationMap(QGraphicsView):
# Polygon display mode # Polygon display mode
if self.game.theater.landmap is not None: if self.game.theater.landmap is not None:
for sea_zone in self.game.theater.landmap[2]: for sea_zone in self.game.theater.landmap.sea_zones:
print(sea_zone) print(sea_zone)
poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in sea_zone.exterior.coords]) poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in sea_zone.exterior.coords])
if self.reference_point_setup_mode: if self.reference_point_setup_mode:
@ -751,14 +955,14 @@ class QLiberationMap(QGraphicsView):
color = "sea_blue" color = "sea_blue"
scene.addPolygon(poly, CONST.COLORS[color], CONST.COLORS[color]) scene.addPolygon(poly, CONST.COLORS[color], CONST.COLORS[color])
for inclusion_zone in self.game.theater.landmap[0]: for inclusion_zone in self.game.theater.landmap.inclusion_zones:
poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in inclusion_zone.exterior.coords]) poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in inclusion_zone.exterior.coords])
if self.reference_point_setup_mode: if self.reference_point_setup_mode:
scene.addPolygon(poly, CONST.COLORS["grey_transparent"], CONST.COLORS["dark_grey_transparent"]) scene.addPolygon(poly, CONST.COLORS["grey_transparent"], CONST.COLORS["dark_grey_transparent"])
else: else:
scene.addPolygon(poly, CONST.COLORS["grey"], CONST.COLORS["dark_grey"]) scene.addPolygon(poly, CONST.COLORS["grey"], CONST.COLORS["dark_grey"])
for exclusion_zone in self.game.theater.landmap[1]: for exclusion_zone in self.game.theater.landmap.exclusion_zones:
poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in exclusion_zone.exterior.coords]) poly = QPolygonF([QPointF(*self._transform_point(Point(point[0], point[1]))) for point in exclusion_zone.exterior.coords])
if self.reference_point_setup_mode: if self.reference_point_setup_mode:
scene.addPolygon(poly, CONST.COLORS["grey_transparent"], CONST.COLORS["dark_dark_grey_transparent"]) scene.addPolygon(poly, CONST.COLORS["grey_transparent"], CONST.COLORS["dark_dark_grey_transparent"])
@ -820,22 +1024,60 @@ class QLiberationMap(QGraphicsView):
distance = self.selected_cp.control_point.position.distance_to_point( distance = self.selected_cp.control_point.position.distance_to_point(
world_destination world_destination
) )
if meter_to_nm(distance) > MAX_SHIP_DISTANCE: if meters(distance) > MAX_SHIP_DISTANCE:
return False return False
return self.game.theater.is_in_sea(world_destination) return self.game.theater.is_in_sea(world_destination)
def sceneMouseMovedEvent(self, event: QGraphicsSceneMouseEvent): def sceneMouseMovedEvent(self, event: QGraphicsSceneMouseEvent):
if self.game is None:
return
mouse_position = Point(event.scenePos().x(), event.scenePos().y())
if self.state == QLiberationMapState.MOVING_UNIT: if self.state == QLiberationMapState.MOVING_UNIT:
self.setCursor(Qt.PointingHandCursor) self.setCursor(Qt.PointingHandCursor)
self.movement_line.setLine( self.movement_line.setLine(
QLineF(self.movement_line.line().p1(), event.scenePos())) QLineF(self.movement_line.line().p1(), event.scenePos()))
pos = Point(event.scenePos().x(), event.scenePos().y()) if self.is_valid_ship_pos(mouse_position):
if self.is_valid_ship_pos(pos):
self.movement_line.setPen(CONST.COLORS["green"]) self.movement_line.setPen(CONST.COLORS["green"])
else: else:
self.movement_line.setPen(CONST.COLORS["red"]) self.movement_line.setPen(CONST.COLORS["red"])
mouse_world_pos = self._scene_to_dcs_coords(mouse_position)
if DisplayOptions.navmeshes.blue_navmesh:
self.highlight_mouse_navmesh(
self.scene(), self.game.blue_navmesh,
self._scene_to_dcs_coords(mouse_position))
if DisplayOptions.path_debug.shortest_path:
self.draw_shortest_path(self.scene(), self.game.blue_navmesh,
mouse_world_pos, player=True)
if DisplayOptions.navmeshes.red_navmesh:
self.highlight_mouse_navmesh(
self.scene(), self.game.red_navmesh, mouse_world_pos)
debug_blue = DisplayOptions.path_debug_faction.blue
if DisplayOptions.path_debug.shortest_path:
self.draw_shortest_path(
self.scene(), self.game.navmesh_for(player=debug_blue),
mouse_world_pos, player=False)
elif not DisplayOptions.path_debug.hide:
if DisplayOptions.path_debug.barcap:
task = FlightType.BARCAP
elif DisplayOptions.path_debug.cas:
task = FlightType.CAS
elif DisplayOptions.path_debug.sweep:
task = FlightType.SWEEP
elif DisplayOptions.path_debug.strike:
task = FlightType.STRIKE
elif DisplayOptions.path_debug.tarcap:
task = FlightType.TARCAP
else:
raise ValueError(
"Unexpected value for DisplayOptions.path_debug")
self.draw_test_flight_plan(self.scene(), task, mouse_world_pos,
player=debug_blue)
def sceneMousePressEvent(self, event: QGraphicsSceneMouseEvent): def sceneMousePressEvent(self, event: QGraphicsSceneMouseEvent):
if self.state == QLiberationMapState.MOVING_UNIT: if self.state == QLiberationMapState.MOVING_UNIT:
if event.buttons() == Qt.RightButton: if event.buttons() == Qt.RightButton:

View File

@ -89,7 +89,7 @@ class QMapControlPoint(QMapObject):
return return
for connected in self.control_point.connected_points: for connected in self.control_point.connected_points:
if connected.captured: if connected.captured and self.game_model.game.settings.enable_base_capture_cheat:
menu.addAction(self.capture_action) menu.addAction(self.capture_action)
break break

View File

@ -58,9 +58,11 @@ class QMapGroundObject(QMapObject):
@property @property
def production_per_turn(self) -> int: def production_per_turn(self) -> int:
production = 0 production = 0
for g in self.control_point.ground_objects: for building in self.buildings:
if g.category in REWARDS.keys(): if building.is_dead:
production += REWARDS[g.category] continue
if building.category in REWARDS.keys():
production += REWARDS[building.category]
return production return production
def paint(self, painter, option, widget=None) -> None: def paint(self, painter, option, widget=None) -> None:
@ -85,9 +87,21 @@ class QMapGroundObject(QMapObject):
is_dead = False is_dead = False
break break
if cat == "aa":
has_threat = False
for group in self.ground_object.groups:
if self.ground_object.threat_range(group).distance_in_meters > 0:
has_threat = True
if not is_dead and not self.control_point.captured: if not is_dead and not self.control_point.captured:
if cat == "aa" and not has_threat:
painter.drawPixmap(rect, const.ICONS["nothreat" + enemy_icons])
else:
painter.drawPixmap(rect, const.ICONS[cat + enemy_icons]) painter.drawPixmap(rect, const.ICONS[cat + enemy_icons])
elif not is_dead: elif not is_dead:
if cat == "aa" and not has_threat:
painter.drawPixmap(rect, const.ICONS["nothreat" + player_icons])
else:
painter.drawPixmap(rect, const.ICONS[cat + player_icons]) painter.drawPixmap(rect, const.ICONS[cat + player_icons])
else: else:
painter.drawPixmap(rect, const.ICONS["destroyed"]) painter.drawPixmap(rect, const.ICONS["destroyed"])

View File

@ -52,7 +52,7 @@ class QDebriefingWindow(QDialog):
for unit_type, count in player_air_losses.items(): for unit_type, count in player_air_losses.items():
try: try:
lostUnitsLayout.addWidget( lostUnitsLayout.addWidget(
QLabel(db.unit_type_name(unit_type)), row, 0) QLabel(db.unit_get_expanded_info(self.debriefing.player_country, unit_type, 'name')), row, 0)
lostUnitsLayout.addWidget(QLabel(str(count)), row, 1) lostUnitsLayout.addWidget(QLabel(str(count)), row, 1)
row += 1 row += 1
except AttributeError: except AttributeError:
@ -94,7 +94,7 @@ class QDebriefingWindow(QDialog):
for unit_type, count in enemy_air_losses.items(): for unit_type, count in enemy_air_losses.items():
try: try:
enemylostUnitsLayout.addWidget( enemylostUnitsLayout.addWidget(
QLabel(db.unit_type_name(unit_type)), row, 0) QLabel(db.unit_get_expanded_info(self.debriefing.enemy_country, unit_type, 'name')), row, 0)
enemylostUnitsLayout.addWidget(QLabel(str(count)), row, 1) enemylostUnitsLayout.addWidget(QLabel(str(count)), row, 1)
row += 1 row += 1
except AttributeError: except AttributeError:

View File

@ -168,16 +168,19 @@ class QLiberationWindow(QMainWindow):
displayMenu = self.menu.addMenu("&Display") displayMenu = self.menu.addMenu("&Display")
last_was_group = True
last_was_group = False
for item in DisplayOptions.menu_items(): for item in DisplayOptions.menu_items():
if isinstance(item, DisplayRule): if isinstance(item, DisplayRule):
if last_was_group:
displayMenu.addSeparator()
self.display_bar.addSeparator()
action = self.make_display_rule_action(item) action = self.make_display_rule_action(item)
displayMenu.addAction(action) displayMenu.addAction(action)
if action.icon(): if action.icon():
self.display_bar.addAction(action) self.display_bar.addAction(action)
last_was_group = False last_was_group = False
elif isinstance(item, DisplayGroup): elif isinstance(item, DisplayGroup):
if not last_was_group:
displayMenu.addSeparator() displayMenu.addSeparator()
self.display_bar.addSeparator() self.display_bar.addSeparator()
group = QActionGroup(displayMenu) group = QActionGroup(displayMenu)
@ -257,8 +260,6 @@ class QLiberationWindow(QMainWindow):
def setGame(self, game: Optional[Game]): def setGame(self, game: Optional[Game]):
try: try:
if game is not None:
game.on_load()
self.game = game self.game = game
if self.info_panel is not None: if self.info_panel is not None:
self.info_panel.setGame(game) self.info_panel.setGame(game)

View File

@ -0,0 +1,118 @@
import logging
from typing import Type
from PySide2 import QtCore
from PySide2.QtCore import Qt
from PySide2.QtGui import QIcon, QMovie, QPixmap
from PySide2.QtWidgets import (
QDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTextBrowser,
QFrame,
)
from jinja2 import Environment, FileSystemLoader, select_autoescape
from dcs.unittype import UnitType, FlyingType, VehicleType
import dcs
from qt_ui.uiconstants import AIRCRAFT_BANNERS, VEHICLE_BANNERS
from game.game import Game
from game import db
import gen.flights.ai_flight_planner_db
from gen.flights.flight import FlightType
class QUnitInfoWindow(QDialog):
def __init__(self, game: Game, unit_type: Type[UnitType]) -> None:
super(QUnitInfoWindow, self).__init__()
self.setModal(True)
self.game = game
self.unit_type = unit_type
self.setWindowTitle(f"Unit Info: {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'name')}")
self.setWindowIcon(QIcon("./resources/icon.png"))
self.setMinimumHeight(570)
self.setMaximumWidth(640)
self.setWindowFlags(Qt.WindowStaysOnTopHint)
self.initUi()
def initUi(self):
self.layout = QGridLayout()
header = QLabel(self)
header.setGeometry(0, 0, 720, 360)
if dcs.planes.plane_map.get(self.unit_type.id) is not None or dcs.helicopters.helicopter_map.get(self.unit_type.id) is not None:
pixmap = AIRCRAFT_BANNERS.get(self.unit_type.id)
elif dcs.vehicles.vehicle_map.get(self.unit_type.id) is not None:
pixmap = VEHICLE_BANNERS.get(self.unit_type.id)
if pixmap is None:
pixmap = AIRCRAFT_BANNERS.get("Missing")
header.setPixmap(pixmap.scaled(header.width(), header.height()))
self.layout.addWidget(header, 0, 0)
self.gridLayout = QGridLayout()
# Build the topmost details grid.
self.details_grid = QFrame()
self.details_grid_layout = QGridLayout()
self.details_grid_layout.setMargin(0)
self.name_box = QLabel(f"<b>Name:</b> {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'manufacturer')} {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'name')}")
self.name_box.setProperty("style", "info-element")
self.country_box = QLabel(f"<b>Country of Origin:</b> {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'country-of-origin')}")
self.country_box.setProperty("style", "info-element")
self.role_box = QLabel(f"<b>Role:</b> {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'role')}")
self.role_box.setProperty("style", "info-element")
self.year_box = QLabel(f"<b>Variant Introduction:</b> {db.unit_get_expanded_info(self.game.player_country, self.unit_type, 'year-of-variant-introduction')}")
self.year_box.setProperty("style", "info-element")
self.details_grid_layout.addWidget(self.name_box, 0, 0)
self.details_grid_layout.addWidget(self.country_box, 0, 1)
self.details_grid_layout.addWidget(self.role_box, 1, 0)
self.details_grid_layout.addWidget(self.year_box, 1, 1)
self.details_grid.setLayout(self.details_grid_layout)
self.gridLayout.addWidget(self.details_grid, 1, 0)
# If it's an aircraft, include the task list.
if dcs.planes.plane_map.get(self.unit_type.id) is not None or dcs.helicopters.helicopter_map.get(self.unit_type.id) is not None:
self.tasks_box = QLabel(f"<b>In-Game Tasks:</b> {self.generateAircraftTasks()}")
self.tasks_box.setProperty("style", "info-element")
self.gridLayout.addWidget(self.tasks_box, 2, 0)
# Finally, add the description box.
self.details_text = QTextBrowser()
self.details_text.setProperty("style", "info-desc")
self.details_text.setText(db.unit_get_expanded_info(self.game.player_country, self.unit_type, "text"))
self.gridLayout.addWidget(self.details_text, 3, 0)
self.layout.addLayout(self.gridLayout, 1, 0)
self.setLayout(self.layout)
def generateAircraftTasks(self) -> str:
aircraft_tasks = ""
if self.unit_type in gen.flights.ai_flight_planner_db.CAP_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.BARCAP}, {FlightType.ESCORT}, {FlightType.INTERCEPTION}, {FlightType.SWEEP}, {FlightType.TARCAP}, "
if self.unit_type in gen.flights.ai_flight_planner_db.CAS_CAPABLE or self.unit_type in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.CAS}, {FlightType.BAI}, {FlightType.OCA_AIRCRAFT}, "
if self.unit_type in gen.flights.ai_flight_planner_db.SEAD_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.SEAD}, "
if self.unit_type in gen.flights.ai_flight_planner_db.DEAD_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.DEAD}, "
if self.unit_type in gen.flights.ai_flight_planner_db.ANTISHIP_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.ANTISHIP}, "
if self.unit_type in gen.flights.ai_flight_planner_db.RUNWAY_ATTACK_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.OCA_RUNWAY}, "
if self.unit_type in gen.flights.ai_flight_planner_db.STRIKE_CAPABLE or self.unit_type in gen.flights.ai_flight_planner_db.TRANSPORT_CAPABLE:
aircraft_tasks = aircraft_tasks + f"{FlightType.STRIKE}, "
return aircraft_tasks[:-2]

View File

@ -2,6 +2,8 @@ from __future__ import annotations
import json import json
import os import os
import timeit
from datetime import timedelta
from PySide2 import QtCore from PySide2 import QtCore
from PySide2.QtCore import QObject, Qt, Signal from PySide2.QtCore import QObject, Qt, Signal
@ -184,11 +186,14 @@ class QWaitingForMissionResultWindow(QDialog):
lambda d: self.on_debriefing_update(d), self.game, self.unit_map) lambda d: self.on_debriefing_update(d), self.game, self.unit_map)
def process_debriefing(self): def process_debriefing(self):
start = timeit.default_timer()
self.game.finish_event(event=self.gameEvent, debriefing=self.debriefing) self.game.finish_event(event=self.gameEvent, debriefing=self.debriefing)
self.game.pass_turn() self.game.pass_turn()
GameUpdateSignal.get_instance().sendDebriefing(self.debriefing) GameUpdateSignal.get_instance().sendDebriefing(self.debriefing)
GameUpdateSignal.get_instance().updateGame(self.game) GameUpdateSignal.get_instance().updateGame(self.game)
end = timeit.default_timer()
logging.info("Turn processing took %s", timedelta(seconds=end - start))
self.close() self.close()
def debriefing_directory_location(self) -> str: def debriefing_directory_location(self) -> str:

View File

@ -1,6 +1,7 @@
import logging import logging
from typing import Type from typing import Type
from PySide2.QtCore import Qt
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
@ -17,6 +18,7 @@ from game.event import UnitsDeliveryEvent
from game.theater import ControlPoint from game.theater import ControlPoint
from qt_ui.models import GameModel from qt_ui.models import GameModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.QUnitInfoWindow import QUnitInfoWindow
class QRecruitBehaviour: class QRecruitBehaviour:
@ -36,7 +38,6 @@ class QRecruitBehaviour:
@property @property
def pending_deliveries(self) -> UnitsDeliveryEvent: def pending_deliveries(self) -> UnitsDeliveryEvent:
assert self.cp.pending_unit_deliveries
return self.cp.pending_unit_deliveries return self.cp.pending_unit_deliveries
@property @property
@ -59,7 +60,7 @@ class QRecruitBehaviour:
existing_units = self.cp.base.total_units_of_type(unit_type) existing_units = self.cp.base.total_units_of_type(unit_type)
scheduled_units = self.pending_deliveries.units.get(unit_type, 0) scheduled_units = self.pending_deliveries.units.get(unit_type, 0)
unitName = QLabel("<b>" + db.unit_type_name_2(unit_type) + "</b>") unitName = QLabel("<b>" + db.unit_get_expanded_info(self.game_model.game.player_country, unit_type, 'name') + "</b>")
unitName.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)) unitName.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding))
existing_units = QLabel(str(existing_units)) existing_units = QLabel(str(existing_units))
@ -97,6 +98,21 @@ class QRecruitBehaviour:
sell.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) sell.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
sell.clicked.connect(lambda: self.sell(unit_type)) sell.clicked.connect(lambda: self.sell(unit_type))
info = QGroupBox()
info.setProperty("style", "buy-box")
info.setMaximumHeight(36)
info.setMinimumHeight(36)
infolayout = QHBoxLayout()
info.setLayout(infolayout)
unitInfo = QPushButton("i")
unitInfo.setProperty("style", "btn-info")
unitInfo.setDisabled(disabled)
unitInfo.setMinimumSize(16, 16)
unitInfo.setMaximumSize(16, 16)
unitInfo.clicked.connect(lambda: self.info(unit_type))
unitInfo.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
existLayout.addWidget(unitName) existLayout.addWidget(unitName)
existLayout.addItem(QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum)) existLayout.addItem(QSpacerItem(20, 0, QSizePolicy.Minimum, QSizePolicy.Minimum))
existLayout.addWidget(existing_units) existLayout.addWidget(existing_units)
@ -107,8 +123,11 @@ class QRecruitBehaviour:
buysellayout.addWidget(amount_bought) buysellayout.addWidget(amount_bought)
buysellayout.addWidget(buy) buysellayout.addWidget(buy)
infolayout.addWidget(unitInfo)
layout.addWidget(exist, row, 1) layout.addWidget(exist, row, 1)
layout.addWidget(buysell, row, 2) layout.addWidget(buysell, row, 2)
layout.addWidget(info, row, 3)
return row + 1 return row + 1
@ -128,7 +147,7 @@ class QRecruitBehaviour:
def buy(self, unit_type: Type[UnitType]): def buy(self, unit_type: Type[UnitType]):
price = db.PRICES[unit_type] price = db.PRICES[unit_type]
if self.budget >= price: if self.budget >= price:
self.pending_deliveries.deliver({unit_type: 1}) self.pending_deliveries.order({unit_type: 1})
self.budget -= price self.budget -= price
else: else:
# TODO : display modal warning # TODO : display modal warning
@ -137,20 +156,19 @@ class QRecruitBehaviour:
self.update_available_budget() self.update_available_budget()
def sell(self, unit_type): def sell(self, unit_type):
if self.pending_deliveries.units.get(unit_type, 0) > 0: if self.pending_deliveries.available_next_turn(unit_type) > 0:
price = db.PRICES[unit_type] price = db.PRICES[unit_type]
self.budget += price self.budget += price
self.pending_deliveries.units[unit_type] = self.pending_deliveries.units[unit_type] - 1 self.pending_deliveries.sell({unit_type: 1})
if self.pending_deliveries.units[unit_type] == 0: if self.pending_deliveries.units[unit_type] == 0:
del self.pending_deliveries.units[unit_type] del self.pending_deliveries.units[unit_type]
elif self.cp.base.total_units_of_type(unit_type) > 0:
price = db.PRICES[unit_type]
self.budget += price
self.cp.base.commit_losses({unit_type: 1})
self._update_count_label(unit_type) self._update_count_label(unit_type)
self.update_available_budget() self.update_available_budget()
def info(self, unit_type):
self.info_window = QUnitInfoWindow(self.game_model.game, unit_type)
self.info_window.show()
def set_maximum_units(self, maximum_units): def set_maximum_units(self, maximum_units):
""" """
Set the maximum number of units that can be bought Set the maximum number of units that can be bought

View File

@ -65,7 +65,7 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
continue continue
unit_types.add(unit) unit_types.add(unit)
sorted_units = sorted(unit_types, key=lambda u: db.unit_type_name_2(u)) sorted_units = sorted(unit_types, key=lambda u: db.unit_get_expanded_info(self.game_model.game.player_country, u, 'name'))
for unit_type in sorted_units: for unit_type in sorted_units:
row = self.add_purchase_row( row = self.add_purchase_row(
unit_type, task_box_layout, row, unit_type, task_box_layout, row,
@ -92,6 +92,12 @@ class QAircraftRecruitmentMenu(QFrame, QRecruitBehaviour):
self, "No space for additional aircraft", self, "No space for additional aircraft",
f"There is no parking space left at {self.cp.name} to accommodate another plane.", QMessageBox.Ok) f"There is no parking space left at {self.cp.name} to accommodate another plane.", QMessageBox.Ok)
return return
# If we change our mind about selling, we want the aircraft to be put
# back in the inventory immediately.
elif self.pending_deliveries.units.get(unit_type, 0) < 0:
global_inventory = self.game_model.game.aircraft_inventory
inventory = global_inventory.for_control_point(self.cp)
inventory.add_aircraft(unit_type, 1)
super().buy(unit_type) super().buy(unit_type)
self.hangar_status.update_label() self.hangar_status.update_label()

View File

@ -17,6 +17,7 @@ class QAirfieldCommand(QFrame):
def init_ui(self): def init_ui(self):
layout = QGridLayout() layout = QGridLayout()
layout.setHorizontalSpacing(1)
layout.addWidget(QAircraftRecruitmentMenu(self.cp, self.game_model), 0, 0) layout.addWidget(QAircraftRecruitmentMenu(self.cp, self.game_model), 0, 0)
planned = QGroupBox("Planned Flights") planned = QGroupBox("Planned Flights")

View File

@ -5,8 +5,10 @@ from PySide2.QtWidgets import (
QScrollArea, QScrollArea,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
QMessageBox,
) )
from dcs.task import PinpointStrike from dcs.task import PinpointStrike
from dcs.unittype import FlyingType, UnitType
from game import db from game import db
from game.theater import ControlPoint from game.theater import ControlPoint
@ -42,7 +44,7 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
for task_type in units.keys(): for task_type in units.keys():
units_column = list(set(units[task_type])) units_column = list(set(units[task_type]))
if len(units_column) == 0: continue if len(units_column) == 0: continue
units_column.sort(key=lambda x: db.PRICES[x]) units_column.sort(key=lambda u: db.unit_get_expanded_info(self.game_model.game.player_country, u, 'name'))
for unit_type in units_column: for unit_type in units_column:
row = self.add_purchase_row(unit_type, task_box_layout, row) row = self.add_purchase_row(unit_type, task_box_layout, row)
stretch = QVBoxLayout() stretch = QVBoxLayout()
@ -57,3 +59,12 @@ class QArmorRecruitmentMenu(QFrame, QRecruitBehaviour):
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
main_layout.addWidget(scroll) main_layout.addWidget(scroll)
self.setLayout(main_layout) self.setLayout(main_layout)
def sell(self, unit_type: UnitType):
if self.pending_deliveries.available_next_turn(unit_type) <= 0:
QMessageBox.critical(
self, "Could not sell ground unit",
f"Attempted to sell one {unit_type.id} at {self.cp.name} "
"but none are available.", QMessageBox.Ok)
return
super().sell(unit_type)

View File

@ -4,7 +4,10 @@ from PySide2.QtWidgets import (
QGroupBox, QGroupBox,
QLabel, QLabel,
QVBoxLayout, QVBoxLayout,
QScrollArea,
QWidget,
) )
from PySide2.QtCore import Qt
from dcs.task import CAP, CAS, Embarking, PinpointStrike from dcs.task import CAP, CAS, Embarking, PinpointStrike
from game import Game, db from game import Game, db
@ -21,10 +24,11 @@ class QIntelInfo(QFrame):
def init_ui(self): def init_ui(self):
layout = QVBoxLayout() layout = QVBoxLayout()
scroll_content = QWidget()
intel = QGroupBox("Intel")
intelLayout = QVBoxLayout() intelLayout = QVBoxLayout()
units = { units = {
CAP: db.find_unittype(CAP, self.game.enemy_name), CAP: db.find_unittype(CAP, self.game.enemy_name),
Embarking: db.find_unittype(Embarking, self.game.enemy_name), Embarking: db.find_unittype(Embarking, self.game.enemy_name),
@ -46,14 +50,19 @@ class QIntelInfo(QFrame):
existing_units = self.cp.base.total_units_of_type(unit_type) existing_units = self.cp.base.total_units_of_type(unit_type)
if existing_units == 0: if existing_units == 0:
continue continue
groupLayout.addWidget(QLabel("<b>" + db.unit_type_name(unit_type) + "</b>"), row, 0) groupLayout.addWidget(QLabel("<b>" + db.unit_get_expanded_info(self.game.enemy_country, unit_type, 'name') + "</b>"), row, 0)
groupLayout.addWidget(QLabel(str(existing_units)), row, 1) groupLayout.addWidget(QLabel(str(existing_units)), row, 1)
row += 1 row += 1
intelLayout.addWidget(group) intelLayout.addWidget(group)
intelLayout.addStretch() scroll_content.setLayout(intelLayout)
intel.setLayout(intelLayout) scroll = QScrollArea()
layout.addWidget(intel) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
layout.addStretch() scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
scroll.setWidgetResizable(True)
scroll.setWidget(scroll_content)
layout.addWidget(scroll)
self.setLayout(layout) self.setLayout(layout)

View File

@ -1,3 +1,6 @@
import itertools
from typing import Optional
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
@ -8,7 +11,8 @@ from PySide2.QtWidgets import (
import qt_ui.uiconstants as CONST import qt_ui.uiconstants as CONST
from game.game import Game from game.game import Game
from game.income import Income from game.income import BuildingIncome, Income
from game.theater import ControlPoint
class QHorizontalSeparationLine(QFrame): class QHorizontalSeparationLine(QFrame):
@ -25,20 +29,53 @@ class QHorizontalSeparationLine(QFrame):
class FinancesLayout(QGridLayout): class FinancesLayout(QGridLayout):
def __init__(self, game: Game, player: bool) -> None: def __init__(self, game: Game, player: bool) -> None:
super().__init__() super().__init__()
self.row = itertools.count(0)
income = Income(game, player) income = Income(game, player)
self.addWidget(QLabel("<b>Control Points</b>"), 0, 0) control_points = reversed(
self.addWidget(QLabel( sorted(income.control_points, key=lambda c: c.income_per_turn))
f"{len(income.control_points)} bases x {income.income_per_base}M"), for control_point in control_points:
0, 1) self.add_control_point(control_point)
self.addWidget(QLabel(f"{income.from_bases}M"), 0, 2)
self.addWidget(QHorizontalSeparationLine(), 1, 0, 1, 3) self.add_line()
buildings = reversed(sorted(income.buildings, key=lambda b: b.income)) buildings = reversed(sorted(income.buildings, key=lambda b: b.income))
row = 2 for building in buildings:
for row, building in enumerate(buildings, row): self.add_building(building)
self.add_line()
self.add_row(middle=f"Income multiplier: {income.multiplier:.1f}",
right=f"<b>{income.total}M</b>")
if player:
budget = game.budget
else:
budget = game.enemy_budget
self.add_row(middle="Balance", right=f"<b>{budget}M</b>")
self.setRowStretch(next(self.row), 1)
def add_row(self, left: Optional[str] = None, middle: Optional[str] = None,
right: Optional[str] = None) -> None:
if not any([left, middle, right]):
raise ValueError
row = next(self.row)
if left is not None:
self.addWidget(QLabel(left), row, 0)
if middle is not None:
self.addWidget(QLabel(middle), row, 1)
if right is not None:
self.addWidget(QLabel(right), row, 2)
def add_control_point(self, control_point: ControlPoint) -> None:
self.add_row(left=f"<b>{control_point.name}</b>",
right=f"{control_point.income_per_turn}M")
def add_building(self, building: BuildingIncome) -> None:
row = next(self.row)
self.addWidget( self.addWidget(
QLabel(f"<b>{building.category.upper()} [{building.name}]</b>"), QLabel(f"<b>{building.category.upper()} [{building.name}]</b>"),
row, 0) row, 0)
@ -49,20 +86,8 @@ class FinancesLayout(QGridLayout):
rlabel.setProperty("style", "green") rlabel.setProperty("style", "green")
self.addWidget(rlabel, row, 2) self.addWidget(rlabel, row, 2)
self.addWidget(QHorizontalSeparationLine(), row + 1, 0, 1, 3) def add_line(self) -> None:
self.addWidget(QLabel( self.addWidget(QHorizontalSeparationLine(), next(self.row), 0, 1, 3)
f"Income multiplier: {income.multiplier:.1f}"),
row + 2, 1
)
self.addWidget(QLabel(f"<b>{income.total}M</b>"), row + 2, 2)
if player:
budget = game.budget
else:
budget = game.enemy_budget
self.addWidget(QLabel(f"Balance"), row + 3, 1)
self.addWidget(QLabel(f"<b>{budget}M</b>"), row + 3, 2)
self.setRowStretch(row + 4, 1)
class QFinancesMenu(QDialog): class QFinancesMenu(QDialog):

View File

@ -357,8 +357,7 @@ class QBuyGroupForGroundObjectDialog(QDialog):
# Generate SAM # Generate SAM
generator = sam_generator(self.game, self.ground_object) generator = sam_generator(self.game, self.ground_object)
generator.generate() generator.generate()
generated_group = generator.get_generated_group() self.ground_object.groups = list(generator.groups)
self.ground_object.groups = [generated_group]
GameUpdateSignal.get_instance().updateBudget(self.game) GameUpdateSignal.get_instance().updateBudget(self.game)

View File

@ -14,7 +14,7 @@ from PySide2.QtWidgets import (
QWidget, QWidget,
) )
from game.game import Game from game.game import Game, db
from qt_ui.uiconstants import ICONS from qt_ui.uiconstants import ICONS
from qt_ui.windows.finances.QFinancesMenu import FinancesLayout from qt_ui.windows.finances.QFinancesMenu import FinancesLayout
@ -81,7 +81,7 @@ class AircraftIntelLayout(IntelTableLayout):
for airframe, count in base.aircraft.items(): for airframe, count in base.aircraft.items():
if not count: if not count:
continue continue
self.add_row(airframe.id, count) self.add_row(db.unit_get_expanded_info(game.enemy_country, airframe, 'name'), count)
self.add_spacer() self.add_spacer()
self.add_row("<b>Total</b>", total) self.add_row("<b>Total</b>", total)

View File

@ -4,9 +4,8 @@ from PySide2.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.models import GameModel from qt_ui.models import GameModel, PackageModel
from qt_ui.uiconstants import EVENT_ICONS from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner
@ -15,17 +14,19 @@ from qt_ui.windows.mission.flight.QFlightPlanner import QFlightPlanner
class QEditFlightDialog(QDialog): class QEditFlightDialog(QDialog):
"""Dialog window for editing flight plans and loadouts.""" """Dialog window for editing flight plans and loadouts."""
def __init__(self, game_model: GameModel, package: Package, flight: Flight, parent=None) -> None: def __init__(self, game_model: GameModel, package_model: PackageModel,
flight: Flight, parent=None) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self.game_model = game_model self.game_model = game_model
self.setWindowTitle("Create flight") self.setWindowTitle("Edit flight")
self.setWindowIcon(EVENT_ICONS["strike"]) self.setWindowIcon(EVENT_ICONS["strike"])
layout = QVBoxLayout() layout = QVBoxLayout()
self.flight_planner = QFlightPlanner(package, flight, game_model.game) self.flight_planner = QFlightPlanner(package_model, flight,
game_model.game)
layout.addWidget(self.flight_planner) layout.addWidget(self.flight_planner)
self.setLayout(layout) self.setLayout(layout)

View File

@ -3,8 +3,9 @@ import logging
from datetime import timedelta from datetime import timedelta
from typing import Optional from typing import Optional
from PySide2.QtCore import QItemSelection, QTime, Signal from PySide2.QtCore import QItemSelection, QTime, Qt, Signal
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QCheckBox,
QDialog, QDialog,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@ -15,16 +16,15 @@ from PySide2.QtWidgets import (
) )
from game.game import Game from game.game import Game
from game.theater.missiontarget import MissionTarget
from gen.ato import Package from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from gen.flights.flightplan import FlightPlanBuilder, PlanningError from gen.flights.flightplan import FlightPlanBuilder, PlanningError
from gen.flights.traveltime import TotEstimator
from qt_ui.models import AtoModel, GameModel, PackageModel from qt_ui.models import AtoModel, GameModel, PackageModel
from qt_ui.uiconstants import EVENT_ICONS from qt_ui.uiconstants import EVENT_ICONS
from qt_ui.widgets.ato import QFlightList from qt_ui.widgets.ato import QFlightList
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator from qt_ui.windows.mission.flight.QFlightCreator import QFlightCreator
from game.theater.missiontarget import MissionTarget
class QPackageDialog(QDialog): class QPackageDialog(QDialog):
@ -78,15 +78,23 @@ class QPackageDialog(QDialog):
self.tot_spinner.setMinimumTime(QTime(0, 0)) self.tot_spinner.setMinimumTime(QTime(0, 0))
self.tot_spinner.setDisplayFormat("T+hh:mm:ss") self.tot_spinner.setDisplayFormat("T+hh:mm:ss")
self.tot_spinner.timeChanged.connect(self.save_tot) self.tot_spinner.timeChanged.connect(self.save_tot)
self.tot_spinner.setToolTip("Package TOT relative to mission TOT")
self.tot_spinner.setEnabled(not self.package_model.package.auto_asap)
self.tot_column.addWidget(self.tot_spinner) self.tot_column.addWidget(self.tot_spinner)
self.reset_tot_button = QPushButton("ASAP") self.auto_asap = QCheckBox("ASAP")
self.reset_tot_button.setToolTip( self.auto_asap.setToolTip(
"Sets the package TOT to the earliest time that all flights can " "Sets the package TOT to the earliest time that all flights can "
"arrive at the target." "arrive at the target."
) )
self.reset_tot_button.clicked.connect(self.reset_tot) self.auto_asap.setChecked(self.package_model.package.auto_asap)
self.tot_column.addWidget(self.reset_tot_button) self.auto_asap.toggled.connect(self.set_asap)
self.tot_column.addWidget(self.auto_asap)
self.tot_help_label = QLabel("<a href=\"https://github.com/Khopa/dcs_liberation/wiki/Mission-planning\"><span style=\"color:#FFFFFF;\">Help</span></a>")
self.tot_help_label.setAlignment(Qt.AlignCenter)
self.tot_help_label.setOpenExternalLinks(True)
self.tot_column.addWidget(self.tot_help_label)
self.package_view = QFlightList(self.game_model, self.package_model) self.package_view = QFlightList(self.game_model, self.package_model)
self.package_view.selectionModel().selectionChanged.connect( self.package_view.selectionModel().selectionChanged.connect(
@ -107,6 +115,8 @@ class QPackageDialog(QDialog):
self.delete_flight_button.setEnabled(model.rowCount() > 0) self.delete_flight_button.setEnabled(model.rowCount() > 0)
self.button_layout.addWidget(self.delete_flight_button) self.button_layout.addWidget(self.delete_flight_button)
self.package_model.tot_changed.connect(self.update_tot)
self.button_layout.addStretch() self.button_layout.addStretch()
self.setLayout(self.layout) self.setLayout(self.layout)
@ -139,14 +149,14 @@ class QPackageDialog(QDialog):
def save_tot(self) -> None: def save_tot(self) -> None:
time = self.tot_spinner.time() time = self.tot_spinner.time()
seconds = time.hour() * 3600 + time.minute() * 60 + time.second() seconds = time.hour() * 3600 + time.minute() * 60 + time.second()
self.package_model.update_tot(timedelta(seconds=seconds)) self.package_model.set_tot(timedelta(seconds=seconds))
def reset_tot(self) -> None: def set_asap(self, checked: bool) -> None:
if not list(self.package_model.flights): self.package_model.set_asap(checked)
self.package_model.update_tot(timedelta()) self.tot_spinner.setEnabled(not self.package_model.package.auto_asap)
else: self.update_tot()
self.package_model.update_tot(
TotEstimator(self.package_model.package).earliest_tot()) def update_tot(self) -> None:
self.tot_spinner.setTime(self.tot_qtime()) self.tot_spinner.setTime(self.tot_qtime())
def on_selection_changed(self, selected: QItemSelection, def on_selection_changed(self, selected: QItemSelection,
@ -177,6 +187,7 @@ class QPackageDialog(QDialog):
QMessageBox.critical( QMessageBox.critical(
self, "Could not create flight", str(ex), QMessageBox.Ok self, "Could not create flight", str(ex), QMessageBox.Ok
) )
self.package_model.update_tot()
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
self.package_changed.emit() self.package_changed.emit()

View File

@ -2,10 +2,13 @@ from typing import Optional
from PySide2.QtCore import Qt, Signal from PySide2.QtCore import Qt, Signal
from PySide2.QtWidgets import ( from PySide2.QtWidgets import (
QComboBox,
QDialog, QDialog,
QLabel,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QVBoxLayout, QVBoxLayout,
QLineEdit,
) )
from dcs.planes import PlaneType from dcs.planes import PlaneType
@ -31,6 +34,8 @@ class QFlightCreator(QDialog):
self.game = game self.game = game
self.package = package self.package = package
self.custom_name_text = None
self.country = self.game.player_country
self.setWindowTitle("Create flight") self.setWindowTitle("Create flight")
self.setWindowIcon(EVENT_ICONS["strike"]) self.setWindowIcon(EVENT_ICONS["strike"])
@ -41,10 +46,12 @@ class QFlightCreator(QDialog):
self.game.theater, package.target self.game.theater, package.target
) )
self.task_selector.setCurrentIndex(0) self.task_selector.setCurrentIndex(0)
self.task_selector.currentTextChanged.connect(
self.on_task_changed)
layout.addLayout(QLabeledWidget("Task:", self.task_selector)) layout.addLayout(QLabeledWidget("Task:", self.task_selector))
self.aircraft_selector = QAircraftTypeSelector( self.aircraft_selector = QAircraftTypeSelector(
self.game.aircraft_inventory.available_types_for_player self.game.aircraft_inventory.available_types_for_player, self.game.player_country, self.task_selector.currentData()
) )
self.aircraft_selector.setCurrentIndex(0) self.aircraft_selector.setCurrentIndex(0)
self.aircraft_selector.currentIndexChanged.connect( self.aircraft_selector.currentIndexChanged.connect(
@ -57,6 +64,7 @@ class QFlightCreator(QDialog):
self.aircraft_selector.currentData() self.aircraft_selector.currentData()
) )
self.departure.availability_changed.connect(self.update_max_size) self.departure.availability_changed.connect(self.update_max_size)
self.departure.currentIndexChanged.connect(self.on_departure_changed)
layout.addLayout(QLabeledWidget("Departure:", self.departure)) layout.addLayout(QLabeledWidget("Departure:", self.departure))
self.arrival = QArrivalAirfieldSelector( self.arrival = QArrivalAirfieldSelector(
@ -88,6 +96,28 @@ class QFlightCreator(QDialog):
layout.addLayout( layout.addLayout(
QLabeledWidget("Client Slots:", self.client_slots_spinner)) QLabeledWidget("Client Slots:", self.client_slots_spinner))
# When an off-map spawn overrides the start type to in-flight, we save
# the selected type into this value. If a non-off-map spawn is selected
# we restore the previous choice.
self.restore_start_type: Optional[str] = None
self.start_type = QComboBox()
self.start_type.addItems(["Cold", "Warm", "Runway", "In Flight"])
self.start_type.setCurrentText(self.game.settings.default_start_type)
layout.addLayout(QLabeledWidget(
"Start type:", self.start_type,
tooltip="Selects the start type for this flight."))
layout.addWidget(QLabel(
"Any option other than Cold will make this flight " +
"non-targetable<br />by OCA/Aircraft missions. This will affect " +
"game balance."
))
self.custom_name = QLineEdit()
self.custom_name.textChanged.connect(self.set_custom_name_text)
layout.addLayout(
QLabeledWidget("Custom Flight Name (Optional)", self.custom_name)
)
layout.addStretch() layout.addStretch()
self.create_button = QPushButton("Create") self.create_button = QPushButton("Create")
@ -96,12 +126,19 @@ class QFlightCreator(QDialog):
self.setLayout(layout) self.setLayout(layout)
self.on_departure_changed(self.departure.currentIndex())
def set_custom_name_text(self, text: str):
self.custom_name_text = text
def verify_form(self) -> Optional[str]: def verify_form(self) -> Optional[str]:
aircraft: PlaneType = self.aircraft_selector.currentData() aircraft: PlaneType = self.aircraft_selector.currentData()
origin: ControlPoint = self.departure.currentData() origin: ControlPoint = self.departure.currentData()
arrival: ControlPoint = self.arrival.currentData() arrival: ControlPoint = self.arrival.currentData()
divert: ControlPoint = self.divert.currentData() divert: ControlPoint = self.divert.currentData()
size: int = self.flight_size_spinner.value() size: int = self.flight_size_spinner.value()
if aircraft is None:
return "You must select an aircraft type."
if not origin.captured: if not origin.captured:
return f"{origin.name} is not owned by your coalition." return f"{origin.name} is not owned by your coalition."
if arrival is not None and not arrival.captured: if arrival is not None and not arrival.captured:
@ -115,6 +152,8 @@ class QFlightCreator(QDialog):
return f"{origin.name} has only {available} {aircraft.id} available." return f"{origin.name} has only {available} {aircraft.id} available."
if size <= 0: if size <= 0:
return f"Flight must have at least one aircraft." return f"Flight must have at least one aircraft."
if self.custom_name_text and "|" in self.custom_name_text:
return f"Cannot include | in flight name"
return None return None
def create_flight(self) -> None: def create_flight(self) -> None:
@ -134,14 +173,9 @@ class QFlightCreator(QDialog):
if arrival is None: if arrival is None:
arrival = origin arrival = origin
if isinstance(origin, OffMapSpawn): flight = Flight(self.package, self.country, aircraft, size, task,
start_type = "In Flight" self.start_type.currentText(), origin, arrival, divert,
elif self.game.settings.perf_ai_parking_start: custom_name=self.custom_name_text)
start_type = "Cold"
else:
start_type = "Warm"
flight = Flight(self.package, aircraft, size, task, start_type, origin,
arrival, divert)
flight.client_count = self.client_slots_spinner.value() flight.client_count = self.client_slots_spinner.value()
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@ -154,6 +188,23 @@ class QFlightCreator(QDialog):
self.arrival.change_aircraft(new_aircraft) self.arrival.change_aircraft(new_aircraft)
self.divert.change_aircraft(new_aircraft) self.divert.change_aircraft(new_aircraft)
def on_departure_changed(self, index: int) -> None:
departure = self.departure.itemData(index)
if isinstance(departure, OffMapSpawn):
previous_type = self.start_type.currentText()
if previous_type != "In Flight":
self.restore_start_type = previous_type
self.start_type.setCurrentText("In Flight")
self.start_type.setEnabled(False)
else:
self.start_type.setEnabled(True)
if self.restore_start_type is not None:
self.start_type.setCurrentText(self.restore_start_type)
self.restore_start_type = None
def on_task_changed(self) -> None:
self.aircraft_selector.updateItems(self.task_selector.currentData(), self.game.aircraft_inventory.available_types_for_player)
def update_max_size(self, available: int) -> None: def update_max_size(self, available: int) -> None:
self.flight_size_spinner.setMaximum(min(available, 4)) self.flight_size_spinner.setMaximum(min(available, 4))
if self.flight_size_spinner.maximum() >= 2: if self.flight_size_spinner.maximum() >= 2:

View File

@ -1,9 +1,8 @@
from PySide2.QtCore import Signal
from PySide2.QtWidgets import QTabWidget from PySide2.QtWidgets import QTabWidget
from game import Game from game import Game
from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.models import PackageModel
from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import \ from qt_ui.windows.mission.flight.payload.QFlightPayloadTab import \
QFlightPayloadTab QFlightPayloadTab
from qt_ui.windows.mission.flight.settings.QGeneralFlightSettingsTab import \ from qt_ui.windows.mission.flight.settings.QGeneralFlightSettingsTab import \
@ -14,22 +13,15 @@ from qt_ui.windows.mission.flight.waypoints.QFlightWaypointTab import \
class QFlightPlanner(QTabWidget): class QFlightPlanner(QTabWidget):
on_planned_flight_changed = Signal() def __init__(self, package_model: PackageModel, flight: Flight, game: Game):
def __init__(self, package: Package, flight: Flight, game: Game):
super().__init__() super().__init__()
self.general_settings_tab = QGeneralFlightSettingsTab( self.general_settings_tab = QGeneralFlightSettingsTab(
game, package, flight game, package_model, flight
) )
# noinspection PyUnresolvedReferences
self.general_settings_tab.on_flight_settings_changed.connect(
lambda: self.on_planned_flight_changed.emit())
self.payload_tab = QFlightPayloadTab(flight, game) self.payload_tab = QFlightPayloadTab(flight, game)
self.waypoint_tab = QFlightWaypointTab(game, package, flight) self.waypoint_tab = QFlightWaypointTab(game, package_model.package,
# noinspection PyUnresolvedReferences flight)
self.waypoint_tab.on_flight_changed.connect(
lambda: self.on_planned_flight_changed.emit())
self.addTab(self.general_settings_tab, "General Flight settings") self.addTab(self.general_settings_tab, "General Flight settings")
self.addTab(self.payload_tab, "Payload") self.addTab(self.payload_tab, "Payload")
self.addTab(self.waypoint_tab, "Waypoints") self.addTab(self.waypoint_tab, "Waypoints")

View File

@ -1,4 +1,5 @@
from PySide2.QtWidgets import QFrame, QGridLayout from PySide2.QtWidgets import QFrame, QGridLayout, QLabel
from PySide2.QtCore import Qt
from game import Game from game import Game
from gen.flights.flight import Flight from gen.flights.flight import Flight
@ -15,5 +16,13 @@ class QFlightPayloadTab(QFrame):
def init_ui(self): def init_ui(self):
layout = QGridLayout() layout = QGridLayout()
# Docs Link
docsText = QLabel("<a href=\"https://github.com/Khopa/dcs_liberation/wiki/Custom-Loadouts\"><span style=\"color:#FFFFFF;\">How to create your own default loadout</span></a>")
docsText.setAlignment(Qt.AlignCenter)
docsText.setOpenExternalLinks(True)
layout.addWidget(self.payload_editor) layout.addWidget(self.payload_editor)
layout.addWidget(docsText)
self.setLayout(layout) self.setLayout(layout)

View File

@ -1,14 +1,21 @@
import inspect from PySide2.QtWidgets import (
QGridLayout,
from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox, QGridLayout, QVBoxLayout, QSizePolicy QGroupBox,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from game import Game
from game.data.weapons import Pylon
from gen.flights.flight import Flight
from qt_ui.windows.mission.flight.payload.QPylonEditor import QPylonEditor from qt_ui.windows.mission.flight.payload.QPylonEditor import QPylonEditor
class QLoadoutEditor(QGroupBox): class QLoadoutEditor(QGroupBox):
def __init__(self, flight, game): def __init__(self, flight: Flight, game: Game) -> None:
super(QLoadoutEditor, self).__init__("Use custom loadout") super().__init__("Use custom loadout")
self.flight = flight self.flight = flight
self.game = game self.game = game
self.setCheckable(True) self.setCheckable(True)
@ -19,22 +26,23 @@ class QLoadoutEditor(QGroupBox):
hboxLayout = QVBoxLayout(self) hboxLayout = QVBoxLayout(self)
layout = QGridLayout(self) layout = QGridLayout(self)
pylons = [v for v in self.flight.unit_type.__dict__.values() if inspect.isclass(v) and v.__name__.startswith("Pylon")] for i, pylon in enumerate(Pylon.iter_pylons(self.flight.unit_type)):
for i, pylon in enumerate(pylons): label = QLabel(f"<b>{pylon.number}</b>")
label = QLabel("<b>{}</b>".format(pylon.__name__[len("Pylon"):])) label.setSizePolicy(
label.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed))
layout.addWidget(label, i, 0) layout.addWidget(label, i, 0)
try: layout.addWidget(QPylonEditor(game, flight, pylon), i, 1)
pylon_number = int(pylon.__name__.split("Pylon")[1])
except:
pylon_number = i+1
layout.addWidget(QPylonEditor(flight, pylon, pylon_number), i, 1)
hboxLayout.addLayout(layout) hboxLayout.addLayout(layout)
hboxLayout.addStretch() hboxLayout.addStretch()
self.setLayout(hboxLayout) self.setLayout(hboxLayout)
if not self.isChecked():
for i in self.findChildren(QPylonEditor):
i.default_loadout()
def on_toggle(self): def on_toggle(self):
self.flight.use_custom_loadout = self.isChecked() self.flight.use_custom_loadout = self.isChecked()
if not self.isChecked():
for i in self.findChildren(QPylonEditor):
i.default_loadout(i.pylon.number)

View File

@ -1,38 +1,80 @@
import logging import logging
import operator
from typing import Optional
from PySide2.QtWidgets import QWidget, QSpinBox, QComboBox from PySide2.QtWidgets import QComboBox
from game import Game, db
from game.data.weapons import Pylon, Weapon
from gen.flights.flight import Flight
from dcs import weapons_data
class QPylonEditor(QComboBox): class QPylonEditor(QComboBox):
def __init__(self, flight, pylon, pylon_number): def __init__(self, game: Game, flight: Flight, pylon: Pylon) -> None:
super(QPylonEditor, self).__init__() super().__init__()
self.pylon = pylon
self.pylon_number = pylon_number
self.flight = flight self.flight = flight
self.pylon = pylon
self.game = game
self.possible_loadout = [i for i in self.pylon.__dict__.keys() if i[:2] != '__'] current = self.flight.loadout.get(self.pylon.number)
if not str(self.pylon_number) in self.flight.loadout.keys(): self.addItem("None", None)
self.flight.loadout[str(self.pylon_number)] = "" if self.game.settings.restrict_weapons_by_date:
weapons = pylon.available_on(self.game.date)
self.addItem("None") else:
for i,k in enumerate(self.possible_loadout): weapons = pylon.allowed
self.addItem(str(self.pylon.__dict__[k][1]["name"])) allowed = sorted(weapons, key=operator.attrgetter("name"))
if self.flight.loadout[str(self.pylon_number)] == str(k): for i, weapon in enumerate(allowed):
self.addItem(weapon.name, weapon)
if current == weapon:
self.setCurrentIndex(i + 1) self.setCurrentIndex(i + 1)
self.currentTextChanged.connect(self.on_pylon_change) self.currentIndexChanged.connect(self.on_pylon_change)
def on_pylon_change(self): def on_pylon_change(self):
selected = self.currentText() selected: Optional[Weapon] = self.currentData()
if selected == "None": self.flight.loadout[self.pylon.number] = selected
logging.info("Pylon " + str(self.pylon_number) + " emptied")
self.flight.loadout[str(self.pylon_number)] = ""
else:
logging.info("Pylon " + str(self.pylon_number) + " changed to " + selected)
for i, k in enumerate(self.possible_loadout):
if selected == str(self.pylon.__dict__[k][1]["name"]):
self.flight.loadout[str(self.pylon_number)] = str(k)
break
if selected is None:
logging.debug(f"Pylon {self.pylon.number} emptied")
else:
logging.debug(
f"Pylon {self.pylon.number} changed to {selected.name}")
def default_loadout(self) -> None:
self.flight.unit_type.load_payloads()
self.setCurrentText("None")
pylon_default_weapon = None
historical_weapon = None
loadout = None
# Iterate through each possible payload type for a given aircraft.
# Some aircraft have custom loadouts that in aren't the standard set.
for payload_override in db.EXPANDED_TASK_PAYLOAD_OVERRIDE.get(self.flight.flight_type.name):
if loadout is None:
loadout = self.flight.unit_type.loadout_by_name(payload_override)
if loadout is not None:
for i in loadout:
if i[0] == self.pylon.number:
pylon_default_weapon = i[1]["clsid"]
# TODO: Handle removed pylons better.
if pylon_default_weapon == "<CLEAN>":
pylon_default_weapon = None
if pylon_default_weapon is not None:
if self.game.settings.restrict_weapons_by_date:
orig_weapon = Weapon.from_clsid(pylon_default_weapon)
if not orig_weapon.available_on(self.game.date):
for fallback in orig_weapon.fallbacks:
if historical_weapon is None:
if not self.pylon.can_equip(fallback):
continue
if not fallback.available_on(self.game.date):
continue
historical_weapon = fallback
else:
historical_weapon = orig_weapon
if historical_weapon is not None:
self.setCurrentText(weapons_data.weapon_ids.get(historical_weapon.cls_id).get("name"))
else:
self.setCurrentText(weapons_data.weapon_ids.get(pylon_default_weapon).get("name"))

View File

@ -0,0 +1,99 @@
import logging
from PySide2.QtWidgets import (QGroupBox, QLabel, QMessageBox, QVBoxLayout)
from game import Game
from gen.flights.flight import Flight
from gen.flights.flightplan import FlightPlanBuilder, PlanningError
from gen.flights.traveltime import TotEstimator
from qt_ui.models import PackageModel
from qt_ui.widgets.QLabeledWidget import QLabeledWidget
from qt_ui.widgets.combos.QArrivalAirfieldSelector import \
QArrivalAirfieldSelector
class FlightAirfieldDisplay(QGroupBox):
def __init__(self, game: Game, package_model: PackageModel,
flight: Flight) -> None:
super().__init__("Departure/Arrival")
self.game = game
self.package_model = package_model
self.flight = flight
layout = QVBoxLayout()
self.departure_time = QLabel()
layout.addLayout(QLabeledWidget(
f"Departing from <b>{flight.from_cp.name}</b>",
self.departure_time))
self.package_model.tot_changed.connect(self.update_departure_time)
self.update_departure_time()
layout.addWidget(QLabel("Determined based on the package TOT. Edit the "
"package to adjust the TOT."))
self.arrival = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured],
flight.unit_type, "Same as departure"
)
self.arrival.currentIndexChanged.connect(self.set_arrival)
if flight.arrival != flight.departure:
self.arrival.setCurrentText(flight.arrival.name)
layout.addLayout(QLabeledWidget("Arrival:", self.arrival))
self.divert = QArrivalAirfieldSelector(
[cp for cp in game.theater.controlpoints if cp.captured],
flight.unit_type, "None"
)
self.divert.currentIndexChanged.connect(self.set_divert)
if flight.divert is not None:
self.divert.setCurrentText(flight.divert.name)
layout.addLayout(QLabeledWidget("Divert:", self.divert))
self.setLayout(layout)
def update_departure_time(self) -> None:
estimator = TotEstimator(self.package_model.package)
delay = estimator.mission_start_time(self.flight)
self.departure_time.setText(f"At T+{delay}")
def set_arrival(self, index: int) -> None:
old_arrival = self.flight.arrival
arrival = self.arrival.itemData(index)
if arrival == old_arrival:
return
if arrival is None:
arrival = self.flight.departure
self.flight.arrival = arrival
try:
self.update_flight_plan()
except PlanningError as ex:
self.flight.arrival = old_arrival
logging.exception("Could not change arrival airfield")
QMessageBox.critical(
self, "Could not update flight plan", str(ex), QMessageBox.Ok
)
def set_divert(self, index: int) -> None:
old_divert = self.flight.divert
divert = self.divert.itemData(index)
if divert == old_divert:
return
self.flight.divert = divert
try:
self.update_flight_plan()
except PlanningError as ex:
self.flight.divert = old_divert
logging.exception("Could not change divert airfield")
QMessageBox.critical(
self, "Could not update flight plan", str(ex), QMessageBox.Ok
)
def update_flight_plan(self) -> None:
planner = FlightPlanBuilder(self.game, self.package_model.package,
is_player=True)
planner.populate_flight_plan(self.flight)

View File

@ -0,0 +1,16 @@
from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QLabel
from gen.flights.flight import Flight
class QFlightCustomName(QGroupBox):
def __init__(self, flight: Flight):
super(QFlightCustomName, self).__init__()
self.flight = flight
self.layout = QHBoxLayout()
self.custom_name_label = QLabel(f"Custom Name: {flight.custom_name}")
self.layout.addWidget(self.custom_name_label)
self.setLayout(self.layout)

View File

@ -1,32 +0,0 @@
import datetime
from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QVBoxLayout
from gen.ato import Package
from gen.flights.flight import Flight
from gen.flights.traveltime import TotEstimator
# TODO: Remove?
class QFlightDepartureDisplay(QGroupBox):
def __init__(self, package: Package, flight: Flight):
super().__init__("Departure")
layout = QVBoxLayout()
departure_row = QHBoxLayout()
layout.addLayout(departure_row)
estimator = TotEstimator(package)
delay = estimator.mission_start_time(flight)
departure_row.addWidget(QLabel(
f"Departing from <b>{flight.from_cp.name}</b>"
))
departure_row.addWidget(QLabel(f"At T+{delay}"))
layout.addWidget(QLabel("Determined based on the package TOT. Edit the "
"package to adjust the TOT."))
self.setLayout(layout)

View File

@ -1,15 +1,20 @@
import logging import logging
from PySide2.QtCore import Signal from PySide2.QtCore import Signal
from PySide2.QtWidgets import QLabel, QHBoxLayout, QGroupBox, QSpinBox, QGridLayout from PySide2.QtWidgets import QLabel, QGroupBox, QSpinBox, QGridLayout
from game import Game
from gen.flights.flight import Flight
from qt_ui.models import PackageModel
class QFlightSlotEditor(QGroupBox): class QFlightSlotEditor(QGroupBox):
changed = Signal() changed = Signal()
def __init__(self, flight, game): def __init__(self, package_model: PackageModel, flight: Flight, game: Game):
super(QFlightSlotEditor, self).__init__("Slots") super().__init__("Slots")
self.package_model = package_model
self.flight = flight self.flight = flight
self.game = game self.game = game
self.inventory = self.game.aircraft_inventory.for_control_point( self.inventory = self.game.aircraft_inventory.for_control_point(
@ -22,14 +27,14 @@ class QFlightSlotEditor(QGroupBox):
layout = QGridLayout() layout = QGridLayout()
self.aircraft_count = QLabel("Aircraft count :") self.aircraft_count = QLabel("Aircraft count:")
self.aircraft_count_spinner = QSpinBox() self.aircraft_count_spinner = QSpinBox()
self.aircraft_count_spinner.setMinimum(1) self.aircraft_count_spinner.setMinimum(1)
self.aircraft_count_spinner.setMaximum(max_count) self.aircraft_count_spinner.setMaximum(max_count)
self.aircraft_count_spinner.setValue(flight.count) self.aircraft_count_spinner.setValue(flight.count)
self.aircraft_count_spinner.valueChanged.connect(self._changed_aircraft_count) self.aircraft_count_spinner.valueChanged.connect(self._changed_aircraft_count)
self.client_count = QLabel("Client slots count :") self.client_count = QLabel("Client slots count:")
self.client_count_spinner = QSpinBox() self.client_count_spinner = QSpinBox()
self.client_count_spinner.setMinimum(0) self.client_count_spinner.setMinimum(0)
self.client_count_spinner.setMaximum(max_count) self.client_count_spinner.setMaximum(max_count)
@ -70,6 +75,7 @@ class QFlightSlotEditor(QGroupBox):
def _changed_client_count(self): def _changed_client_count(self):
self.flight.client_count = int(self.client_count_spinner.value()) self.flight.client_count = int(self.client_count_spinner.value())
self._cap_client_count() self._cap_client_count()
self.package_model.update_tot()
self.changed.emit() self.changed.emit()
def _cap_client_count(self): def _cap_client_count(self):

View File

@ -1,18 +1,25 @@
from PySide2.QtWidgets import QGroupBox, QHBoxLayout, QComboBox, QLabel from PySide2.QtWidgets import (
from dcs.mission import StartType QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
)
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.models import PackageModel
class QFlightStartType(QGroupBox): class QFlightStartType(QGroupBox):
def __init__(self, flight:Flight): def __init__(self, package_model: PackageModel, flight: Flight):
super(QFlightStartType, self).__init__() super().__init__()
self.package_model = package_model
self.flight = flight self.flight = flight
self.layout = QHBoxLayout() self.layout = QVBoxLayout()
self.start_type_label = QLabel("Start type : ") self.main_row = QHBoxLayout()
self.start_type_label = QLabel("Start type:")
self.start_type = QComboBox() self.start_type = QComboBox()
for i, st in enumerate([b for b in ["Cold", "Warm", "Runway", "In Flight"]]): for i, st in enumerate([b for b in ["Cold", "Warm", "Runway", "In Flight"]]):
@ -21,13 +28,17 @@ class QFlightStartType(QGroupBox):
self.start_type.setCurrentIndex(i) self.start_type.setCurrentIndex(i)
self.start_type.currentTextChanged.connect(self._on_start_type_selected) self.start_type.currentTextChanged.connect(self._on_start_type_selected)
self.layout.addWidget(self.start_type_label) self.main_row.addWidget(self.start_type_label)
self.layout.addWidget(self.start_type) self.main_row.addWidget(self.start_type)
self.layout.addLayout(self.main_row)
self.layout.addWidget(QLabel(
"Any option other than Cold will make this flight non-targetable " +
"by OCA/Aircraft missions. This will affect game balance."
))
self.setLayout(self.layout) self.setLayout(self.layout)
def _on_start_type_selected(self): def _on_start_type_selected(self):
selected = self.start_type.currentData() selected = self.start_type.currentData()
self.flight.start_type = selected self.flight.start_type = selected
self.package_model.update_tot()

View File

@ -2,38 +2,34 @@ from PySide2.QtCore import Signal
from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout from PySide2.QtWidgets import QFrame, QGridLayout, QVBoxLayout
from game import Game from game import Game
from gen.ato import Package
from gen.flights.flight import Flight from gen.flights.flight import Flight
from qt_ui.windows.mission.flight.settings.QFlightDepartureDisplay import \ from qt_ui.models import PackageModel
QFlightDepartureDisplay from qt_ui.windows.mission.flight.settings.FlightAirfieldDisplay import \
FlightAirfieldDisplay
from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import \ from qt_ui.windows.mission.flight.settings.QFlightSlotEditor import \
QFlightSlotEditor QFlightSlotEditor
from qt_ui.windows.mission.flight.settings.QFlightStartType import \ from qt_ui.windows.mission.flight.settings.QFlightStartType import \
QFlightStartType QFlightStartType
from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import \ from qt_ui.windows.mission.flight.settings.QFlightTypeTaskInfo import \
QFlightTypeTaskInfo QFlightTypeTaskInfo
from qt_ui.windows.mission.flight.settings.QCustomName import \
QFlightCustomName
class QGeneralFlightSettingsTab(QFrame): class QGeneralFlightSettingsTab(QFrame):
on_flight_settings_changed = Signal() on_flight_settings_changed = Signal()
def __init__(self, game: Game, package: Package, flight: Flight): def __init__(self, game: Game, package_model: PackageModel, flight: Flight):
super().__init__() super().__init__()
layout = QGridLayout() layout = QGridLayout()
flight_info = QFlightTypeTaskInfo(flight) layout.addWidget(QFlightTypeTaskInfo(flight), 0, 0)
flight_departure = QFlightDepartureDisplay(package, flight) layout.addWidget(FlightAirfieldDisplay(game, package_model, flight), 1,
flight_slots = QFlightSlotEditor(flight, game) 0)
flight_start_type = QFlightStartType(flight) layout.addWidget(QFlightSlotEditor(package_model, flight, game), 2, 0)
layout.addWidget(flight_info, 0, 0) layout.addWidget(QFlightStartType(package_model, flight), 3, 0)
layout.addWidget(flight_departure, 1, 0) layout.addWidget(QFlightCustomName(flight), 4, 0)
layout.addWidget(flight_slots, 2, 0)
layout.addWidget(flight_start_type, 3, 0)
vstretch = QVBoxLayout() vstretch = QVBoxLayout()
vstretch.addStretch() vstretch.addStretch()
layout.addLayout(vstretch, 3, 0) layout.addLayout(vstretch, 5, 0)
self.setLayout(layout) self.setLayout(layout)
flight_start_type.setEnabled(flight.client_count > 0)
flight_slots.changed.connect(
lambda: flight_start_type.setEnabled(flight.client_count > 0))

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