Compare commits

..

5 Commits

Author SHA1 Message Date
David Pierron
45c67534e6 added whatever was necessary to generate
the new IADS CPs on the map
2020-10-28 12:05:05 +01:00
David Pierron
bcc3333d1b typing corrections 2020-10-28 12:04:40 +01:00
David Pierron
f9ee36db83 Merge remote-tracking branch 'origin/develop' into plugin-skynet-iads 2020-10-28 08:10:04 +01:00
David Pierron
ca5204634a added ground object templates :
- iads-power
- iads-controlcenter
- iads-ewr
- iads-commnode
2020-10-28 08:09:51 +01:00
David Pierron
14a3279b2c cleaner way of adding a faction parameter
to the groupgenerator constructor
2020-10-27 10:06:22 +01:00
1539 changed files with 27773 additions and 58738 deletions

View File

@@ -1,2 +0,0 @@
# Black
a47bef1f1336fd264d0b175f4421758339a30acb

View File

@@ -1,40 +0,0 @@
---
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.
If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs.
**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 located in your Tacview tracks directory (`%USERPROFILE%/Documents/Tacview`).
- The state.json file from the finished mission when the problem is related to results processing. By default these are located in your Liberation install directory.
**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

@@ -1,28 +0,0 @@
---
name: Campaign update submission
about: Submit an update to a campaign you maintain.
title: 'Update for <campaign name>'
labels: campaign-update-submission
assignees: ''
---
This form should only be used for submitted updated miz/json files for campaigns
distributed with Liberation. If you are _requesting_ an update to a campaign, see
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance. If the
campaign has an owner, it will be updated before release. If it does not, you can
volunteer to own it.
If you are not the owner of the campaign listed on
https://github.com/dcs-liberation/dcs_liberation/wiki/Campaign-maintenance, please start
there.
Otherwise, delete everything above the line below and fill out the following form. Note:
GitHub does not accept .miz files. You can either rename the file to .miz.txt or add the
file to a .zip file.
---
* Campaign name:
* Files:
* Update summary (optional):

View File

@@ -1,21 +0,0 @@
---
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.
If requesting a DCS AI feature, check If reporting a DCS AI bug, check https://github.com/dcs-liberation/dcs_liberation#dcs-bugs.
**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.

View File

@@ -1,13 +0,0 @@
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable
with:
args: ". --check"

View File

@@ -11,10 +11,10 @@ jobs:
with:
submodules: true
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.9
python-version: 3.8
- name: Install environment
run: |
@@ -36,10 +36,11 @@ jobs:
run: |
./venv/scripts/activate
mypy gen
- name: update build number
- name: mypy theater
run: |
[IO.File]::WriteAllLines($pwd.path + "\resources\buildnumber", $env:GITHUB_RUN_NUMBER)
./venv/scripts/activate
mypy theater
- name: Build binaries
run: |

View File

@@ -13,10 +13,10 @@ jobs:
with:
submodules: true
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.9
python-version: 3.8
- name: Install environment
run: |
@@ -29,10 +29,6 @@ jobs:
# For some reason the shiboken2.abi3.dll is not found properly, so I copy it instead
Copy-Item .\venv\Lib\site-packages\shiboken2\shiboken2.abi3.dll .\venv\Lib\site-packages\PySide2\ -Force
- name: Finalize version
run: |
New-Item -ItemType file resources\final
- name: mypy game
run: |
./venv/scripts/activate
@@ -43,6 +39,11 @@ jobs:
./venv/scripts/activate
mypy gen
- name: mypy theater
run: |
./venv/scripts/activate
mypy theater
- name: Build binaries
run: |
./venv/scripts/activate
@@ -53,6 +54,11 @@ jobs:
env:
TAG_NAME: ${{ github.ref }}
run: |
$version = ($env:TAG_NAME -split "/") | Select-Object -Last 1
(Get-Content .\installer\dcs_liberation.iss) -replace "{{version}}",$version | Out-File .\build\installer.iss
cd .\installer
iscc.exe ..\build\installer.iss
cd ..
Copy-Item .\changelog.md .\dist
- uses: actions/upload-artifact@v2
@@ -95,7 +101,15 @@ jobs:
body_path: releasenotes.md
draft: false
prerelease: ${{ steps.version.outputs.prerelease }}
- uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dcs_liberation.exe
asset_name: dcs_liberation.${{ steps.version.outputs.number }}.exe
asset_content_type: application/exe
- uses: actions/upload-release-asset@v1
env:

3
.gitignore vendored
View File

@@ -5,14 +5,11 @@ resources/payloads/*.lua
venv
logs.txt
.DS_Store
.vscode/settings.json
dist/**
a.py
resources/tools/a.miz
# User-specific stuff
.idea/
.env
env/
/kneeboards
/liberation_preferences.json

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "pydcs"]
path = pydcs
url = https://github.com/pydcs/dcs
branch = master

View File

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

12
.vscode/launch.json vendored
View File

@@ -4,6 +4,7 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Main",
"type": "python",
@@ -15,6 +16,17 @@
},
"preLaunchTask": "Prepare Environment"
},
{
"name": "Python: build ground objects templates",
"type": "python",
"request": "launch",
"program": "resources\\tools\\generate_groundobject_templates.py",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": ".;./pydcs"
},
"preLaunchTask": "Prepare Environment"
},
{
"name": "Python: Make Release",
"type": "python",

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"python.pythonPath": "g:\\python\\dcs_liberation\\venv\\Scripts\\python.exe",
"vsintellicode.python.completionsEnabled": true
}

View File

@@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at khopa.studio@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@@ -1,26 +0,0 @@
First, note that we have a code of conduct, please follow it in all your interactions with the project.
## Contributing as a non-developer
* Report bugs by opening issues here on Github.
* Help others users on Discord by answering their questions.
* Raise awareness about the project, by making a video and/or a tutorial.
Should you report a bug, 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.
## Making content for Liberation
You can create new campaigns : See [campaign creation wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/Custom-Campaigns).
You can also improve existing campaigns.
You can then submit new campaigns on the "campaigns" channel on Discord, or by making a pull request if you are comfortable with git.
## Develop new features
If you want to develop a new feature, we recommend you first open an issue describing the new feature and discuss it with us on Discord before starting development.
However, feel free to work on any existing issue.
## Pull requests
Please submit your pull requests on the **develop** branch. We expect a description of its content, and when applicable, a reference to the issue(s) it is resolving.

165
LICENSE
View File

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

@@ -1,46 +1,29 @@
![Logo](https://i.imgur.com/c2k18E1.png)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal)](https://www.paypal.com/paypalme/KhopaDCSL)
[![Patreon](https://img.shields.io/badge/patreon-become%20a%20patron-orange?logo=patreon)](https://patreon.com/khopa)
[![Download](https://img.shields.io/github/downloads/dcs-liberation/dcs_liberation/total?label=Download)](https://github.com/dcs-liberation/dcs_liberation/releases)
[![Download](https://img.shields.io/github/downloads/khopa/dcs_liberation/total?label=Download)](https://github.com/Khopa/dcs_liberation/releases)
[![Discord](https://img.shields.io/discord/595702951800995872?label=Discord&logo=discord)](https://discord.gg/bKrtrkJ)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/dcs-liberation/dcs_liberation)](https://github.com/dcs-liberation/dcs_liberation)
[![GitHub issues](https://img.shields.io/github/issues/dcs-liberation/dcs_liberation)](https://github.com/dcs-liberation/dcs_liberation/issues)
![GitHub stars](https://img.shields.io/github/stars/dcs-liberation/dcs_liberation?style=social)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/khopa/dcs_liberation)](https://github.com/Khopa/dcs_liberation)
[![GitHub issues](https://img.shields.io/github/issues/khopa/dcs_liberation)](https://github.com/Khopa/dcs_liberation/issues)
![GitHub stars](https://img.shields.io/github/stars/khopa/dcs_liberation?style=social)
## About DCS Liberation
DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player or co-op dynamic campaign.
DCS Liberation is a [DCS World](https://www.digitalcombatsimulator.com/en/products/world/) turn based single-player semi dynamic campaign.
It is an external program that generates full and complex DCS missions and manage a persistent combat environment.
![Screenshot](https://user-images.githubusercontent.com/315852/120939254-0b4a9f80-c6cc-11eb-82f5-ce3f8d714bfe.png)
![Logo](https://imgur.com/B6tvlBJ.png)
## Downloads
Latest release is available here : https://github.com/dcs-liberation/dcs_liberation/releases
To download preview builds of the next version of DCS Liberation, see https://github.com/dcs-liberation/dcs_liberation/wiki/Preview-builds.
## DCS bugs
These DCS bugs prevent us from improving AI behavior. Please upvote them! (But please
_don't_ spam them with comments):
* [A2A and SEAD escorts don't escort](https://forums.eagle.ru/topic/251798-options-for-alternate-ai-escort-behavior/?tab=comments#comment-4668033)
* [DEAD can't use mixed loadouts effectively](https://forums.eagle.ru/topic/271941-ai-rtbs-after-firing-decoys-despite-full-load-of-bombs/)
## 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/dcs-liberation/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/dcs-liberation/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/dcs-liberation/dcs_liberation/wiki/Preview-builds) for that release. Items in the To do column are planned to be added to that release.
Latest release is available here : https://github.com/Khopa/dcs_liberation/releases
## Resources
Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/dcs-liberation/dcs_liberation/wiki/)
Tutorials, contributors and developer's guides are available in the project's [Wiki](https://github.com/Khopa/dcs_liberation/wiki/)
## Special Thanks
@@ -48,10 +31,6 @@ First, a big thanks to shdwp, for starting the original DCS Liberation project.
Then, DCS Liberation uses [pydcs](http://github.com/pydcs/dcs) for mission generation, and nothing would be possible without this.
It also uses the popular [Mist](https://github.com/mrSkortch/MissionScriptingTools) lua framework for mission scripting.
Excellent lua scripts DCS Liberation uses as plugins:
* For the JTAC feature, DCS Liberation embeds Ciribob's JTAC Autolase [script](https://github.com/ciribob/DCS-JTACAutoLaze).
* Walder's [Skynet-IADS](https://github.com/walder/Skynet-IADS) is used for Integrated Air Defense System.
And for the JTAC feature, DCS Liberation embed Ciribob's JTAC Autolase [script](https://github.com/ciribob/DCS-JTACAutoLaze).
Please also show some support to these projects !

View File

@@ -1,471 +1,16 @@
# 4.1.0
Saves from 4.0.0 are compatible with 4.1.0.
## Features/Improvements
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
* **[Campaign]** Added support for Mariana Islands map.
* **[Campaign AI]** Adjustments to aircraft selection priorities for most mission types.
* **[Engine]** Support for DCS 2.7.4.9632 and newer, including the Marianas map, F-16 JSOWs, NASAMS, and Tin Shield EWR.
* **[Flight Planning]** CAP patrol altitudes are now set per-aircraft. By default the altitude will be set based on the aircraft's maximum speed.
* **[Flight Planning]** CAP patrol speeds are now set per-aircraft to be more suitable/sensible. By default the speed will be set based on the aircraft's maximum speed.
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
* **[Mods]** Support for version v1.5.0-Beta of Gripen mod. In-progress campaigns may need to re-plan Gripen flights to pick up updated loadouts.
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
* **[Plugins]** Updated SkynetIADS to 2.2.0 (adds NASAMS support).
* **[UI]** Added ability to take notes and have those notes appear as a kneeboard page.
* **[UI]** Hovering over the weather information now dispalys the cloud base (meters and feet).
* **[UI]** Google search link added to unit information when there is no information provided.
* **[UI]** Control point name displayed with ground object group name on map.
* **[UI]** Buy or Replace will now show the correct price for generated ground objects like sams.
* **[UI]** Improved logging for frontline movement to be more descriptive about what happened and why.
* **[UI]** Brought ruler map module into source, which should fix file integrity issues with the module.
## Fixes
* **[Campaign]** Fixed the Silkworm generator to include launchers and not all radars.
* **[Data]** Fixed Introduction dates for targeting pods (ATFLIR and LITENING were both a few years too early).
* **[Data]** Removed SA-10 from Syria 2011 faction.
* **[Economy]** EWRs can now be bought and sold for the correct price and can no longer be used to generate money
* **[Flight Planning]** Helicopters are now correctly identified, and will fly ingress/CAS/BAI/egress and similar at low altitude.
* **[Flight Planning]** Fixed potential issue with angles > 360° or < 0° being generated when summing two angles.
* **[Mission Generation]** The lua data for other plugins is now generated correctly
* **[Mission Generation]** Fixed problem with opfor planning missions against sold ground objects like SAMs
* **[Mission Generation]** The legacy always-available tanker option no longer prevents mission creation.
* **[Mission Generation]** Prevent the creation of a transfer order with 0 units for a rare situtation when a point was captured.
* **[Mission Generation]** Planned transfers which will be impossible after a base capture will no longer prevent the mission result submit.
* **[Mission Generation]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
* **[Mission Generation]** Fix for AAA Flak generator using Opel Blitz preventing the mission from being generated because duplicate unit names were used.
* **[Campaign AI]** Transport aircraft will now be bought only if necessary at control points which can produce ground units and are capable to operate transport aircraft.
* **[UI]** Statistics window tick marks are now always integers.
* **[UI]** Statistics window now shows the correct info for the turn
* **[UI]** Toggling custom loadout for an aircraft with no preset loadouts no longer breaks the flight.
# 4.0.0
Saves from 3.x are not compatible with 4.0.
## Features/Improvements
* **[Engine]** Support for DCS 2.7.2.7910.1 and newer, including Cyprus, F-16 JDAMs, and the Hind.
* **[Campaign]** Squadrons now (optionally, off by default) have a maximum size and killed pilots replenish at a limited rate.
* **[Campaign]** Added an option to disable levelling up of AI pilots.
* **[Campaign]** Added Russian Intervention 2015 campaign on Syria, for a small and somewhat realistic Russian COIN scenario.
* **[Campaign]** Added Operation Atilla campaign on Syria, for a reasonably large invasion of Cyprus scenario.
* **[Campaign AI]** AI will plan Tanker flights.
* **[Campaign AI]** Removed max distance for AEW&C auto planning.
* **[Economy]** Adjusted prices for aircraft to balance out some price inconsistencies.
* **[Factions]** Added more tankers to factions.
* **[Flight Planner]** Added ability to plan Tankers.
* **[Modding]** Campaign format version is now 7.0 to account for DCS map changes that made scenery strike targets incompatible with existing campaigns.
* **[Mods]** Added support for the Gripen mod.
* **[Mods]** Removes MB-339PAN support, as the mod is now deprecated and no longer works with DCS 2.7+.
* **[Mission Generation]** Added support for "Neutral Dot" label options.
* **[New Game Wizard]** Mods are now selected via checkboxes in the new game wizard, not as separate factions.
* **[UI]** Ctrl click and shift click now buy or sell 5 or 10 units respectively.
* **[UI]** Multiple waypoints can now be deleted simultaneously if multiple waypoints are selected.
* **[UI]** Carriers and LHAs now match the colour of airfields, and their destination icons are translucent.
* **[UI]** Updated intel box text for first turn.
* **[UI]** Base Capture Cheat is now usable at all bases and can also be used to transfer player-owned bases to OPFOR.
* **[UI]** Pass Turn button is relabled as "Begin Campaign" on Turn 0.
* **[UI]** Added a ruler to the map.
* **[UI]** Liberation now saves games to `<DCS user directory>/Liberation/Saves` by default to declutter the main directory.
## Fixes
* **[Campaign AI]** Fix procurement for factions that lack some unit types.
* **[Campaign AI]** Fix auto purchase of aircraft for factions that have no transport aircraft.
* **[Campaign AI]** Fix refunding of pending aircraft purchases when a side has no factory available.
* **[Mission Generation]** Fixed problem with mission load when control point name contained an apostrophe.
* **[Mission Generation]** Fixed EWR group names so they contribute to Skynet again.
* **[Mission Generation]** Fixed duplicate name error when generating convoys and cargo ships when creating manual transfers after loading a game.
* **[Mission Generation]** Fixed empty convoys not being disbanded when all units are killed/removed.
* **[Mission Generation]** Fixed player losing frontline progress when skipping from turn 0 to turn 1.
* **[Mission Generation]** Fixed issue where frontline would only search to the right for valid locations.
* **[UI]** Made non-interactive map elements less obstructive.
* **[UI]** Added support for Neutral Dot difficulty label
* **[UI]** Clear skies at night no longer described as "Sunny" by the weather widget.
* **[UI]** Removed ability to buy (useless) ground units at carriers and LHAs.
* **[UI]** Fixed enable/disable of buy/sell buttons.
* **[UI]** EWRs now appear in the custom waypoint list.
# 3.0.0
Saves from 2.5 are not compatible with 3.0.
## Features/Improvements
* **[Campaign]** Ground units can now be transferred by road, airlift, and cargo ship. See https://github.com/dcs-liberation/dcs_liberation/wiki/Unit-Transfers for more information.
* **[Campaign]** Ground units can no longer be sold. To move units to a new location, transfer them.
* **[Campaign]** Ground units must now be recruited at a base with a factory and transferred to their destination. When buying units in the UI, the purchase will automatically be fulfilled at the closest factory, and a transfer will be created on the next turn.
* **[Campaign]** Non-control point FOBs will no longer spawn.
* **[Campaign]** Added squadrons and pilots. See https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots for more information.
* **[Campaign]** Capturing a base now depopulates all of its attached objectives with units: air defenses, EWRs, ships, armor groups, etc. Buildings are captured.
* **[Campaign]** Ammunition Depots determine how many ground units can be deployed on the frontline by a control point.
* **[Campaign AI]** AI now considers Ju-88s for CAS, strike, and DEAD missions.
* **[Campaign AI]** AI planned AEW&C missions will now be scheduled ASAP.
* **[Campaign AI]** AI now considers the range to the SAM's threat zone rather than the range to the SAM itself when determining target priorities.
* **[Campaign AI]** Auto purchase of ground units will now maintain unit composition instead of buying randomly. The unit composition is predefined.
* **[Campaign AI]** Auto purchase will aim to purchase enough ground units to support the frontline, plus 30% reserve units.
* **[Campaign AI]** Auto purchase will now adjust its air/ground balance to favor whichever is under-funded.
* **[Flight Planner]** Desired mission length is now configurable (defaults to 60 minutes). A BARCAP will be planned every 30 minutes. Other packages will simply have their takeoffs spread out or compressed such that the last flight will take off around the mission end time.
* **[Flight Planner]** Flight plans now include bullseye waypoints.
* **[Flight Planner]** Differentiated SEAD and SEAD escort. SEAD is tasked with suppressing the package target, SEAD escort is tasked with protecting the package from all SAMs along its route.
* **[Flight Planner]** Planned airspeed increased to 0.85 mach for supersonic airframes and 85% of max speed for subsonic.
* **[Flight Planner]** Taxi time estimation for airfields increased from 5 minutes to 8 minutes.
* **[Flight Planner]** Reduce expected error margin for flight plans from 10% to 5%.
* **[Flight Planner]** SEAD flights are scheduled one minute ahead of the package's TOT so that they can suppress the site ahead of the strike.
* **[Flight Planner]** Automatic ATO generation for the player's coalition can now be disabled in the settings.
* **[Payloads]** AI flights for most air to ground mission types (CAS excluded) will have their guns emptied to prevent strafing fully armed and operational battle stations. Gun-reliant airframes like A-10s and warbirds will keep their bullets.
* **[Kneeboard]** ATC table overflow alleviated by wrapping long airfield names and splitting ATC frequency and channel into separate rows.
* **[UI]** Overhauled the map implementation. Now uses satellite imagery instead of low res map images. Display options have moved from the toolbar to panels in the map.
* **[UI]** Campaigns generated for an older or newer version of the game will now be marked as incompatible. They can still be played, but bugs may be present.
* **[UI]** DCS loadouts are now selectable in the loadout setup menu.
* **[UI]** Added global aircraft inventory view under Air Wing dialog.
* **[UI]** Base menu now shows information about ground unit deployment limits.
* **[Modding]** Campaigns now choose locations for factories to spawn.
* **[Modding]** Campaigns now choose locations for ammunition depots to spawn.
* **[Modding]** Campaigns now use map structures as strike targets.
* **[Modding]** Campaigns may now set *any* objective type to be a required spawn rather than random chance. Support for random objective generation was removed.
* **[Modding]** Campaigns may now place AAA objectives.
* **[Modding]** Can now install custom factions to <DCS saved games>/Liberation/Factions instead of the Liberation install directory.
* **[Performance Settings]** Added a settings to lower the number of smoke effects generated on frontlines. Lowered default settings for frontline smoke generators, so less smoke should be generated by default.
* **[Configuration]** Liberation preferences (DCS install and save game location) are now saved to `%LOCALAPPDATA%/DCSLiberation` to prevent needing to reconfigure each new install.
* **[Skynet]** Updated to 2.1.0.
## Fixes
* **[Campaign AI]** Fix purchase of aircraft by priority (the faction's list was being used as the priority list rather than the game's).
* **[Campaign AI]** Fixed bug causing AI to over-purchase cheap aircraft.
* **[Campaign AI]** Auto planner will no longer attempt to plan missions for which the faction has no compatible aircraft.
* **[Campaign AI]** Stop purchasing aircraft after the first unaffordable package to attempt to complete more packages rather than filling airfields with cheap escorts that will never be used.
* **[Campaign]** Fixed bug where offshore strike locations were being used to spawn ship objectives.
* **[Campaign]** EWR sites are now purchasable.
* **[Flight Planner]** AI strike flight plans now include the correct target actions for building groups.
* **[Flight Planner]** AI BAI/DEAD/SEAD flights now have tasks to attack all groups at the target location, not just the primary group (for multi-group SAM sites).
* **[Flight Planner]** Fixed some contexts where damaged runways would be used. Destroying a carrier will no longer break the game.
# 2.5.1
## Features/Improvements
* **[UI]** Engagement ranges are now displayed by default.
* **[UI]** Engagement range display generalized to work for all patrolling flight plans (BARCAP, TARCAP, and CAS).
* **[Flight Planner]** Front lines no longer project threat zones to avoid pushing BARCAPs back so much. TARCAPs will be forcibly planned but strike packages will not route around front lines even if it is reasonable to do so.
## Fixes
* **[Campaigns]** EWRs associated with a base will now only be generated near the base.
* **[Flight Planner]** Fixed error when generating AEW&C flight plans in campaigns with no front lines.
# 2.5.0
Saves from 2.4 are not compatible with 2.5.
## Features/Improvements
* **[Engine]** DCS 2.7 Support
* **[UI]** Improved FOB menu, added a custom banner, and do not display aircraft recruitment menu
* **[Flight Planner]** Added AEW&C missions. (by siKruger)
* **[Kneeboard]** Added dark kneeboard option (by GvonH)
* **[Campaigns]** Multiple EWR sites may now be generated, and EWR sites may be generated outside bases (by SnappyComebacks)
* **[Mission Generation]** Cloudy and rainy (but not thunderstorm) weather will use the cloud presets from DCS 2.7.
* **[Plugins]** Added LotATC export plugin (by drsoran)
* **[Plugins]** Added Splash Damage Plugin (by Wheelijoe)
* **[Loadouts]** Replaced Litening with ATFLIR for all default F/A-18C loadouts.
## Fixes
* **[Flight Planner]** Front lines now project threat zones, so TARCAP/escorts will not be pruned for flights near the front. Packages may also route around the front line when practical.
* **[Flight Planner]** Fixed error when planning BAI at SAMs with dead subgroups.
* **[Flight Planner]** Mig-19 was not allowed for CAS roles fixed
* **[Flight Planner]** Increased size of navigation planning area to avoid plannign failures with distant waypoints.
* **[Flight Planner]** Fixed UI refresh when unchecking the "default loadout" box in the loadout editor.
* **[Objective names]** Fixed typos in objective name : ARMADILLLO -> ARMADILLO (by SnappyComebacks)
* **[Payloads]** F-86 Sabre was missing a custom payload
* **[Payloads]** Added GAR-8 period restrictions (by Mustang-25)
* **[Campaign]** Date now progresses.
* **[Campaign]** Added game over message when a coalition runs out of functioning airbases.
* **[Mission Generation]** Fixed "invalid face handle" error in kneeboard generation that occurred on some machines.
## Regressions
* **[Mod Support]** Stopped support for 2.5.5 Rafale Mode, and removed factions that were using it
* **[Mod Support]** Su-57 mod support might be out of date
# 2.4.3
## Features/Improvements
* **[New Game Wizard]** Added the possibility to setup custom start date
## Fixes
* **[Mods]** Updated C-130J mod data to version 6.4
* **[Mods]** Updated F-22A mod to latest version
# 2.4.2
## Features/Improvements
* **[Factions]** Introduction dates and fallback weapons added for US, Russian, UK, and French weapons. Huge thanks to @TheCandianVendingMachine for the massive amount of data entry!
* **[Campaigns]** Added 1995 start dates.
## Fixes
* **[Economy]** Pending ground unit purchases will also be transferred when a connected base is captured.
* **[UI]** Fixed rounding of budget in recruitment menu.
# 2.4.1
## Fixes
* **[Units]** Fixed syntax error with the SH-60B payload file.
* **[Culling]** Missile sites generate reasonably sized non-cull zones rather than 100km ones.
* **[UI]** Budget display is also now rounded to 2 decimal places.
* **[UI]** Fixed some areas where the old, non-pretty name was displayed to users.
# 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
## Fixes:
[Mission Generator] Mission generator would crash when generating fire missions for destroyed SCUD sites - fixed
# 2.3.3
## Features/Improvements
* **[Campaigns]** Reworked Golan Heights campaign on Syria, (Added FOB and preset locations for SAMS)
* **[Campaigns]** Added a lite version of the Golan Heights campaign
* **[Campaigns]** Reworked Syrian Civil War campaign (Added FOB and preset locations for SAMS)
* **[Campaigns]** Reworked Emirates campaign
* **[Campaigns]** AA units added to frontlines and updated all factions to include some frontline AA units.
* **[Mission Generator]** Infantry will only be generated for APC and IFV groups
* **[Mission Generator]** Infantry squads size is not randomized anymore
* **[Mission Generator]** Infantry squads can have a mortar.
* **[Mission Generator]** SCUD missiles sites will now fire on enemy controls points in range when possible
* **[Factions]** Updated Nato Desert Storm to include F-14A
* **[Factions]** Updated Iraq 1991 factions to include Zsu-57 and Mig-29A
* **[Factions]** Germany 1944, added Stug III and Stug IV
* **[Factions]** Added factions Insurgents (Hard) with better and more weapons
* **[Plugins]** [The EWRS plugin](https://github.com/Bob7heBuilder/EWRS) is now included.
* **[UI]** Added enemy intelligence summary and details window.
## Fixes:
* **[Factions]** AI would never buy artillery units for the frontline - fixed
* **[Factions]** Removed the F-111 unit from the NATO desert storm faction. (Recruiting it would cause crashes in DCS, since it is not a valid unit)
* **[Campaign]** Automatic redeployment of ground units would sometimes fail - fixed
* **[Mission Generator]** Artillery groups would retreat in the wrong direction - fixed
* **[Units]** Fixed SPG_Stryker_M1128_MGS not being in db
* **[UI]** Fixed and added many missing ground units icons
* **[UI]** Ship groups could be replaced by SAM sites in the UI, which would lead to broken mission being generated - fixed
* **[New Game Wizard]** Removed the "mid game" campaign generator option which is currently broken
* **[Mission Generator]** Empty navy groups will no longer be generated
* **[Mission Generator]** Fixed BAI, SEAD, and DEAD flights ocassionally being assigned the wrong targets.
* **[Flight Planner]** Fixed not being able to plan packages against opfor carriers
* **[UI]** Repaired SAMs no longer show as dead.
* **[UI]** Fixed not being able to manage a disbanded site after disbanding and closing the base menu.
# 2.3.2
## Features/Improvements
* **[Units]** Support for newly added BTR-82A, T-72B3
* **[Units]** Added ZSU-57 AAA sites
* **[Culling]** BARCAP missions no longer create culling exclusion zones.
* **[Flight Planner]** Improved TOT planning. Negative start times no longer occur with TARCAPs and hold times no longer affect planning for flight plans without hold points.
* **[Factions]** Added Iraq 1991 faction (thanks again to Hawkmoon!)
## Fixes:
* **[Mission Generator]** Fix mission generation error when there are too many radio frequency to setup for the Mig-21
* **[Mission Generator]** Fix ground units not moving forward
* **[Mission Generator]** Fixed assigned radio channels overlapping with beacons.
* **[Flight Planner]** Fix creation of custom waypoints.
* **[Campaigns]** Fixed many cases of SAMs spawning on the runways/taxiways in Syria Full.
# 2.3.1
## Features/Improvements
* **[UX]** Added a warning message when the player is attempting to buy more planes at an already full airbase.
* **[Campaigns]** Migrated Syria full map to new format. (Thanks to Hawkmoon)
* **[Faction]** Added NATO desert Storm faction (Thanks to Hawkmoon)
## Fixes:
* **[AI]** CAP flights will engage enemies again.
* **[Campaigns]** Fixed a missing path on the Caucasus Full Map campaign
# 2.3.0
## Features/Improvements
* **[Campaign Map]** Overhauled the campaign model
* **[Campaign Map]** Possible to add FOB as control points
* **[Campaign Map]** Added off-map spawn locations
* **[Campaign AI]** Overhauled AI recruiting behaviour
* **[Campaign AI]** Added AI procurement for Blue
* **[Campaign]** New Campaign: "Black Sea"
* **[Mission Planner]** Possible to move carrier and tarawa on the campaign map
* **[Mission Generator]** Infantry squads on frontline can have manpads
* **[Mission Generator]** Unused aircraft now spawned to allow for OCA strikes
* **[Mission Generator]** Opfor now obeys parking limits
* **[Mission Generator]** Support for Anubis C-130 Hercules mod
* **[Flight Planner]** Added fighter sweep missions.
* **[Flight Planner]** Added BAI missions.
* **[Flight Planner]** Added anti-ship missions.
* **[Flight Planner]** Differentiated BARCAP and TARCAP. TARCAP is now for hostile areas and will arrive before the package.
* **[Flight Planner]** Added OCA missions
* **[Flight Planner]** Added Alternate/divert airfields
* **[Culling]** Added possibility to include/exclude carriers from culling zones
* **[QOL]** On liberation startup, your latest save game is loaded automatically
* **[Units]** Reduced starting fuel load for C101
* **[UI]** Inform the user of the weather
* **[UI]** Added toolbar buttons to change map display settings
* **[Game]** Added new Economy options for adjusting income multipliers and starting budgets.
## Fixes :
* **[Map]** Missiles sites now have a proper icon and will not re-use the SAM sites icon
* **[Mission Generator]** Ground unit waypoints improperly set to "On Road" - fixed
* **[Mission Generator]** Target waypoints not at ground level - fixed
* **[Mission Generator]** Selected skill not applied to Helicopters - fixed
* **[Mission Generator]** Ground units do not always spawn - fixed
* **[Kneeboard]** Briefing waypoints off by one - fixed
* **[Game]** Destroyed buildings still granting budget - fixed
# 2.2.1
## Features/Improvements
* **[Factions]** Added factions : Georgia 2008, USN 1985, France 2005 Frenchpack by HerrTom
* **[Factions]** Added map Persian Gulf full by Plob
* **[Flight Planner]** Player flights with start delays under ten minutes will spawn immediately.
* **[UI]** Mission start screen now informs players about delayed flights.
* **[Units]** Added support for F-14A-135-GR
* **[Modding]** Possible to setup liveries overrides in factions definition files
## Fixes :
* **[Flight Planner]** Hold, join, and split points are planned cautiously near enemy airfields. Ascend/descend points are no longer planned.
* **[Flight Planner]** Custom waypoints are usable again. Not that in most cases custom flight plans will revert to the 2.1 flight planning behavior.
* **[Flight Planner]** Fixed UI bug that made it possible to create empty flights which would throw an error.
* **[Flight Planner]** Player flights from carriers will now be delayed correctly according to the player's settings.
* **[Misc]** Spitfire variant with clipped wings was not seen as flyable by DCS Liberation (hence could not be setup as client/player slot)
* **[Misc]** Updated Syria terrain parking slots database, the out-of-date database could end up generating aircraft in wrong slots (We are still experiencing issues with somes airbases, such as Khalkhalah though)
# 2.2.0
# 2.2.X
## Features/Improvements :
* **[Campaign Generator]** Added early warning radar generation
* **[Campaign Generator]** Added scud launcher sites
* **[Cheat Menu]** Added ability to capture base from mission planner
* **[Cheat Menu]** Added ability to show red ATO
* **[Factions]** Added WW2 factions that do not depend on WW2 asset pack
* **[Factions]** Cold War / Middle eastern factions will use Flak sites
* **[Flight Planner]** Flight planner overhaul, with package and TOT system
* **[Flight Planner]** Pick runways and ascent/descent based on headwind
* **[Map]** Added polygon debug mode display
* **[Map]** Highlight the selected flight path on the map
* **[Map]** Improved flight plan display settings
* **[Map]** Improved SAM display settings
* **[Map]** Improved flight plan display settings
* **[Map]** Caucasus and The Channel map use a new system to generate SAM and strike target location to reduce probability of targets generated in the middle of a forests
* **[Misc]** Flexible Dedicated Hosting Options for Mission Files via environment variables
* **[Moddability]** Custom campaigns can be designed through json files
* **[Moddability]** LUA plugins can now be injected into Liberation missions.
* **[Moddability]** Optional Skynet IADS lua plugin now included
* **[New Game]** Starting budget can be freely selected
* **[New Game]** Exanded information for faction and campaign selection in the new game wizard
* **[UI]** Add double and right click actions to many UI elements.
* **[UI]** Add polygon drawing mode for map background
* **[UI]** Added a warning if you press takeoff with no player enabled flights
* **[UI]** Packages and flights now visible in the main window sidebar
* **[Units/Factions]** Added bombers to some coalitions
* **[Units/Factions]** Added support for SU-57 mod by Cubanace
* **[Units]** Added Freya EWR sites to german WW2 factions
* **[Units]** Added support for many bombers (B-52H, B-1B, Tu-22, Tu-142)
* **[Units]** Added support for new P-47 variants
* **[Map]** Added polygon debug mode display
* **[New Game]** Starting budget can be freely selected
* **[Moddability]** Custom campaigns can be designed through json files
## Fixes :
* **[Campaign Generator]** Big airbases could end up without any airbase defense.
* **[Campaign generator]** Ship group and offshore buildings should not be generated on land anymore
* **[Flight Planner]** Fix waypoint alitudes for helicopters
* **[Flight Planner]** Fixed CAS aircraft wandering away from frontline
* **[Maps]** Incirlik airbase was missing exclusions zones, so SAMS could end up being generated on the runway
* **[Mission Generator]** Fixed player/client confusion when a flight had only one player slot.
* **[Radios]** Fix A-10C radio
* **[UI]** Many missing unit icons were added
* **[Campaign generator]** Ship group and offshore buildings should not be generated on land anymore
* **[UI]** Missing TER weapons in custom payload now selectable.
# 2.1.5
@@ -744,4 +289,4 @@ Sorry :(
* **[Mission Generator]** Planned flights will spawn even if their home base has been captured or is being contested by enemy ground units.
* **[Campaign Generator]** Base defenses would not be generated on Normandy map and in some rare cases on others maps as well
* **[Mission Planning]** CAS waypoints created from the "Predefined waypoint selector" would not be at the exact location of the frontline
* **[Naming]** CAP mission flown from airbase are not named BARCAP anymore (CAP from carrier is still named BARCAP)
* **[Naming]** CAP mission flown from airbase are not named BARCAP anymore (CAP from carrier is still named BARCAP)

View File

@@ -1,3 +1,2 @@
from .game import Game
from . import db
from .version import VERSION
from . import db

21
game/data/aaa_db.py Normal file
View File

@@ -0,0 +1,21 @@
from dcs.vehicles import AirDefence
AAA_UNITS = [
AirDefence.SPAAA_Gepard,
AirDefence.SPAAA_ZSU_23_4_Shilka,
AirDefence.AAA_Vulcan_M163,
AirDefence.AAA_ZU_23_Closed,
AirDefence.AAA_ZU_23_Emplacement,
AirDefence.AAA_ZU_23_on_Ural_375,
AirDefence.AAA_ZU_23_Insurgent_Closed,
AirDefence.AAA_ZU_23_Insurgent_on_Ural_375,
AirDefence.AAA_ZU_23_Insurgent,
AirDefence.AAA_8_8cm_Flak_18,
AirDefence.AAA_Flak_38,
AirDefence.AAA_8_8cm_Flak_36,
AirDefence.AAA_8_8cm_Flak_37,
AirDefence.AAA_Flak_Vierling_38,
AirDefence.AAA_Kdo_G_40,
AirDefence.AAA_8_8cm_Flak_41,
AirDefence.AAA_Bofors_40mm
]

View File

@@ -1,42 +0,0 @@
from dcs.unit import Unit
from dcs.vehicles import AirDefence
class AlicCodes:
CODES = {
AirDefence._1L13_EWR.id: 101,
AirDefence._55G6_EWR.id: 102,
AirDefence.S_300PS_40B6MD_sr.id: 103,
AirDefence.S_300PS_64H6E_sr.id: 104,
AirDefence.SA_11_Buk_SR_9S18M1.id: 107,
AirDefence.Kub_1S91_str.id: 108,
AirDefence.Dog_Ear_radar.id: 109,
AirDefence.S_300PS_40B6M_tr.id: 110,
AirDefence.SA_11_Buk_LN_9A310M1.id: 115,
AirDefence.Osa_9A33_ln.id: 117,
AirDefence.Strela_10M3.id: 118,
AirDefence.Tor_9A331.id: 119,
AirDefence._2S6_Tunguska.id: 120,
AirDefence.ZSU_23_4_Shilka.id: 121,
AirDefence.P_19_s_125_sr.id: 122,
AirDefence.Snr_s_125_tr.id: 123,
AirDefence.Rapier_fsa_blindfire_radar.id: 124,
AirDefence.Rapier_fsa_launcher.id: 125,
AirDefence.SNR_75V.id: 126,
AirDefence.HQ_7_LN_SP.id: 127,
AirDefence.HQ_7_STR_SP.id: 128,
AirDefence.RLS_19J6.id: 130,
AirDefence.Roland_ADS.id: 201,
AirDefence.Patriot_str.id: 202,
AirDefence.Hawk_sr.id: 203,
AirDefence.Hawk_tr.id: 204,
AirDefence.Roland_Radar.id: 205,
AirDefence.Hawk_cwar.id: 206,
AirDefence.Gepard.id: 207,
AirDefence.Vulcan.id: 208,
AirDefence.NASAMS_Radar_MPQ64F1.id: 209,
}
@classmethod
def code_for(cls, unit: Unit) -> int:
return cls.CODES[unit.type]

View File

@@ -1,63 +1,16 @@
import inspect
import dcs
DEFAULT_AVAILABLE_BUILDINGS = [
"fuel",
"comms",
"oil",
"ware",
"farp",
"power",
"derrick",
]
DEFAULT_AVAILABLE_BUILDINGS = ['fuel', 'ammo', 'comms', 'oil', 'ware', 'farp', 'fob', 'power', 'factory', 'derrick', 'aa', 'iads-controlcenter', 'iads-ewr', 'iads-commnode', 'iads-power']
WW2_FREE = ["fuel", "ware"]
WW2_GERMANY_BUILDINGS = [
"fuel",
"ww2bunker",
"ww2bunker",
"ww2bunker",
"allycamp",
"allycamp",
]
WW2_ALLIES_BUILDINGS = [
"fuel",
"allycamp",
"allycamp",
"allycamp",
"allycamp",
"allycamp",
]
WW2_GERMANY_BUILDINGS = ['fuel', 'factory', 'ww2bunker', 'ww2bunker', 'ww2bunker', 'allycamp', 'allycamp', 'aa']
WW2_ALLIES_BUILDINGS = ['fuel', 'factory', 'allycamp', 'allycamp', 'allycamp', 'allycamp', 'allycamp', 'aa']
FORTIFICATION_BUILDINGS = [
"Siegfried Line",
"Concertina wire",
"Concertina Wire",
"Czech hedgehogs 1",
"Czech hedgehogs 2",
"Dragonteeth 1",
"Dragonteeth 2",
"Dragonteeth 3",
"Dragonteeth 4",
"Dragonteeth 5",
"Haystack 1",
"Haystack 2",
"Haystack 3",
"Haystack 4",
"Hemmkurvenvenhindernis",
"Log posts 1",
"Log posts 2",
"Log posts 3",
"Log ramps 1",
"Log ramps 2",
"Log ramps 3",
"Belgian Gate",
"Container white",
]
FORTIFICATION_BUILDINGS = ['Siegfried Line', 'Concertina wire', 'Concertina Wire', 'Czech hedgehogs 1', 'Czech hedgehogs 2',
'Dragonteeth 1', 'Dragonteeth 2', 'Dragonteeth 3', 'Dragonteeth 4', 'Dragonteeth 5',
'Haystack 1', 'Haystack 2', 'Haystack 3', 'Haystack 4', 'Hemmkurvenvenhindernis',
'Log posts 1', 'Log posts 2', 'Log posts 3', 'Log ramps 1', 'Log ramps 2', 'Log ramps 3',
'Belgian Gate', 'Container white']
FORTIFICATION_UNITS = [
c for c in vars(dcs.vehicles.Fortification).values() if inspect.isclass(c)
]
FORTIFICATION_UNITS_ID = [
c.id for c in vars(dcs.vehicles.Fortification).values() if inspect.isclass(c)
]
FORTIFICATION_UNITS = [c for c in vars(dcs.vehicles.Fortification).values() if inspect.isclass(c)]
FORTIFICATION_UNITS_ID = [c.id for c in vars(dcs.vehicles.Fortification).values() if inspect.isclass(c)]

View File

@@ -0,0 +1,51 @@
from dcs.planes import (
Bf_109K_4,
C_101CC,
FW_190A8,
FW_190D9,
F_5E_3,
F_86F_Sabre,
I_16,
L_39ZA,
MiG_15bis,
MiG_19P,
MiG_21Bis,
P_47D_30,
P_51D,
P_51D_30_NA,
SpitfireLFMkIX,
SpitfireLFMkIXCW,
)
from pydcs_extensions.a4ec.a4ec import A_4E_C
"""
This list contains the aircraft that do not use the guns as the last resort weapons, but as a main weapon
They'll RTB when they don't have gun ammo left
"""
GUNFIGHTERS = [
# Cold War
MiG_15bis,
MiG_19P,
MiG_21Bis,
F_86F_Sabre,
A_4E_C,
F_5E_3,
# Trainers
C_101CC,
L_39ZA,
# WW2
P_51D_30_NA,
P_51D,
P_47D_30,
SpitfireLFMkIXCW,
SpitfireLFMkIX,
Bf_109K_4,
FW_190D9,
FW_190A8,
I_16,
]

View File

@@ -1,20 +1,6 @@
from dataclasses import dataclass
from datetime import timedelta
from dcs.task import Reconnaissance
from game.utils import Distance, feet, nautical_miles
from game.data.groundunitclass import GroundUnitClass
@dataclass
class GroundUnitProcurementRatios:
ratios: dict[GroundUnitClass, float]
def for_unit_class(self, unit_class: GroundUnitClass) -> float:
try:
return self.ratios[unit_class] / sum(self.ratios.values())
except KeyError:
return 0.0
from game.utils import nm_to_meter, feet_to_meter
@dataclass(frozen=True)
@@ -25,45 +11,24 @@ class Doctrine:
strike: bool
antiship: bool
rendezvous_altitude: Distance
hold_distance: Distance
push_distance: Distance
join_distance: Distance
split_distance: Distance
ingress_egress_distance: Distance
ingress_altitude: Distance
egress_altitude: Distance
strike_max_range: int
sead_max_range: int
min_patrol_altitude: Distance
max_patrol_altitude: Distance
pattern_altitude: Distance
rendezvous_altitude: int
join_distance: int
split_distance: int
ingress_egress_distance: int
ingress_altitude: int
egress_altitude: int
#: The duration that CAP flights will remain on-station.
cap_duration: timedelta
min_patrol_altitude: int
max_patrol_altitude: int
pattern_altitude: int
#: The minimum length of the CAP race track.
cap_min_track_length: Distance
#: The maximum length of the CAP race track.
cap_max_track_length: Distance
#: The minimum distance between the defended position and the *end* of the
#: CAP race track.
cap_min_distance_from_cp: Distance
#: The maximum distance between the defended position and the *end* of the
#: CAP race track.
cap_max_distance_from_cp: Distance
#: The engagement range of CAP flights. Any enemy aircraft within this range
#: of the CAP's current position will be engaged by the CAP.
cap_engagement_range: Distance
cas_duration: timedelta
sweep_distance: Distance
ground_unit_procurement_ratios: GroundUnitProcurementRatios
cap_min_track_length: int
cap_max_track_length: int
cap_min_distance_from_cp: int
cap_max_distance_from_cp: int
MODERN_DOCTRINE = Doctrine(
@@ -72,36 +37,21 @@ MODERN_DOCTRINE = Doctrine(
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
split_distance=nautical_miles(20),
ingress_egress_distance=nautical_miles(45),
ingress_altitude=feet(20000),
egress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(15),
cap_max_track_length=nautical_miles(40),
cap_min_distance_from_cp=nautical_miles(10),
cap_max_distance_from_cp=nautical_miles(40),
cap_engagement_range=nautical_miles(50),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(60),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 3,
GroundUnitClass.Atgm: 2,
GroundUnitClass.Apc: 2,
GroundUnitClass.Ifv: 3,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 2,
GroundUnitClass.Recon: 1,
}
),
strike_max_range=1500000,
sead_max_range=1500000,
rendezvous_altitude=feet_to_meter(25000),
join_distance=nm_to_meter(20),
split_distance=nm_to_meter(20),
ingress_egress_distance=nm_to_meter(45),
ingress_altitude=feet_to_meter(20000),
egress_altitude=feet_to_meter(20000),
min_patrol_altitude=feet_to_meter(15000),
max_patrol_altitude=feet_to_meter(33000),
pattern_altitude=feet_to_meter(5000),
cap_min_track_length=nm_to_meter(15),
cap_max_track_length=nm_to_meter(40),
cap_min_distance_from_cp=nm_to_meter(10),
cap_max_distance_from_cp=nm_to_meter(40),
)
COLDWAR_DOCTRINE = Doctrine(
@@ -110,36 +60,21 @@ COLDWAR_DOCTRINE = Doctrine(
sead=True,
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(10),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
split_distance=nautical_miles(10),
ingress_egress_distance=nautical_miles(30),
ingress_altitude=feet(18000),
egress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(12),
cap_max_track_length=nautical_miles(24),
cap_min_distance_from_cp=nautical_miles(8),
cap_max_distance_from_cp=nautical_miles(25),
cap_engagement_range=nautical_miles(35),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(40),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 4,
GroundUnitClass.Atgm: 2,
GroundUnitClass.Apc: 3,
GroundUnitClass.Ifv: 2,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 2,
GroundUnitClass.Recon: 1,
}
),
strike_max_range=1500000,
sead_max_range=1500000,
rendezvous_altitude=feet_to_meter(22000),
join_distance=nm_to_meter(10),
split_distance=nm_to_meter(10),
ingress_egress_distance=nm_to_meter(30),
ingress_altitude=feet_to_meter(18000),
egress_altitude=feet_to_meter(18000),
min_patrol_altitude=feet_to_meter(10000),
max_patrol_altitude=feet_to_meter(24000),
pattern_altitude=feet_to_meter(5000),
cap_min_track_length=nm_to_meter(12),
cap_max_track_length=nm_to_meter(24),
cap_min_distance_from_cp=nm_to_meter(8),
cap_max_distance_from_cp=nm_to_meter(25),
)
WWII_DOCTRINE = Doctrine(
@@ -148,33 +83,19 @@ WWII_DOCTRINE = Doctrine(
sead=False,
strike=True,
antiship=True,
hold_distance=nautical_miles(5),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
split_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
ingress_egress_distance=nautical_miles(7),
ingress_altitude=feet(8000),
egress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),
pattern_altitude=feet(5000),
cap_duration=timedelta(minutes=30),
cap_min_track_length=nautical_miles(8),
cap_max_track_length=nautical_miles(18),
cap_min_distance_from_cp=nautical_miles(0),
cap_max_distance_from_cp=nautical_miles(5),
cap_engagement_range=nautical_miles(20),
cas_duration=timedelta(minutes=30),
sweep_distance=nautical_miles(10),
ground_unit_procurement_ratios=GroundUnitProcurementRatios(
{
GroundUnitClass.Tank: 3,
GroundUnitClass.Atgm: 3,
GroundUnitClass.Apc: 3,
GroundUnitClass.Artillery: 1,
GroundUnitClass.Shorads: 3,
GroundUnitClass.Recon: 1,
}
),
strike_max_range=1500000,
sead_max_range=1500000,
join_distance=nm_to_meter(5),
split_distance=nm_to_meter(5),
rendezvous_altitude=feet_to_meter(10000),
ingress_egress_distance=nm_to_meter(7),
ingress_altitude=feet_to_meter(8000),
egress_altitude=feet_to_meter(8000),
min_patrol_altitude=feet_to_meter(4000),
max_patrol_altitude=feet_to_meter(15000),
pattern_altitude=feet_to_meter(5000),
cap_min_track_length=nm_to_meter(8),
cap_max_track_length=nm_to_meter(18),
cap_min_distance_from_cp=nm_to_meter(0),
cap_max_distance_from_cp=nm_to_meter(5),
)

View File

@@ -1,17 +0,0 @@
from __future__ import annotations
from enum import unique, Enum
@unique
class GroundUnitClass(Enum):
Tank = "Tank"
Atgm = "ATGM"
Ifv = "IFV"
Apc = "APC"
Artillery = "Artillery"
Logistics = "Logistics"
Recon = "Recon"
Infantry = "Infantry"
Shorads = "SHORADS"
Manpads = "MANPADS"

View File

@@ -1,108 +1,74 @@
from dcs.ships import (
PIOTR,
MOSCOW,
VINSON,
CVN_71,
CVN_72,
CVN_73,
Stennis,
KUZNECOW,
CV_1143_5,
NEUSTRASH,
ALBATROS,
REZKY,
MOLNIYA,
LHA_Tarawa,
PERRY,
TICONDEROG,
Type_052B,
Type_052C,
Type_054A,
CGN_1144_2_Pyotr_Velikiy,
CG_1164_Moskva,
CVN_70_Carl_Vinson,
CVN_71_Theodore_Roosevelt,
CVN_72_Abraham_Lincoln,
CVN_73_George_Washington,
CVN_74_John_C__Stennis,
CV_1143_5_Admiral_Kuznetsov,
CV_1143_5_Admiral_Kuznetsov_2017,
FFG_11540_Neustrashimy,
FFL_1124_4_Grisha,
FF_1135M_Rezky,
FSG_1241_1MP_Molniya,
LHA_1_Tarawa,
Oliver_Hazzard_Perry_class,
Ticonderoga_class,
Type_052B_Destroyer,
Type_052C_Destroyer,
Type_054A_Frigate,
USS_Arleigh_Burke_IIa,
)
from dcs.vehicles import AirDefence
TELARS = {
AirDefence._2S6_Tunguska,
AirDefence.SA_11_Buk_SR_9S18M1,
AirDefence.Osa_9A33_ln,
AirDefence.Tor_9A331,
AirDefence.Roland_ADS,
}
UNITS_WITH_RADAR = [
TRACK_RADARS = {
AirDefence.Kub_1S91_str,
AirDefence.Snr_s_125_tr,
AirDefence.S_300PS_40B6M_tr,
AirDefence.Hawk_tr,
AirDefence.Patriot_str,
AirDefence.SNR_75V,
AirDefence.Rapier_fsa_blindfire_radar,
AirDefence.HQ_7_STR_SP,
}
LAUNCHER_TRACKER_PAIRS = {
AirDefence.Kub_2P25_ln: AirDefence.Kub_1S91_str,
AirDefence._5p73_s_125_ln: AirDefence.Snr_s_125_tr,
AirDefence.S_300PS_5P85C_ln: AirDefence.S_300PS_40B6M_tr,
AirDefence.S_300PS_5P85D_ln: AirDefence.S_300PS_40B6M_tr,
AirDefence.Hawk_ln: AirDefence.Hawk_tr,
AirDefence.Patriot_ln: AirDefence.Patriot_str,
AirDefence.S_75M_Volhov: AirDefence.SNR_75V,
AirDefence.Rapier_fsa_launcher: AirDefence.Rapier_fsa_blindfire_radar,
AirDefence.HQ_7_LN_SP: AirDefence.HQ_7_STR_SP,
}
UNITS_WITH_RADAR = {
# Radars
AirDefence._2S6_Tunguska,
AirDefence.SA_11_Buk_LN_9A310M1,
AirDefence.Osa_9A33_ln,
AirDefence.Tor_9A331,
AirDefence.Gepard,
AirDefence.Vulcan,
AirDefence.Roland_ADS,
AirDefence.ZSU_23_4_Shilka,
AirDefence._1L13_EWR,
AirDefence.Kub_1S91_str,
AirDefence.S_300PS_40B6M_tr,
AirDefence.S_300PS_40B6MD_sr,
AirDefence._55G6_EWR,
AirDefence.S_300PS_64H6E_sr,
AirDefence.SA_11_Buk_SR_9S18M1,
AirDefence.Dog_Ear_radar,
AirDefence.Hawk_tr,
AirDefence.Hawk_sr,
AirDefence.Patriot_str,
AirDefence.Hawk_cwar,
AirDefence.P_19_s_125_sr,
AirDefence.Roland_Radar,
AirDefence.Snr_s_125_tr,
AirDefence.SNR_75V,
AirDefence.Rapier_fsa_blindfire_radar,
AirDefence.HQ_7_LN_SP,
AirDefence.HQ_7_STR_SP,
AirDefence.FuMG_401,
AirDefence.FuSe_65,
AirDefence.SAM_SA_15_Tor_9A331,
AirDefence.SAM_SA_11_Buk_CC_9S470M1,
AirDefence.SAM_Patriot_AMG_AN_MRC_137,
AirDefence.SAM_Patriot_ECS_AN_MSQ_104,
AirDefence.SPAAA_Gepard,
AirDefence.AAA_Vulcan_M163,
AirDefence.SPAAA_ZSU_23_4_Shilka,
AirDefence.EWR_1L13,
AirDefence.SAM_SA_6_Kub_STR_9S91,
AirDefence.SAM_SA_10_S_300PS_TR_30N6,
AirDefence.SAM_SA_10_S_300PS_SR_5N66M,
AirDefence.EWR_55G6,
AirDefence.SAM_SA_10_S_300PS_SR_64H6E,
AirDefence.SAM_SA_11_Buk_SR_9S18M1,
AirDefence.CP_9S80M1_Sborka,
AirDefence.SAM_Hawk_TR_AN_MPQ_46,
AirDefence.SAM_Hawk_SR_AN_MPQ_50,
AirDefence.SAM_Patriot_STR_AN_MPQ_53,
AirDefence.SAM_Hawk_CWAR_AN_MPQ_55,
AirDefence.SAM_SR_P_19,
AirDefence.SAM_Roland_EWR,
AirDefence.SAM_SA_3_S_125_TR_SNR,
AirDefence.SAM_SA_2_TR_SNR_75_Fan_Song,
AirDefence.HQ_7_Self_Propelled_STR,
# Ships
VINSON,
PERRY,
TICONDEROG,
ALBATROS,
KUZNECOW,
MOLNIYA,
MOSCOW,
NEUSTRASH,
PIOTR,
REZKY,
CV_1143_5,
Stennis,
CVN_71,
CVN_72,
CVN_73,
CVN_70_Carl_Vinson,
Oliver_Hazzard_Perry_class,
Ticonderoga_class,
FFL_1124_4_Grisha,
CV_1143_5_Admiral_Kuznetsov,
FSG_1241_1MP_Molniya,
CG_1164_Moskva,
FFG_11540_Neustrashimy,
CGN_1144_2_Pyotr_Velikiy,
FF_1135M_Rezky,
CV_1143_5_Admiral_Kuznetsov_2017,
CVN_74_John_C__Stennis,
CVN_71_Theodore_Roosevelt,
CVN_72_Abraham_Lincoln,
CVN_73_George_Washington,
USS_Arleigh_Burke_IIa,
LHA_Tarawa,
Type_052B,
Type_054A,
Type_052C,
}
LHA_1_Tarawa,
Type_052B_Destroyer,
Type_054A_Frigate,
Type_052C_Destroyer
]

File diff suppressed because it is too large Load Diff

1392
game/db.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,332 +0,0 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import ClassVar, Type, Iterator, TYPE_CHECKING, Optional, Any
import yaml
from dcs.helicopters import helicopter_map
from dcs.planes import plane_map
from dcs.unittype import FlyingType
from game.dcs.unittype import UnitType
from game.radio.channels import (
ChannelNamer,
RadioChannelAllocator,
CommonRadioChannelAllocator,
HueyChannelNamer,
SCR522ChannelNamer,
ViggenChannelNamer,
ViperChannelNamer,
TomcatChannelNamer,
MirageChannelNamer,
SingleRadioChannelNamer,
FarmerRadioChannelAllocator,
SCR522RadioChannelAllocator,
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
from game.utils import (
Distance,
SPEED_OF_SOUND_AT_SEA_LEVEL,
Speed,
feet,
kph,
knots,
)
if TYPE_CHECKING:
from gen.aircraft import FlightData
from gen import AirSupport, RadioFrequency, RadioRegistry
from gen.radios import Radio
@dataclass(frozen=True)
class RadioConfig:
inter_flight: Optional[Radio]
intra_flight: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
@classmethod
def from_data(cls, data: dict[str, Any]) -> RadioConfig:
return RadioConfig(
cls.make_radio(data.get("inter_flight", None)),
cls.make_radio(data.get("intra_flight", None)),
cls.make_allocator(data.get("channels", {})),
cls.make_namer(data.get("channels", {})),
)
@classmethod
def make_radio(cls, name: Optional[str]) -> Optional[Radio]:
from gen.radios import get_radio
if name is None:
return None
return get_radio(name)
@classmethod
def make_allocator(cls, data: dict[str, Any]) -> Optional[RadioChannelAllocator]:
try:
alloc_type = data["type"]
except KeyError:
return None
allocator_type: Type[RadioChannelAllocator] = {
"SCR-522": SCR522RadioChannelAllocator,
"common": CommonRadioChannelAllocator,
"farmer": FarmerRadioChannelAllocator,
"noop": NoOpChannelAllocator,
"viggen": ViggenRadioChannelAllocator,
}[alloc_type]
return allocator_type.from_cfg(data)
@classmethod
def make_namer(cls, config: dict[str, Any]) -> Type[ChannelNamer]:
return {
"SCR-522": SCR522ChannelNamer,
"default": ChannelNamer,
"huey": HueyChannelNamer,
"mirage": MirageChannelNamer,
"single": SingleRadioChannelNamer,
"tomcat": TomcatChannelNamer,
"viggen": ViggenChannelNamer,
"viper": ViperChannelNamer,
}[config.get("namer", "default")]
@dataclass(frozen=True)
class PatrolConfig:
altitude: Optional[Distance]
speed: Optional[Speed]
@classmethod
def from_data(cls, data: dict[str, Any]) -> PatrolConfig:
altitude = data.get("altitude", None)
speed = data.get("speed", None)
return PatrolConfig(
feet(altitude) if altitude is not None else None,
knots(speed) if speed is not None else None,
)
# TODO: Split into PlaneType and HelicopterType?
@dataclass(frozen=True)
class AircraftType(UnitType[Type[FlyingType]]):
carrier_capable: bool
lha_capable: bool
always_keeps_gun: bool
# If true, the aircraft does not use the guns as the last resort weapons, but as a main weapon.
# It'll RTB when it doesn't have gun ammo left.
gunfighter: bool
max_group_size: int
patrol_altitude: Optional[Distance]
patrol_speed: Optional[Speed]
intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
_by_name: ClassVar[dict[str, AircraftType]] = {}
_by_unit_type: ClassVar[dict[Type[FlyingType], list[AircraftType]]] = defaultdict(
list
)
_loaded: ClassVar[bool] = False
def __str__(self) -> str:
return self.name
@property
def dcs_id(self) -> str:
return self.dcs_unit_type.id
@property
def flyable(self) -> bool:
return self.dcs_unit_type.flyable
@cached_property
def max_speed(self) -> Speed:
return kph(self.dcs_unit_type.max_speed)
@property
def preferred_patrol_altitude(self) -> Distance:
if self.patrol_altitude is not None:
return self.patrol_altitude
else:
# Estimate based on max speed.
# Aircaft with max speed 600 kph will prefer patrol at 10 000 ft
# Aircraft with max speed 2800 kph will prefer pratrol at 33 000 ft
altitude_for_lowest_speed = feet(10 * 1000)
altitude_for_highest_speed = feet(33 * 1000)
lowest_speed = kph(600)
highest_speed = kph(2800)
factor = (self.max_speed - lowest_speed).kph / (
highest_speed - lowest_speed
).kph
altitude = (
altitude_for_lowest_speed
+ (altitude_for_highest_speed - altitude_for_lowest_speed) * factor
)
logging.debug(
f"Preferred patrol altitude for {self.dcs_unit_type.id}: {altitude.feet}"
)
rounded_altitude = feet(round(1000 * round(altitude.feet / 1000)))
return max(
altitude_for_lowest_speed,
min(altitude_for_highest_speed, rounded_altitude),
)
def preferred_patrol_speed(self, altitude: Distance) -> Speed:
"""Preferred true airspeed when patrolling"""
if self.patrol_speed is not None:
return self.patrol_speed
else:
# Estimate based on max speed.
max_speed = self.max_speed
if max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.6:
# Fast airplanes, should manage pretty high patrol speed
return (
Speed.from_mach(0.85, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.7, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 1.2:
# Medium-fast like F/A-18C
return (
Speed.from_mach(0.8, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.65, altitude)
)
elif max_speed > SPEED_OF_SOUND_AT_SEA_LEVEL * 0.7:
# Semi-fast like airliners or similar
return (
Speed.from_mach(0.5, altitude)
if altitude.feet > 20000
else Speed.from_mach(0.4, altitude)
)
else:
# Slow like warbirds or helicopters
# Use whichever is slowest - mach 0.35 or 70% of max speed
logging.debug(f"{self.name} max_speed * 0.7 is {max_speed * 0.7}")
return min(Speed.from_mach(0.35, altitude), max_speed * 0.7)
def alloc_flight_radio(self, radio_registry: RadioRegistry) -> RadioFrequency:
from gen.radios import ChannelInUseError, kHz
if self.intra_flight_radio is not None:
return radio_registry.alloc_for_radio(self.intra_flight_radio)
# The default radio frequency is set in megahertz. For some aircraft, it is a
# floating point value. For all current aircraft, adjusting to kilohertz will be
# sufficient to convert to an integer.
in_khz = float(self.dcs_unit_type.radio_frequency) * 1000
if not in_khz.is_integer():
logging.warning(
f"Found unexpected sub-kHz default radio for {self}: {in_khz} kHz. "
"Truncating to integer. The truncated frequency may not be valid for "
"the aircraft."
)
freq = kHz(int(in_khz))
try:
radio_registry.reserve(freq)
except ChannelInUseError:
pass
return freq
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
if self.channel_allocator is not None:
self.channel_allocator.assign_channels_for_flight(flight, air_support)
def channel_name(self, radio_id: int, channel_id: int) -> str:
return self.channel_namer.channel_name(radio_id, channel_id)
def __setstate__(self, state: dict[str, Any]) -> None:
# Update any existing models with new data on load.
updated = AircraftType.named(state["name"])
state.update(updated.__dict__)
self.__dict__.update(state)
@classmethod
def register(cls, aircraft_type: AircraftType) -> None:
cls._by_name[aircraft_type.name] = aircraft_type
cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type)
@classmethod
def named(cls, name: str) -> AircraftType:
if not cls._loaded:
cls._load_all()
return cls._by_name[name]
@classmethod
def for_dcs_type(cls, dcs_unit_type: Type[FlyingType]) -> Iterator[AircraftType]:
if not cls._loaded:
cls._load_all()
yield from cls._by_unit_type[dcs_unit_type]
@staticmethod
def _each_unit_type() -> Iterator[Type[FlyingType]]:
yield from helicopter_map.values()
yield from plane_map.values()
@classmethod
def _load_all(cls) -> None:
for unit_type in cls._each_unit_type():
for data in cls._each_variant_of(unit_type):
cls.register(data)
cls._loaded = True
@classmethod
def _each_variant_of(cls, aircraft: Type[FlyingType]) -> Iterator[AircraftType]:
data_path = Path("resources/units/aircraft") / f"{aircraft.id}.yaml"
if not data_path.exists():
logging.warning(f"No data for {aircraft.id}; it will not be available")
return
with data_path.open() as data_file:
data = yaml.safe_load(data_file)
try:
price = data["price"]
except KeyError as ex:
raise KeyError(f"Missing required price field: {data_path}") from ex
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
try:
introduction = data["introduced"]
if introduction is None:
introduction = "N/A"
except KeyError:
introduction = "No data."
for variant in data.get("variants", [aircraft.id]):
yield AircraftType(
dcs_unit_type=aircraft,
name=variant,
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
role=data.get("role", "No data."),
price=price,
carrier_capable=data.get("carrier_capable", False),
lha_capable=data.get("lha_capable", False),
always_keeps_gun=data.get("always_keeps_gun", False),
gunfighter=data.get("gunfighter", False),
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer,
)

View File

@@ -1,100 +0,0 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Type, Optional, ClassVar, Iterator
import yaml
from dcs.unittype import VehicleType
from dcs.vehicles import vehicle_map
from game.data.groundunitclass import GroundUnitClass
from game.dcs.unittype import UnitType
@dataclass(frozen=True)
class GroundUnitType(UnitType[Type[VehicleType]]):
unit_class: Optional[GroundUnitClass]
spawn_weight: int
_by_name: ClassVar[dict[str, GroundUnitType]] = {}
_by_unit_type: ClassVar[
dict[Type[VehicleType], list[GroundUnitType]]
] = defaultdict(list)
_loaded: ClassVar[bool] = False
def __str__(self) -> str:
return self.name
@property
def dcs_id(self) -> str:
return self.dcs_unit_type.id
@classmethod
def register(cls, aircraft_type: GroundUnitType) -> None:
cls._by_name[aircraft_type.name] = aircraft_type
cls._by_unit_type[aircraft_type.dcs_unit_type].append(aircraft_type)
@classmethod
def named(cls, name: str) -> GroundUnitType:
if not cls._loaded:
cls._load_all()
return cls._by_name[name]
@classmethod
def for_dcs_type(cls, dcs_unit_type: Type[VehicleType]) -> Iterator[GroundUnitType]:
if not cls._loaded:
cls._load_all()
yield from cls._by_unit_type[dcs_unit_type]
@staticmethod
def _each_unit_type() -> Iterator[Type[VehicleType]]:
yield from vehicle_map.values()
@classmethod
def _load_all(cls) -> None:
for unit_type in cls._each_unit_type():
for data in cls._each_variant_of(unit_type):
cls.register(data)
cls._loaded = True
@classmethod
def _each_variant_of(cls, vehicle: Type[VehicleType]) -> Iterator[GroundUnitType]:
data_path = Path("resources/units/ground_units") / f"{vehicle.id}.yaml"
if not data_path.exists():
logging.warning(f"No data for {vehicle.id}; it will not be available")
return
with data_path.open() as data_file:
data = yaml.safe_load(data_file)
try:
introduction = data["introduced"]
if introduction is None:
introduction = "N/A"
except KeyError:
introduction = "No data."
class_name = data.get("class")
unit_class: Optional[GroundUnitClass] = None
if class_name is not None:
unit_class = GroundUnitClass(class_name)
for variant in data.get("variants", [vehicle.id]):
yield GroundUnitType(
dcs_unit_type=vehicle,
unit_class=unit_class,
spawn_weight=data.get("spawn_weight", 0),
name=variant,
description=data.get(
"description",
f"No data. <a href=\"https://google.com/search?q=DCS+{variant.replace(' ', '+')}\"><span style=\"color:#FFFFFF\">Google {variant}</span></a>",
),
year_introduced=introduction,
country_of_origin=data.get("origin", "No data."),
manufacturer=data.get("manufacturer", "No data."),
role=data.get("role", "No data."),
price=data.get("price", 1),
)

View File

@@ -1,26 +0,0 @@
from dataclasses import dataclass
from functools import cached_property
from typing import TypeVar, Generic, Type
from dcs.unittype import UnitType as DcsUnitType
DcsUnitTypeT = TypeVar("DcsUnitTypeT", bound=Type[DcsUnitType])
@dataclass(frozen=True)
class UnitType(Generic[DcsUnitTypeT]):
dcs_unit_type: DcsUnitTypeT
name: str
description: str
year_introduced: str
country_of_origin: str
manufacturer: str
role: str
price: int
def __str__(self) -> str:
return self.name
@cached_property
def eplrs_capable(self) -> bool:
return getattr(self.dcs_unit_type, "eplrs", False)

View File

@@ -1,410 +1,201 @@
from __future__ import annotations
import itertools
import json
import logging
import os
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Dict,
Iterator,
List,
TYPE_CHECKING,
Union,
)
import typing
from game import db
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater import Airfield, ControlPoint
from game.transfers import CargoShip
from game.unitmap import (
AirliftUnits,
Building,
ConvoyUnit,
FrontLineUnit,
GroundObjectUnit,
UnitMap,
FlyingUnit,
)
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game import Game
DEBRIEFING_LOG_EXTENSION = "log"
class DebriefingDeadUnitInfo:
country_id = -1
player_unit = False
type = None
@dataclass(frozen=True)
class AirLosses:
player: List[FlyingUnit]
enemy: List[FlyingUnit]
@property
def losses(self) -> Iterator[FlyingUnit]:
return itertools.chain(self.player, self.enemy)
def by_type(self, player: bool) -> Dict[AircraftType, int]:
losses_by_type: Dict[AircraftType, int] = defaultdict(int)
losses = self.player if player else self.enemy
for loss in losses:
losses_by_type[loss.flight.unit_type] += 1
return losses_by_type
def surviving_flight_members(self, flight: Flight) -> int:
losses = 0
for loss in self.losses:
if loss.flight == flight:
losses += 1
return flight.count - losses
@dataclass
class GroundLosses:
player_front_line: List[FrontLineUnit] = field(default_factory=list)
enemy_front_line: List[FrontLineUnit] = field(default_factory=list)
player_convoy: List[ConvoyUnit] = field(default_factory=list)
enemy_convoy: List[ConvoyUnit] = field(default_factory=list)
player_cargo_ships: List[CargoShip] = field(default_factory=list)
enemy_cargo_ships: List[CargoShip] = field(default_factory=list)
player_airlifts: List[AirliftUnits] = field(default_factory=list)
enemy_airlifts: List[AirliftUnits] = field(default_factory=list)
player_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
enemy_ground_objects: List[GroundObjectUnit[Any]] = field(default_factory=list)
player_buildings: List[Building] = field(default_factory=list)
enemy_buildings: List[Building] = field(default_factory=list)
player_airfields: List[Airfield] = field(default_factory=list)
enemy_airfields: List[Airfield] = field(default_factory=list)
@dataclass(frozen=True)
class BaseCaptureEvent:
control_point: ControlPoint
captured_by_player: bool
@dataclass(frozen=True)
class StateData:
#: True if the mission ended. If False, the mission exited abnormally.
mission_ended: bool
#: Names of aircraft units that were killed during the mission.
killed_aircraft: List[str]
#: Names of vehicle (and ship) units that were killed during the mission.
killed_ground_units: List[str]
#: List of descriptions of destroyed statics. Format of each element is a mapping of
#: the coordinate type ("x", "y", "z", "type", "orientation") to the value.
destroyed_statics: List[dict[str, Union[float, str]]]
#: Mangled names of bases that were captured during the mission.
base_capture_events: List[str]
@classmethod
def from_json(cls, data: Dict[str, Any]) -> StateData:
return cls(
mission_ended=data["mission_ended"],
killed_aircraft=data["killed_aircrafts"],
# Airfields emit a new "dead" event every time a bomb is dropped on
# them when they've already dead. Dedup.
#
# Also normalize dead map objects (which are ints) to strings. The unit map
# only stores strings.
killed_ground_units=list({str(u) for u in data["killed_ground_units"]}),
destroyed_statics=data["destroyed_objects_positions"],
base_capture_events=data["base_capture_events"],
)
def __init__(self, country_id, player_unit , type):
self.country_id = country_id
self.player_unit = player_unit
self.type = type
def __repr__(self):
return str(self.country_id) + " " + str(self.player_unit) + " " + str(self.type)
class Debriefing:
def __init__(
self, state_data: Dict[str, Any], game: Game, unit_map: UnitMap
) -> None:
self.state_data = StateData.from_json(state_data)
self.game = game
self.unit_map = unit_map
def __init__(self, state_data, game):
self.base_capture_events = state_data["base_capture_events"]
self.killed_aircrafts = state_data["killed_aircrafts"]
self.killed_ground_units = state_data["killed_ground_units"]
self.weapons_fired = state_data["weapons_fired"]
self.mission_ended = state_data["mission_ended"]
self.destroyed_units = state_data["destroyed_objects_positions"]
self.__destroyed_units = []
logging.info("--------------------------------")
logging.info("Starting Debriefing preprocessing")
logging.info("--------------------------------")
logging.info(self.base_capture_events)
logging.info(self.killed_aircrafts)
logging.info(self.killed_ground_units)
logging.info(self.weapons_fired)
logging.info(self.mission_ended)
logging.info(self.destroyed_units)
logging.info("--------------------------------")
self.player_country = game.player_country
self.enemy_country = game.enemy_country
self.player_country_id = db.country_id_from_name(game.player_country)
self.enemy_country_id = db.country_id_from_name(game.enemy_country)
self.air_losses = self.dead_aircraft()
self.ground_losses = self.dead_ground_units()
self.base_captures = self.base_capture_events()
@property
def front_line_losses(self) -> Iterator[FrontLineUnit]:
yield from self.ground_losses.player_front_line
yield from self.ground_losses.enemy_front_line
@property
def convoy_losses(self) -> Iterator[ConvoyUnit]:
yield from self.ground_losses.player_convoy
yield from self.ground_losses.enemy_convoy
@property
def cargo_ship_losses(self) -> Iterator[CargoShip]:
yield from self.ground_losses.player_cargo_ships
yield from self.ground_losses.enemy_cargo_ships
@property
def airlift_losses(self) -> Iterator[AirliftUnits]:
yield from self.ground_losses.player_airlifts
yield from self.ground_losses.enemy_airlifts
@property
def ground_object_losses(self) -> Iterator[GroundObjectUnit[Any]]:
yield from self.ground_losses.player_ground_objects
yield from self.ground_losses.enemy_ground_objects
@property
def building_losses(self) -> Iterator[Building]:
yield from self.ground_losses.player_buildings
yield from self.ground_losses.enemy_buildings
@property
def damaged_runways(self) -> Iterator[Airfield]:
yield from self.ground_losses.player_airfields
yield from self.ground_losses.enemy_airfields
def casualty_count(self, control_point: ControlPoint) -> int:
return len([x for x in self.front_line_losses if x.origin == control_point])
def front_line_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
if player:
losses = self.ground_losses.player_front_line
else:
losses = self.ground_losses.enemy_front_line
for loss in losses:
losses_by_type[loss.unit_type] += 1
return losses_by_type
def convoy_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
if player:
losses = self.ground_losses.player_convoy
else:
losses = self.ground_losses.enemy_convoy
for loss in losses:
losses_by_type[loss.unit_type] += 1
return losses_by_type
def cargo_ship_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
if player:
ships = self.ground_losses.player_cargo_ships
else:
ships = self.ground_losses.enemy_cargo_ships
for ship in ships:
for unit_type, count in ship.units.items():
losses_by_type[unit_type] += count
return losses_by_type
def airlift_losses_by_type(self, player: bool) -> dict[GroundUnitType, int]:
losses_by_type: dict[GroundUnitType, int] = defaultdict(int)
if player:
losses = self.ground_losses.player_airlifts
else:
losses = self.ground_losses.enemy_airlifts
for loss in losses:
for unit_type in loss.cargo:
losses_by_type[unit_type] += 1
return losses_by_type
def building_losses_by_type(self, player: bool) -> Dict[str, int]:
losses_by_type: Dict[str, int] = defaultdict(int)
if player:
losses = self.ground_losses.player_buildings
else:
losses = self.ground_losses.enemy_buildings
for loss in losses:
if loss.ground_object.control_point.captured != player:
continue
losses_by_type[loss.ground_object.dcs_identifier] += 1
return losses_by_type
def dead_aircraft(self) -> AirLosses:
player_losses = []
enemy_losses = []
for unit_name in self.state_data.killed_aircraft:
aircraft = self.unit_map.flight(unit_name)
if aircraft is None:
logging.error(f"Could not find Flight matching {unit_name}")
continue
if aircraft.flight.departure.captured:
player_losses.append(aircraft)
else:
enemy_losses.append(aircraft)
return AirLosses(player_losses, enemy_losses)
def dead_ground_units(self) -> GroundLosses:
losses = GroundLosses()
for unit_name in self.state_data.killed_ground_units:
front_line_unit = self.unit_map.front_line_unit(unit_name)
if front_line_unit is not None:
if front_line_unit.origin.captured:
losses.player_front_line.append(front_line_unit)
else:
losses.enemy_front_line.append(front_line_unit)
continue
convoy_unit = self.unit_map.convoy_unit(unit_name)
if convoy_unit is not None:
if convoy_unit.convoy.player_owned:
losses.player_convoy.append(convoy_unit)
else:
losses.enemy_convoy.append(convoy_unit)
continue
cargo_ship = self.unit_map.cargo_ship(unit_name)
if cargo_ship is not None:
if cargo_ship.player_owned:
losses.player_cargo_ships.append(cargo_ship)
else:
losses.enemy_cargo_ships.append(cargo_ship)
continue
ground_object_unit = self.unit_map.ground_object_unit(unit_name)
if ground_object_unit is not None:
if ground_object_unit.ground_object.control_point.captured:
losses.player_ground_objects.append(ground_object_unit)
else:
losses.enemy_ground_objects.append(ground_object_unit)
continue
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.ground_object.control_point.captured:
losses.player_buildings.append(building)
else:
losses.enemy_buildings.append(building)
continue
airfield = self.unit_map.airfield(unit_name)
if airfield is not None:
if airfield.captured:
losses.player_airfields.append(airfield)
else:
losses.enemy_airfields.append(airfield)
continue
# Only logging as debug because we don't currently track infantry
# deaths, so we expect to see quite a few unclaimed dead ground
# units. We should start tracking those and covert this to a
# warning.
logging.debug(
f"Death of untracked ground unit {unit_name} will "
"have no effect. This may be normal behavior."
)
for unit_name in self.state_data.killed_aircraft:
airlift_unit = self.unit_map.airlift_unit(unit_name)
if airlift_unit is not None:
if airlift_unit.transfer.player:
losses.player_airlifts.append(airlift_unit)
else:
losses.enemy_airlifts.append(airlift_unit)
continue
return losses
def base_capture_events(self) -> List[BaseCaptureEvent]:
"""Keeps only the last instance of a base capture event for each base ID."""
blue_coalition_id = 2
seen = set()
captures = []
for capture in reversed(self.state_data.base_capture_events):
cp_id_str, new_owner_id_str, _name = capture.split("||")
cp_id = int(cp_id_str)
# Only the most recent capture event matters.
if cp_id in seen:
continue
seen.add(cp_id)
self.dead_aircraft = []
self.dead_units = []
self.dead_aaa_groups = []
self.dead_buildings = []
for aircraft in self.killed_aircrafts:
try:
control_point = self.game.theater.find_control_point_by_id(cp_id)
except KeyError:
# Captured base is not a part of the campaign. This happens when neutral
# bases are near the conflict. Nothing to do.
continue
country = int(aircraft.split("|")[1])
type = db.unit_type_from_name(aircraft.split("|")[4])
player_unit = (country == self.player_country_id)
aircraft = DebriefingDeadUnitInfo(country, player_unit, type)
if type is not None:
self.dead_aircraft.append(aircraft)
except Exception as e:
logging.error(e)
captured_by_player = int(new_owner_id_str) == blue_coalition_id
if control_point.is_friendly(to_player=captured_by_player):
# Base is currently friendly to the new owner. Was captured and
# recaptured in the same mission. Nothing to do.
continue
for unit in self.killed_ground_units:
try:
country = int(unit.split("|")[1])
type = db.unit_type_from_name(unit.split("|")[4])
player_unit = (country == self.player_country_id)
unit = DebriefingDeadUnitInfo(country, player_unit, type)
if type is not None:
self.dead_units.append(unit)
except Exception as e:
logging.error(e)
captures.append(BaseCaptureEvent(control_point, captured_by_player))
return captures
for unit in self.killed_ground_units:
for cp in game.theater.controlpoints:
logging.info(cp.name)
logging.info(cp.captured)
if cp.captured:
country = self.player_country_id
else:
country = self.enemy_country_id
player_unit = (country == self.player_country_id)
for i, ground_object in enumerate(cp.ground_objects):
logging.info(unit)
logging.info(ground_object.string_identifier)
if ground_object.matches_string_identifier(unit):
unit = DebriefingDeadUnitInfo(country, player_unit, ground_object.dcs_identifier)
self.dead_buildings.append(unit)
elif ground_object.dcs_identifier in ["AA", "CARRIER", "LHA"]:
for g in ground_object.groups:
for u in g.units:
if u.name == unit:
unit = DebriefingDeadUnitInfo(country, player_unit, db.unit_type_from_name(u.type))
self.dead_units.append(unit)
self.player_dead_aircraft = [a for a in self.dead_aircraft if a.country_id == self.player_country_id]
self.enemy_dead_aircraft = [a for a in self.dead_aircraft if a.country_id == self.enemy_country_id]
self.player_dead_units = [a for a in self.dead_units if a.country_id == self.player_country_id]
self.enemy_dead_units = [a for a in self.dead_units if a.country_id == self.enemy_country_id]
self.player_dead_buildings = [a for a in self.dead_buildings if a.country_id == self.player_country_id]
self.enemy_dead_buildings = [a for a in self.dead_buildings if a.country_id == self.enemy_country_id]
logging.info(self.player_dead_aircraft)
logging.info(self.enemy_dead_aircraft)
logging.info(self.player_dead_units)
logging.info(self.enemy_dead_units)
self.player_dead_aircraft_dict = {}
for a in self.player_dead_aircraft:
if a.type in self.player_dead_aircraft_dict.keys():
self.player_dead_aircraft_dict[a.type] = self.player_dead_aircraft_dict[a.type] + 1
else:
self.player_dead_aircraft_dict[a.type] = 1
self.enemy_dead_aircraft_dict = {}
for a in self.enemy_dead_aircraft:
if a.type in self.enemy_dead_aircraft_dict.keys():
self.enemy_dead_aircraft_dict[a.type] = self.enemy_dead_aircraft_dict[a.type] + 1
else:
self.enemy_dead_aircraft_dict[a.type] = 1
self.player_dead_units_dict = {}
for a in self.player_dead_units:
if a.type in self.player_dead_units_dict.keys():
self.player_dead_units_dict[a.type] = self.player_dead_units_dict[a.type] + 1
else:
self.player_dead_units_dict[a.type] = 1
self.enemy_dead_units_dict = {}
for a in self.enemy_dead_units:
if a.type in self.enemy_dead_units_dict.keys():
self.enemy_dead_units_dict[a.type] = self.enemy_dead_units_dict[a.type] + 1
else:
self.enemy_dead_units_dict[a.type] = 1
self.player_dead_buildings_dict = {}
for a in self.player_dead_buildings:
if a.type in self.player_dead_buildings_dict.keys():
self.player_dead_buildings_dict[a.type] = self.player_dead_buildings_dict[a.type] + 1
else:
self.player_dead_buildings_dict[a.type] = 1
self.enemy_dead_buildings_dict = {}
for a in self.enemy_dead_buildings:
if a.type in self.enemy_dead_buildings_dict.keys():
self.enemy_dead_buildings_dict[a.type] = self.enemy_dead_buildings_dict[a.type] + 1
else:
self.enemy_dead_buildings_dict[a.type] = 1
logging.info("--------------------------------")
logging.info("Debriefing pre process results :")
logging.info("--------------------------------")
logging.info(self.player_dead_aircraft_dict)
logging.info(self.enemy_dead_aircraft_dict)
logging.info(self.player_dead_units_dict)
logging.info(self.enemy_dead_units_dict)
logging.info(self.player_dead_buildings_dict)
logging.info(self.enemy_dead_buildings_dict)
class PollDebriefingFileThread(threading.Thread):
"""Thread class with a stop() method. The thread itself has to check
regularly for the stopped() condition."""
def __init__(
self, callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
) -> None:
super().__init__()
def __init__(self, callback: typing.Callable, game):
super(PollDebriefingFileThread, self).__init__()
self._stop_event = threading.Event()
self.callback = callback
self.game = game
self.unit_map = unit_map
def stop(self) -> None:
def stop(self):
self._stop_event.set()
def stopped(self) -> bool:
def stopped(self):
return self._stop_event.is_set()
def run(self) -> None:
def run(self):
if os.path.isfile("state.json"):
last_modified = os.path.getmtime("state.json")
else:
last_modified = 0
while not self.stopped():
try:
if (
os.path.isfile("state.json")
and os.path.getmtime("state.json") > last_modified
):
with open("state.json", "r") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game, self.unit_map)
self.callback(debriefing)
break
except json.JSONDecodeError:
logging.exception(
"Failed to decode state.json. Probably attempted read while DCS "
"was still writing the file. Will retry in 5 seconds."
)
if os.path.isfile("state.json") and os.path.getmtime("state.json") > last_modified:
with open("state.json", "r") as json_file:
json_data = json.load(json_file)
debriefing = Debriefing(json_data, self.game)
self.callback(debriefing)
break
time.sleep(5)
def wait_for_debriefing(
callback: Callable[[Debriefing], None], game: Game, unit_map: UnitMap
) -> PollDebriefingFileThread:
thread = PollDebriefingFileThread(callback, game, unit_map)
def wait_for_debriefing(callback: typing.Callable, game)->PollDebriefingFileThread:
thread = PollDebriefingFileThread(callback, game)
thread.start()
return thread

View File

@@ -1,10 +0,0 @@
from __future__ import annotations
from .event import Event
class AirWarEvent(Event):
"""Event handler for the air battle"""
def __str__(self) -> str:
return "AirWar"

View File

@@ -1,24 +1,27 @@
from __future__ import annotations
import logging
from typing import List, TYPE_CHECKING, Type
import math
from typing import Dict, List, Optional, Type, TYPE_CHECKING
from dcs.mapping import Point
from dcs.task import Task
from dcs.unittype import UnitType
from game import persistency
from game.debriefing import AirLosses, Debriefing
from game import db, persistency
from game.debriefing import Debriefing
from game.infos.information import Information
from game.operation.operation import Operation
from game.theater import ControlPoint
from gen import AirTaskingOrder
from gen.ground_forces.combat_stance import CombatStance
from ..dcs.groundunittype import GroundUnitType
from ..unitmap import UnitMap
from theater import ControlPoint
from theater.start_generator import generate_airbase_defense_group
if TYPE_CHECKING:
from ..game import Game
DIFFICULTY_LOG_BASE = 1.1
EVENT_DEPARTURE_MAX_DISTANCE = 340000
MINOR_DEFEAT_INFLUENCE = 0.1
DEFEAT_INFLUENCE = 0.3
@@ -28,23 +31,21 @@ STRONG_DEFEAT_INFLUENCE = 0.5
class Event:
silent = False
informational = False
is_awacs_enabled = False
ca_slots = 0
game = None # type: Game
location = None # type: Point
from_cp = None # type: ControlPoint
to_cp = None # type: ControlPoint
difficulty = 1 # type: int
def __init__(
self,
game: Game,
from_cp: ControlPoint,
target_cp: ControlPoint,
location: Point,
attacker_name: str,
defender_name: str,
) -> None:
operation = None # type: Operation
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):
self.game = game
self.departure_cp: Optional[ControlPoint] = None
self.from_cp = from_cp
self.to_cp = target_cp
self.location = location
@@ -53,234 +54,172 @@ class Event:
@property
def is_player_attacking(self) -> bool:
return self.attacker_name == self.game.player_faction.name
return self.attacker_name == self.game.player_name
@property
def enemy_cp(self) -> Optional[ControlPoint]:
if self.attacker_name == self.game.player_name:
return self.to_cp
else:
return self.departure_cp
@property
def tasks(self) -> List[Type[Task]]:
return []
def generate(self) -> UnitMap:
Operation.prepare(self.game)
unit_map = Operation.generate()
Operation.current_mission.save(
persistency.mission_path_for("liberation_nextturn.miz")
)
return unit_map
@property
def global_cp_available(self) -> bool:
return False
@staticmethod
def _transfer_aircraft(
ato: AirTaskingOrder, losses: AirLosses, for_player: bool
) -> None:
for package in ato.packages:
for flight in package.flights:
# No need to transfer to the same location.
if flight.departure == flight.arrival:
continue
def is_departure_available_from(self, cp: ControlPoint) -> bool:
if not cp.captured:
return False
# Don't transfer to bases that were captured. Note that if the
# airfield was back-filling transfers it may overflow. We could
# attempt to be smarter in the future by performing transfers in
# order up a graph to prevent transfers to full airports and
# send overflow off-map, but overflow is fine for now.
if flight.arrival.captured != for_player:
logging.info(
f"Not transferring {flight} because {flight.arrival} "
"was captured"
)
continue
if self.location.distance_to_point(cp.position) > EVENT_DEPARTURE_MAX_DISTANCE:
return False
transfer_count = losses.surviving_flight_members(flight)
if transfer_count < 0:
logging.error(
f"{flight} had {flight.count} aircraft but "
f"{transfer_count} losses were recorded."
)
continue
if cp.is_global and not self.global_cp_available:
return False
aircraft = flight.unit_type
available = flight.departure.base.total_units_of_type(aircraft)
if available < transfer_count:
logging.error(
f"Found killed {aircraft} from {flight.departure} but "
f"that airbase has only {available} available."
)
continue
return True
flight.departure.base.aircraft[aircraft] -= transfer_count
if aircraft not in flight.arrival.base.aircraft:
# TODO: Should use defaultdict.
flight.arrival.base.aircraft[aircraft] = 0
flight.arrival.base.aircraft[aircraft] += transfer_count
def bonus(self) -> int:
return int(math.log(self.to_cp.importance + 1, DIFFICULTY_LOG_BASE) * self.BONUS_BASE)
def complete_aircraft_transfers(self, debriefing: Debriefing) -> None:
self._transfer_aircraft(
self.game.blue_ato, debriefing.air_losses, for_player=True
)
self._transfer_aircraft(
self.game.red_ato, debriefing.air_losses, for_player=False
)
def is_successfull(self, debriefing: Debriefing) -> bool:
return self.operation.is_successfull(debriefing)
def commit_air_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.air_losses.losses:
if loss.pilot is not None and (
not loss.pilot.player
or not self.game.settings.invulnerable_player_pilots
):
loss.pilot.kill()
aircraft = loss.flight.unit_type
cp = loss.flight.departure
available = cp.base.total_units_of_type(aircraft)
if available <= 0:
logging.error(
f"Found killed {aircraft} from {cp} but that airbase has "
"none available."
)
continue
def generate(self):
self.operation.is_awacs_enabled = self.is_awacs_enabled
self.operation.ca_slots = self.ca_slots
logging.info(f"{aircraft} destroyed from {cp}")
cp.base.aircraft[aircraft] -= 1
self.operation.prepare(self.game.theater.terrain, is_quick=False)
self.operation.generate()
self.operation.current_mission.save(persistency.mission_path_for("liberation_nextturn.miz"))
self.environment_settings = self.operation.environment_settings
@staticmethod
def _commit_pilot_experience(ato: AirTaskingOrder) -> None:
for package in ato.packages:
for flight in package.flights:
for idx, pilot in enumerate(flight.roster.pilots):
if pilot is None:
logging.error(
f"Cannot award experience to pilot #{idx} of {flight} "
"because no pilot is assigned"
)
continue
pilot.record.missions_flown += 1
def commit(self, debriefing: Debriefing):
def commit_pilot_experience(self) -> None:
self._commit_pilot_experience(self.game.blue_ato)
self._commit_pilot_experience(self.game.red_ato)
logging.info("Commiting mission results")
@staticmethod
def commit_front_line_losses(debriefing: Debriefing) -> None:
for loss in debriefing.front_line_losses:
unit_type = loss.unit_type
control_point = loss.origin
available = control_point.base.total_units_of_type(unit_type)
if available <= 0:
logging.error(
f"Found killed {unit_type} from {control_point} but that "
"airbase has none available."
)
continue
logging.info(f"{unit_type} destroyed from {control_point}")
control_point.base.armor[unit_type] -= 1
@staticmethod
def commit_convoy_losses(debriefing: Debriefing) -> None:
for loss in debriefing.convoy_losses:
unit_type = loss.unit_type
convoy = loss.convoy
available = loss.convoy.units.get(unit_type, 0)
convoy_name = f"convoy from {convoy.origin} to {convoy.destination}"
if available <= 0:
logging.error(
f"Found killed {unit_type} in {convoy_name} but that convoy has "
"none available."
)
continue
logging.info(f"{unit_type} destroyed in {convoy_name}")
convoy.kill_unit(unit_type)
@staticmethod
def commit_cargo_ship_losses(debriefing: Debriefing) -> None:
for ship in debriefing.cargo_ship_losses:
logging.info(
f"All units destroyed in cargo ship from {ship.origin} to "
f"{ship.destination}."
)
ship.kill_all()
@staticmethod
def commit_airlift_losses(debriefing: Debriefing) -> None:
for loss in debriefing.airlift_losses:
transfer = loss.transfer
airlift_name = f"airlift from {transfer.origin} to {transfer.destination}"
for unit_type in loss.cargo:
try:
transfer.kill_unit(unit_type)
logging.info(f"{unit_type} destroyed in {airlift_name}")
except KeyError:
logging.exception(
f"Found killed {unit_type} in {airlift_name} but that airlift "
"has none available."
)
@staticmethod
def commit_ground_object_losses(debriefing: Debriefing) -> None:
for loss in debriefing.ground_object_losses:
# TODO: This should be stored in the TGO, not in the pydcs Group.
if not hasattr(loss.group, "units_losts"):
loss.group.units_losts = [] # type: ignore
loss.group.units.remove(loss.unit)
loss.group.units_losts.append(loss.unit) # type: ignore
def commit_building_losses(self, debriefing: Debriefing) -> None:
for loss in debriefing.building_losses:
loss.ground_object.kill()
self.game.informations.append(
Information(
"Building destroyed",
f"{loss.ground_object.dcs_identifier} has been destroyed at "
f"location {loss.ground_object.obj_name}",
self.game.turn,
)
)
@staticmethod
def commit_damaged_runways(debriefing: Debriefing) -> None:
for damaged_runway in debriefing.damaged_runways:
damaged_runway.damage_runway()
def commit_captures(self, debriefing: Debriefing) -> None:
for captured in debriefing.base_captures:
# ------------------------------
# Destroyed aircrafts
cp_map = {cp.id: cp for cp in self.game.theater.controlpoints}
for destroyed_aircraft in debriefing.killed_aircrafts:
try:
if captured.captured_by_player:
info = Information(
f"{captured.control_point} captured!",
f"We took control of {captured.control_point}.",
self.game.turn,
)
else:
info = Information(
f"{captured.control_point} lost!",
f"The enemy took control of {captured.control_point}.",
self.game.turn,
)
cpid = int(destroyed_aircraft.split("|")[3])
type = db.unit_type_from_name(destroyed_aircraft.split("|")[4])
if cpid in cp_map.keys():
cp = cp_map[cpid]
if type in cp.base.aircraft.keys():
logging.info("Aircraft destroyed : " + str(type))
cp.base.aircraft[type] = max(0, cp.base.aircraft[type]-1)
except Exception as e:
print(e)
self.game.informations.append(info)
captured.control_point.capture(self.game, captured.captured_by_player)
logging.info(f"Will run redeploy for {captured.control_point}")
self.redeploy_units(captured.control_point)
except Exception:
logging.exception(f"Could not process base capture {captured}")
# ------------------------------
# Destroyed ground units
killed_unit_count_by_cp = {cp.id: 0 for cp in self.game.theater.controlpoints}
cp_map = {cp.id: cp for cp in self.game.theater.controlpoints}
for killed_ground_unit in debriefing.killed_ground_units:
try:
cpid = int(killed_ground_unit.split("|")[3])
type = db.unit_type_from_name(killed_ground_unit.split("|")[4])
if cpid in cp_map.keys():
killed_unit_count_by_cp[cpid] = killed_unit_count_by_cp[cpid] + 1
cp = cp_map[cpid]
if type in cp.base.armor.keys():
logging.info("Ground unit destroyed : " + str(type))
cp.base.armor[type] = max(0, cp.base.armor[type] - 1)
except Exception as e:
print(e)
def commit(self, debriefing: Debriefing) -> None:
logging.info("Committing mission results")
# ------------------------------
# Static ground objects
for destroyed_ground_unit_name in debriefing.killed_ground_units:
for cp in self.game.theater.controlpoints:
if not cp.ground_objects:
continue
self.commit_air_losses(debriefing)
self.commit_pilot_experience()
self.commit_front_line_losses(debriefing)
self.commit_convoy_losses(debriefing)
self.commit_airlift_losses(debriefing)
self.commit_ground_object_losses(debriefing)
self.commit_building_losses(debriefing)
self.commit_damaged_runways(debriefing)
self.commit_captures(debriefing)
self.complete_aircraft_transfers(debriefing)
# -- Static ground objects
for i, ground_object in enumerate(cp.ground_objects):
if ground_object.is_dead:
continue
if ground_object.matches_string_identifier(destroyed_ground_unit_name):
logging.info("cp {} killing ground object {}".format(cp, ground_object.string_identifier))
cp.ground_objects[i].is_dead = True
info = Information("Building destroyed",
ground_object.dcs_identifier + " has been destroyed at location " + ground_object.obj_name,
self.game.turn)
self.game.informations.append(info)
# -- AA Site groups
destroyed_units = 0
info = Information("Units destroyed at " + ground_object.obj_name,
"",
self.game.turn)
for i, ground_object in enumerate(cp.ground_objects):
if ground_object.dcs_identifier in ["AA", "CARRIER", "LHA"]:
for g in ground_object.groups:
if not hasattr(g, "units_losts"):
g.units_losts = []
for u in g.units:
if u.name == destroyed_ground_unit_name:
g.units.remove(u)
g.units_losts.append(u)
destroyed_units = destroyed_units + 1
info.text = u.type
ucount = sum([len(g.units) for g in ground_object.groups])
if ucount == 0:
ground_object.is_dead = True
if destroyed_units > 0:
self.game.informations.append(info)
# ------------------------------
# Captured bases
#if self.game.player_country in db.BLUEFOR_FACTIONS:
coalition = 2 # Value in DCS mission event for BLUE
#else:
# coalition = 1 # Value in DCS mission event for RED
for captured in debriefing.base_capture_events:
try:
id = int(captured.split("||")[0])
new_owner_coalition = int(captured.split("||")[1])
captured_cps = []
for cp in self.game.theater.controlpoints:
if cp.id == id:
if cp.captured and new_owner_coalition != coalition:
for_player = False
info = Information(cp.name + " lost !", "The ennemy took control of " + cp.name + "\nShame on us !", self.game.turn)
self.game.informations.append(info)
captured_cps.append(cp)
elif not(cp.captured) and new_owner_coalition == coalition:
for_player = True
info = Information(cp.name + " captured !", "We took control of " + cp.name + "! Great job !", self.game.turn)
self.game.informations.append(info)
captured_cps.append(cp)
else:
continue
cp.capture(self.game, for_player)
for cp in captured_cps:
logging.info("Will run redeploy for " + cp.name)
self.redeploy_units(cp)
except Exception as e:
print(e)
# Destroyed units carcass
# -------------------------
for destroyed_unit in debriefing.state_data.destroyed_statics:
for destroyed_unit in debriefing.destroyed_units:
self.game.add_destroyed_units(destroyed_unit)
# -----------------------------------
@@ -288,192 +227,153 @@ class Event:
for cp in self.game.theater.player_points():
enemy_cps = [e for e in cp.connected_points if not e.captured]
for enemy_cp in enemy_cps:
print(
"Compute frontline progression for : "
+ cp.name
+ " to "
+ enemy_cp.name
)
print("Compute frontline progression for : " + cp.name + " to " + enemy_cp.name)
delta = 0.0
player_won = True
status_msg: str = ""
ally_casualties = debriefing.casualty_count(cp)
enemy_casualties = debriefing.casualty_count(enemy_cp)
ally_casualties = killed_unit_count_by_cp[cp.id]
enemy_casualties = killed_unit_count_by_cp[enemy_cp.id]
ally_units_alive = cp.base.total_armor
enemy_units_alive = enemy_cp.base.total_armor
print(f"Remaining allied units: {ally_units_alive}")
print(f"Remaining enemy units: {enemy_units_alive}")
print(f"Allied casualties {ally_casualties}")
print(f"Enemy casualties {enemy_casualties}")
print(ally_units_alive)
print(enemy_units_alive)
print(ally_casualties)
print(enemy_casualties)
ratio = (1.0 + enemy_casualties) / (1.0 + ally_casualties)
player_aggresive = cp.stances[enemy_cp.id] in [
CombatStance.AGGRESSIVE,
CombatStance.ELIMINATION,
CombatStance.BREAKTHROUGH,
]
player_aggresive = cp.stances[enemy_cp.id] in [CombatStance.AGGRESSIVE, CombatStance.ELIMINATION, CombatStance.BREAKTHROUGH]
if ally_units_alive == 0:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No allied units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces suffer a strong defeat."
elif enemy_units_alive == 0:
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"No enemy units alive at {cp.name}-{enemy_cp.name} frontline. Allied ground forces win a strong victory."
elif cp.stances[enemy_cp.id] == CombatStance.RETREAT:
player_won = False
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces are retreating along the {cp.name}-{enemy_cp.name} frontline, suffering a strong defeat."
else:
if enemy_casualties > ally_casualties:
player_won = True
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied forces break through the {cp.name}-{enemy_cp.name} frontline, winning a strong victory"
else:
if ratio > 3:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties massively outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a strong victory."
elif ratio < 1.5:
delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Enemy casualties minorly outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces win a minor victory."
else:
delta = DEFEAT_INFLUENCE
status_msg = f"Enemy casualties outnumber allied casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces claim a victory."
elif ally_casualties > enemy_casualties:
if (
ally_units_alive > 2 * enemy_units_alive
and player_aggresive
):
if ally_units_alive > 2*enemy_units_alive and player_aggresive:
# Even with casualties if the enemy is overwhelmed, they are going to lose ground
player_won = True
delta = MINOR_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a minor victory."
elif (
ally_units_alive > 3 * enemy_units_alive
and player_aggresive
):
elif ally_units_alive > 3*enemy_units_alive and player_aggresive:
player_won = True
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Despite suffering losses, allied forces still heavily outnumber enemy forces along the {cp.name}-{enemy_cp.name} frontline. Due to allied force's aggressive posture, allied forces claim a major victory."
else:
# But if the enemy is not outnumbered, we lose
# But is the enemy is not outnumbered, we lose
player_won = False
if cp.stances[enemy_cp.id] == CombatStance.BREAKTHROUGH:
delta = STRONG_DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces have overextended themselves, suffering a major defeat."
else:
delta = DEFEAT_INFLUENCE
status_msg = f"Allied casualties outnumber enemy casualties along the {cp.name}-{enemy_cp.name} frontline. Allied forces suffer a defeat."
delta = STRONG_DEFEAT_INFLUENCE
# No progress with defensive strategies
if player_won and cp.stances[enemy_cp.id] in [
CombatStance.DEFENSIVE,
CombatStance.AMBUSH,
]:
print(
f"Allied forces have adopted a defensive stance along the {cp.name}-{enemy_cp.name} "
f"frontline, making only limited progress."
)
if player_won and cp.stances[enemy_cp.id] in [CombatStance.DEFENSIVE, CombatStance.AMBUSH]:
print("Defensive stance, progress is limited")
delta = MINOR_DEFEAT_INFLUENCE
# Handle the case where there are no casualties at all on either side but both sides still have units
if delta == 0.0:
print(status_msg)
info = Information(
"Frontline Report",
f"Our ground forces from {cp.name} reached a stalemate with enemy forces from {enemy_cp.name}.",
self.game.turn,
)
if player_won:
print(cp.name + " won ! factor > " + str(delta))
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
info = Information("Frontline Report",
"Our ground forces from " + cp.name + " are making progress toward " + enemy_cp.name,
self.game.turn)
self.game.informations.append(info)
else:
if player_won:
print(status_msg)
cp.base.affect_strength(delta)
enemy_cp.base.affect_strength(-delta)
info = Information(
"Frontline Report",
f"Our ground forces from {cp.name} are making progress toward {enemy_cp.name}. {status_msg}",
self.game.turn,
)
self.game.informations.append(info)
else:
print(status_msg)
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information(
"Frontline Report",
f"Our ground forces from {cp.name} are losing ground against the enemy forces from "
f"{enemy_cp.name}. {status_msg}",
self.game.turn,
)
self.game.informations.append(info)
print(cp.name + " lost ! factor > " + str(delta))
enemy_cp.base.affect_strength(delta)
cp.base.affect_strength(-delta)
info = Information("Frontline Report",
"Our ground forces from " + cp.name + " are losing ground against the enemy forces from " + enemy_cp.name,
self.game.turn)
self.game.informations.append(info)
def redeploy_units(self, cp: ControlPoint) -> None:
""" "
def skip(self):
pass
def redeploy_units(self, cp):
""""
Auto redeploy units to newly captured base
"""
ally_connected_cps = [
ocp for ocp in cp.connected_points if cp.captured == ocp.captured
]
enemy_connected_cps = [
ocp for ocp in cp.connected_points if cp.captured != ocp.captured
]
ally_connected_cps = [ocp for ocp in cp.connected_points if cp.captured == ocp.captured]
enemy_connected_cps = [ocp for ocp in cp.connected_points if cp.captured != ocp.captured]
# If the newly captured cp does not have enemy connected cp,
# then it is not necessary to redeploy frontline units there.
if len(enemy_connected_cps) == 0:
return
# From each ally cp, send reinforcements
for ally_cp in ally_connected_cps:
self.redeploy_between(cp, ally_cp)
def redeploy_between(self, destination: ControlPoint, source: ControlPoint) -> None:
total_units_redeployed = 0
moved_units = {}
if source.has_active_frontline or not destination.captured:
# If there are still active front lines to defend at the
# transferring CP we should not transfer all units.
#
# Opfor also does not transfer all of their units.
# TODO: Balance the CPs rather than moving half from everywhere.
move_factor = 0.5
else:
# Otherwise we can move everything.
move_factor = 1
# From each ally cp, send reinforcements
for ally_cp in ally_connected_cps:
total_units_redeployed = 0
own_enemy_cp = [ocp for ocp in ally_cp.connected_points if ally_cp.captured != ocp.captured]
for frontline_unit, count in source.base.armor.items():
moved_units[frontline_unit] = int(count * move_factor)
total_units_redeployed = total_units_redeployed + int(count * move_factor)
moved_units = {}
destination.base.commission_units(moved_units)
source.base.commit_losses(moved_units)
# If the connected base, does not have any more enemy cp connected.
# Or if it is not the opponent redeploying forces there (enemy AI will never redeploy all their forces at once)
if len(own_enemy_cp) > 0 or not cp.captured:
for frontline_unit, count in ally_cp.base.armor.items():
moved_units[frontline_unit] = int(count/2)
total_units_redeployed = total_units_redeployed + int(count/2)
else: # So if the old base, does not have any more enemy cp connected, or if it is an enemy base
for frontline_unit, count in ally_cp.base.armor.items():
moved_units[frontline_unit] = count
total_units_redeployed = total_units_redeployed + count
# Also transfer pending deliveries.
for unit_type, count in source.pending_unit_deliveries.units.items():
if not isinstance(unit_type, GroundUnitType):
continue
if count <= 0:
# Don't transfer *sales*...
continue
move_count = int(count * move_factor)
source.pending_unit_deliveries.sell({unit_type: move_count})
destination.pending_unit_deliveries.order({unit_type: move_count})
total_units_redeployed += move_count
cp.base.commision_units(moved_units)
ally_cp.base.commit_losses(moved_units)
if total_units_redeployed > 0:
text = (
f"{total_units_redeployed} units have been redeployed from "
f"{source.name} to {destination.name}"
)
info = Information("Units redeployed", text, self.game.turn)
if total_units_redeployed > 0:
info = Information("Units redeployed", "", self.game.turn)
info.text = str(total_units_redeployed) + " units have been redeployed from " + ally_cp.name + " to " + cp.name
self.game.informations.append(info)
logging.info(info.text)
class UnitsDeliveryEvent(Event):
informational = True
def __init__(self, attacker_name: str, defender_name: str, from_cp: ControlPoint, to_cp: ControlPoint, game):
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)
self.units: Dict[UnitType, int] = {}
def __str__(self):
return "Pending delivery to {}".format(self.to_cp)
def deliver(self, units: Dict[UnitType, int]):
for k, v in units.items():
self.units[k] = self.units.get(k, 0) + v
def skip(self):
for k, v in self.units.items():
info = Information("Ally Reinforcement", str(k.id) + " x " + str(v) + " at " + self.to_cp.name, self.game.turn)
self.game.informations.append(info)
logging.info(text)
self.to_cp.base.commision_units(self.units)

View File

@@ -1,12 +1,49 @@
from typing import List, Type
from dcs.task import CAP, CAS, Task
from game import db
from game.operation.frontlineattack import FrontlineAttackOperation
from .event import Event
from ..debriefing import Debriefing
class FrontlineAttackEvent(Event):
"""
An event centered on a FrontLine Conflict.
Currently the same as its parent, but here for legacy compatibility as well as to allow for
future unique Event handling
"""
def __str__(self) -> str:
@property
def tasks(self) -> List[Type[Task]]:
if self.is_player_attacking:
return [CAS, CAP]
else:
return [CAP]
@property
def global_cp_available(self) -> bool:
return True
def __str__(self):
return "Frontline attack"
def is_successfull(self, debriefing: Debriefing):
attackers_success = True
if self.from_cp.captured:
return attackers_success
else:
return not attackers_success
def commit(self, debriefing: Debriefing):
super(FrontlineAttackEvent, self).commit(debriefing)
def skip(self):
if self.to_cp.captured:
self.to_cp.base.affect_strength(-0.1)
def player_attacking(self, flights: db.TaskForceDict):
assert self.departure_cp is not None
op = FrontlineAttackOperation(game=self.game,
attacker_name=self.attacker_name,
defender_name=self.defender_name,
from_cp=self.from_cp,
departure_cp=self.departure_cp,
to_cp=self.to_cp)
self.operation = op

View File

@@ -1,39 +1,22 @@
from __future__ import annotations
import itertools
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Type, List, Any, Iterator, TYPE_CHECKING
from typing import Optional, Dict, Type, List, Any, cast
import dcs
from dcs.countries import country_dict
from dcs.unittype import ShipType, UnitType
from dcs.planes import plane_map
from dcs.unittype import FlyingType, ShipType, VehicleType, UnitType
from dcs.vehicles import Armor, Unarmed, Infantry, Artillery, AirDefence
from game.data.building_data import (
WW2_ALLIES_BUILDINGS,
DEFAULT_AVAILABLE_BUILDINGS,
WW2_GERMANY_BUILDINGS,
WW2_FREE,
)
from game.data.doctrine import (
Doctrine,
MODERN_DOCTRINE,
COLDWAR_DOCTRINE,
WWII_DOCTRINE,
)
from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
if TYPE_CHECKING:
from game.theater.start_generator import ModSettings
from game.data.building_data import WW2_ALLIES_BUILDINGS, DEFAULT_AVAILABLE_BUILDINGS, WW2_GERMANY_BUILDINGS
from game.data.doctrine import Doctrine, MODERN_DOCTRINE, COLDWAR_DOCTRINE, WWII_DOCTRINE
from pydcs_extensions.mod_units import MODDED_VEHICLES, MODDED_AIRPLANES
@dataclass
class Faction:
#: List of locales to use when generating random names. If not set, Faker will
#: choose the default locale.
locales: Optional[List[str]]
# Country used by this faction
country: str = field(default="")
@@ -48,46 +31,43 @@ class Faction:
description: str = field(default="")
# Available aircraft
aircrafts: List[AircraftType] = field(default_factory=list)
aircrafts: List[UnitType] = field(default_factory=list)
# Available awacs aircraft
awacs: List[AircraftType] = field(default_factory=list)
awacs: List[UnitType] = field(default_factory=list)
# Available tanker aircraft
tankers: List[AircraftType] = field(default_factory=list)
tankers: List[UnitType] = field(default_factory=list)
# Available frontline units
frontline_units: List[GroundUnitType] = field(default_factory=list)
frontline_units: List[VehicleType] = field(default_factory=list)
# Available artillery units
artillery_units: List[GroundUnitType] = field(default_factory=list)
artillery_units: List[VehicleType] = field(default_factory=list)
# Infantry units used
infantry_units: List[GroundUnitType] = field(default_factory=list)
infantry_units: List[VehicleType] = field(default_factory=list)
# Logistics units used
logistics_units: List[GroundUnitType] = field(default_factory=list)
logistics_units: List[VehicleType] = field(default_factory=list)
# List of units that can be deployed as SHORAD
shorads: List[str] = field(default_factory=list)
# Possible SAMS site generators for this faction
air_defenses: List[str] = field(default_factory=list)
# Possible EWR generators for this faction.
ewrs: List[str] = field(default_factory=list)
sams: List[str] = field(default_factory=list)
# Possible Missile site generators for this faction
missiles: List[str] = field(default_factory=list)
# Possible costal site generators for this faction
coastal_defenses: List[str] = field(default_factory=list)
# Required mods or asset packs
requirements: Dict[str, str] = field(default_factory=dict)
# possible aircraft carrier units
aircraft_carrier: List[Type[ShipType]] = field(default_factory=list)
aircraft_carrier: List[UnitType] = field(default_factory=list)
# possible helicopter carrier units
helicopter_carrier: List[Type[ShipType]] = field(default_factory=list)
helicopter_carrier: List[UnitType] = field(default_factory=list)
# Possible carrier names
carrier_names: List[str] = field(default_factory=list)
@@ -99,10 +79,10 @@ class Faction:
navy_generators: List[str] = field(default_factory=list)
# Available destroyers
destroyers: List[Type[ShipType]] = field(default_factory=list)
destroyers: List[str] = field(default_factory=list)
# Available cruisers
cruisers: List[Type[ShipType]] = field(default_factory=list)
cruisers: List[str] = field(default_factory=list)
# How many navy group should we try to generate per CP on startup for this faction
navy_group_count: int = field(default=1)
@@ -110,14 +90,11 @@ class Faction:
# How many missiles group should we try to generate per CP on startup for this faction
missiles_group_count: int = field(default=1)
# How many coastal group should we try to generate per CP on startup for this faction
coastal_group_count: int = field(default=1)
# Whether this faction has JTAC access
has_jtac: bool = field(default=False)
# Unit to use as JTAC for this faction
jtac_unit: Optional[AircraftType] = field(default=None)
jtac_unit: Optional[FlyingType] = field(default=None)
# doctrine
doctrine: Doctrine = field(default=MODERN_DOCTRINE)
@@ -125,35 +102,14 @@ class Faction:
# List of available buildings for this faction
building_set: List[str] = field(default_factory=list)
# List of default livery overrides
liveries_overrides: Dict[AircraftType, List[str]] = field(default_factory=dict)
#: Set to True if the faction should force the "Unrestricted satnav" option
#: for the mission. This option enables GPS for capable aircraft regardless
#: of the time period or operator. For example, the CJTF "countries" don't
#: appear to have GPS capability, so they need this.
#:
#: Note that this option cannot be set per-side. If either faction needs it,
#: both will use it.
unrestricted_satnav: bool = False
def has_access_to_unittype(self, unit_class: GroundUnitClass) -> bool:
for vehicle in itertools.chain(self.frontline_units, self.artillery_units):
if vehicle.unit_class is unit_class:
return True
return False
@classmethod
def from_json(cls: Type[Faction], json: Dict[str, Any]) -> Faction:
faction = Faction(locales=json.get("locales"))
faction = Faction()
faction.country = json.get("country", "/")
if faction.country not in [c.name for c in country_dict.values()]:
raise AssertionError(
'Faction\'s country ("{}") is not a valid DCS country ID'.format(
faction.country
)
)
raise AssertionError("Faction's country (\"{}\") is not a valid DCS country ID".format(faction.country))
faction.name = json.get("name", "")
if not faction.name:
@@ -162,55 +118,42 @@ class Faction:
faction.authors = json.get("authors", "")
faction.description = json.get("description", "")
faction.aircrafts = [AircraftType.named(n) for n in json.get("aircrafts", [])]
faction.awacs = [AircraftType.named(n) for n in json.get("awacs", [])]
faction.tankers = [AircraftType.named(n) for n in json.get("tankers", [])]
faction.aircrafts = load_all_aircraft(json.get("aircrafts", []))
faction.awacs = load_all_aircraft(json.get("awacs", []))
faction.tankers = load_all_aircraft(json.get("tankers", []))
faction.aircrafts = list(
set(faction.aircrafts + faction.awacs + faction.tankers)
)
faction.frontline_units = [
GroundUnitType.named(n) for n in json.get("frontline_units", [])
]
faction.artillery_units = [
GroundUnitType.named(n) for n in json.get("artillery_units", [])
]
faction.infantry_units = [
GroundUnitType.named(n) for n in json.get("infantry_units", [])
]
faction.logistics_units = [
GroundUnitType.named(n) for n in json.get("logistics_units", [])
]
faction.ewrs = json.get("ewrs", [])
faction.air_defenses = json.get("air_defenses", [])
# Compatibility for older factions. All air defenses now belong to a
# single group and the generator decides what belongs where.
faction.air_defenses.extend(json.get("sams", []))
faction.air_defenses.extend(json.get("shorads", []))
faction.frontline_units = load_all_vehicles(
json.get("frontline_units", []))
faction.artillery_units = load_all_vehicles(
json.get("artillery_units", []))
faction.infantry_units = load_all_vehicles(
json.get("infantry_units", []))
faction.logistics_units = load_all_vehicles(
json.get("logistics_units", []))
faction.sams = json.get("sams", [])
faction.shorads = json.get("shorads", [])
faction.missiles = json.get("missiles", [])
faction.coastal_defenses = json.get("coastal_defenses", [])
faction.requirements = json.get("requirements", {})
faction.carrier_names = json.get("carrier_names", [])
faction.helicopter_carrier_names = json.get("helicopter_carrier_names", [])
faction.helicopter_carrier_names = json.get(
"helicopter_carrier_names", [])
faction.navy_generators = json.get("navy_generators", [])
faction.aircraft_carrier = load_all_ships(json.get("aircraft_carrier", []))
faction.helicopter_carrier = load_all_ships(json.get("helicopter_carrier", []))
faction.aircraft_carrier = load_all_ships(
json.get("aircraft_carrier", []))
faction.helicopter_carrier = load_all_ships(
json.get("helicopter_carrier", []))
faction.destroyers = load_all_ships(json.get("destroyers", []))
faction.cruisers = load_all_ships(json.get("cruisers", []))
faction.has_jtac = json.get("has_jtac", False)
jtac_name = json.get("jtac_unit", None)
if jtac_name is not None:
faction.jtac_unit = AircraftType.named(jtac_name)
faction.jtac_unit = load_aircraft(jtac_name)
else:
faction.jtac_unit = None
faction.navy_group_count = int(json.get("navy_group_count", 1))
faction.missiles_group_count = int(json.get("missiles_group_count", 0))
faction.coastal_group_count = int(json.get("coastal_group_count", 0))
# Load doctrine
doctrine = json.get("doctrine", "modern")
@@ -227,8 +170,6 @@ class Faction:
building_set = json.get("building_set", "default")
if building_set == "default":
faction.building_set = DEFAULT_AVAILABLE_BUILDINGS
elif building_set == "ww2free":
faction.building_set = WW2_FREE
elif building_set == "ww2ally":
faction.building_set = WW2_ALLIES_BUILDINGS
elif building_set == "ww2germany":
@@ -236,116 +177,73 @@ class Faction:
else:
faction.building_set = DEFAULT_AVAILABLE_BUILDINGS
# Load liveries override
faction.liveries_overrides = {}
liveries_overrides = json.get("liveries_overrides", {})
for name, livery in liveries_overrides.items():
aircraft = AircraftType.named(name)
faction.liveries_overrides[aircraft] = [s.lower() for s in livery]
faction.unrestricted_satnav = json.get("unrestricted_satnav", False)
return faction
@property
def ground_units(self) -> Iterator[GroundUnitType]:
yield from self.artillery_units
yield from self.frontline_units
yield from self.logistics_units
def infantry_with_class(
self, unit_class: GroundUnitClass
) -> Iterator[GroundUnitType]:
for unit in self.infantry_units:
if unit.unit_class is unit_class:
yield unit
def apply_mod_settings(self, mod_settings: ModSettings) -> Faction:
# aircraft
if not mod_settings.a4_skyhawk:
self.remove_aircraft("A-4E-C")
if not mod_settings.hercules:
self.remove_aircraft("Hercules")
if not mod_settings.f22_raptor:
self.remove_aircraft("F-22A")
if not mod_settings.jas39_gripen:
self.remove_aircraft("JAS39Gripen")
self.remove_aircraft("JAS39Gripen_AG")
if not mod_settings.su57_felon:
self.remove_aircraft("Su-57")
# frenchpack
if not mod_settings.frenchpack:
self.remove_vehicle("AMX10RCR")
self.remove_vehicle("SEPAR")
self.remove_vehicle("ERC")
self.remove_vehicle("M120")
self.remove_vehicle("AA20")
self.remove_vehicle("TRM2000")
self.remove_vehicle("TRM2000_Citerne")
self.remove_vehicle("TRM2000_AA20")
self.remove_vehicle("TRMMISTRAL")
self.remove_vehicle("VABH")
self.remove_vehicle("VAB_RADIO")
self.remove_vehicle("VAB_50")
self.remove_vehicle("VIB_VBR")
self.remove_vehicle("VAB_HOT")
self.remove_vehicle("VAB_MORTIER")
self.remove_vehicle("VBL50")
self.remove_vehicle("VBLANF1")
self.remove_vehicle("VBL-radio")
self.remove_vehicle("VBAE")
self.remove_vehicle("VBAE_MMP")
self.remove_vehicle("AMX-30B2")
self.remove_vehicle("Tracma")
self.remove_vehicle("JTACFP")
self.remove_vehicle("SHERIDAN")
self.remove_vehicle("Leclerc_XXI")
self.remove_vehicle("Toyota_bleu")
self.remove_vehicle("Toyota_vert")
self.remove_vehicle("Toyota_desert")
self.remove_vehicle("Kamikaze")
self.remove_vehicle("AMX1375")
self.remove_vehicle("AMX1390")
self.remove_vehicle("VBCI")
self.remove_vehicle("T62")
self.remove_vehicle("T64BV")
self.remove_vehicle("T72M")
self.remove_vehicle("KORNET")
# high digit sams
if not mod_settings.high_digit_sams:
self.remove_air_defenses("SA10BGenerator")
self.remove_air_defenses("SA12Generator")
self.remove_air_defenses("SA20Generator")
self.remove_air_defenses("SA20BGenerator")
self.remove_air_defenses("SA23Generator")
self.remove_air_defenses("SA17Generator")
self.remove_air_defenses("KS19Generator")
return self
def remove_aircraft(self, name: str) -> None:
for i in self.aircrafts:
if i.dcs_unit_type.id == name:
self.aircrafts.remove(i)
def remove_air_defenses(self, name: str) -> None:
for i in self.air_defenses:
if i == name:
self.air_defenses.remove(i)
def remove_vehicle(self, name: str) -> None:
for i in self.frontline_units:
if i.dcs_unit_type.id == name:
self.frontline_units.remove(i)
def units(self) -> List[UnitType]:
return (self.infantry_units + self.aircrafts + self.awacs +
self.artillery_units + self.frontline_units +
self.tankers + self.logistics_units)
def load_ship(name: str) -> Optional[Type[ShipType]]:
if (ship := getattr(dcs.ships, name, None)) is not None:
return ship
logging.error(f"FACTION ERROR : Unable to find {name} in dcs.ships")
return None
def unit_loader(unit: str, class_repository: List[Any]) -> Optional[UnitType]:
"""
Find unit by name
:param unit: Unit name as string
:param class_repository: Repository of classes (Either a module, a class, or a list of classes)
:return: The unit as a PyDCS type
"""
if unit is None:
return None
elif unit in plane_map.keys():
return plane_map[unit]
else:
for mother_class in class_repository:
if getattr(mother_class, unit, None) is not None:
return getattr(mother_class, unit)
if type(mother_class) is list:
for m in mother_class:
if m.__name__ == unit:
return m
logging.error(f"FACTION ERROR : Unable to find {unit} in pydcs")
return None
def load_all_ships(data: list[str]) -> List[Type[ShipType]]:
def load_aircraft(name: str) -> Optional[FlyingType]:
return cast(Optional[FlyingType], unit_loader(
name, [dcs.planes, dcs.helicopters, MODDED_AIRPLANES]
))
def load_all_aircraft(data) -> List[FlyingType]:
items = []
for name in data:
item = load_aircraft(name)
if item is not None:
items.append(item)
return items
def load_vehicle(name: str) -> Optional[VehicleType]:
return cast(Optional[FlyingType], unit_loader(
name, [Infantry, Unarmed, Armor, AirDefence, Artillery, MODDED_VEHICLES]
))
def load_all_vehicles(data) -> List[VehicleType]:
items = []
for name in data:
item = load_vehicle(name)
if item is not None:
items.append(item)
return items
def load_ship(name: str) -> Optional[ShipType]:
return cast(Optional[FlyingType], unit_loader(name, [dcs.ships]))
def load_all_ships(data) -> List[ShipType]:
items = []
for name in data:
item = load_ship(name)

View File

@@ -2,53 +2,27 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Type
from typing import Dict, Type
from game import persistency
from game.factions.faction import Faction
FACTION_DIRECTORY = Path("./resources/factions/")
class FactionLoader:
def __init__(self) -> None:
self._factions: Optional[Dict[str, Faction]] = None
@property
def factions(self) -> Dict[str, Faction]:
self.initialize()
assert self._factions is not None
return self._factions
def initialize(self) -> None:
if self._factions is None:
self._factions = self.load_factions()
@staticmethod
def find_faction_files_in(path: Path) -> List[Path]:
return [f for f in path.glob("*.json") if f.is_file()]
@classmethod
def load_factions(cls: Type[FactionLoader]) -> Dict[str, Faction]:
user_faction_path = Path(persistency.base_path()) / "Liberation/Factions"
files = cls.find_faction_files_in(
FACTION_DIRECTORY
) + cls.find_faction_files_in(user_faction_path)
files = [f for f in FACTION_DIRECTORY.glob("*.json") if f.is_file()]
factions = {}
for f in files:
try:
with f.open("r", encoding="utf-8") as fdata:
data = json.load(fdata)
data = json.load(fdata, encoding="utf-8")
factions[data["name"]] = Faction.from_json(data)
logging.info("Loaded faction : " + str(f))
except Exception:
logging.exception(f"Unable to load faction : {f}")
return factions
def __getitem__(self, name: str) -> Faction:
return self.factions[name]
def __iter__(self) -> Iterator[str]:
return iter(self.factions.keys())

View File

@@ -1,46 +1,35 @@
import itertools
import logging
import math
import random
import sys
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, List, Type, Union, cast
from typing import Any, Dict, List
from dcs.action import Coalition
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.task import CAP, CAS, PinpointStrike, Task
from dcs.unittype import UnitType
from dcs.vehicles import AirDefence
from faker import Faker
from game import db
from game.db import PLAYER_BUDGET_BASE, REWARDS
from game.inventory import GlobalAircraftInventory
from game.models.game_stats import GameStats
from game.plugins import LuaPluginManager
from gen import naming
from gen.ato import AirTaskingOrder
from gen.conflictgen import Conflict
from gen.flights.ai_flight_planner import CoalitionMissionPlanner
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
from gen.ground_forces.ai_ground_planner import GroundPlanner
from theater import ConflictTheater, ControlPoint
from theater.conflicttheater import IMPORTANCE_HIGH, IMPORTANCE_LOW
from . import persistency
from .debriefing import Debriefing
from .event.event import Event
from .event.event import Event, UnitsDeliveryEvent
from .event.frontlineattack import FrontlineAttackEvent
from .factions.faction import Faction
from .income import Income
from .infos.information import Information
from .navmesh import NavMesh
from .procurement import AircraftProcurementRequest, ProcurementAi
from .profiling import logged_duration
from .settings import Settings, AutoAtoBehavior
from .squadrons import AirWing
from .theater import ConflictTheater, ControlPoint
from .theater.bullseye import Bullseye
from .theater.transitnetwork import TransitNetwork, TransitNetworkBuilder
from .threatzones import ThreatZones
from .transfers import PendingTransfers
from .unitmap import UnitMap
from .settings import Settings
from plugin import LuaPluginManager
from .weather import Conditions, TimeOfDay
COMMISION_UNIT_VARIETY = 4
@@ -73,122 +62,55 @@ ENEMY_BASE_STRENGTH_RECOVERY = 0.05
# cost of AWACS for single operation
AWACS_BUDGET_COST = 4
# Initial budget value
PLAYER_BUDGET_INITIAL = 650
# Bonus multiplier logarithm base
PLAYER_BUDGET_IMPORTANCE_LOG = 2
class TurnState(Enum):
WIN = 0
LOSS = 1
CONTINUE = 2
class Game:
def __init__(
self,
player_faction: Faction,
enemy_faction: Faction,
theater: ConflictTheater,
start_date: datetime,
settings: Settings,
player_budget: float,
enemy_budget: float,
) -> None:
def __init__(self, player_name: str, enemy_name: str,
theater: ConflictTheater, start_date: datetime,
settings: Settings):
self.settings = settings
self.events: List[Event] = []
self.theater = theater
self.player_faction = player_faction
self.player_country = player_faction.country
self.enemy_faction = enemy_faction
self.enemy_country = enemy_faction.country
# pass_turn() will be called when initialization is complete which will
# increment this to turn 0 before it reaches the player.
self.turn = -1
# NB: This is the *start* date. It is never updated.
self.player_name = player_name
self.player_country = db.FACTIONS[player_name].country
self.enemy_name = enemy_name
self.enemy_country = db.FACTIONS[enemy_name].country
self.turn = 0
self.date = date(start_date.year, start_date.month, start_date.day)
self.game_stats = GameStats()
self.notes = ""
self.ground_planners: dict[int, GroundPlanner] = {}
self.game_stats.update(self)
self.ground_planners: Dict[int, GroundPlanner] = {}
self.informations = []
self.informations.append(Information("Game Start", "-" * 40, 0))
# Culling Zones are for areas around points of interest that contain things we may not wish to cull.
self.__culling_zones: List[Point] = []
self.__destroyed_units: list[dict[str, Union[float, str]]] = []
self.__culling_points = self.compute_conflicts_position()
self.__destroyed_units: List[str] = []
self.savepath = ""
self.budget = player_budget
self.enemy_budget = enemy_budget
self.budget = PLAYER_BUDGET_INITIAL
self.current_unit_id = 0
self.current_group_id = 0
self.name_generator = naming.namegen
self.conditions = self.generate_conditions()
self.blue_transit_network = TransitNetwork()
self.red_transit_network = TransitNetwork()
self.blue_procurement_requests: List[AircraftProcurementRequest] = []
self.red_procurement_requests: List[AircraftProcurementRequest] = []
self.blue_ato = AirTaskingOrder()
self.red_ato = AirTaskingOrder()
self.blue_bullseye = Bullseye(Point(0, 0))
self.red_bullseye = Bullseye(Point(0, 0))
self.aircraft_inventory = GlobalAircraftInventory(self.theater.controlpoints)
self.transfers = PendingTransfers(self)
self.sanitize_sides()
self.blue_faker = Faker(self.player_faction.locales)
self.red_faker = Faker(self.enemy_faction.locales)
self.blue_air_wing = AirWing(self, player=True)
self.red_air_wing = AirWing(self, player=False)
self.on_load(game_still_initializing=True)
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"]
del state["blue_faker"]
del state["red_faker"]
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 ato_for(self, player: bool) -> AirTaskingOrder:
if player:
return self.blue_ato
return self.red_ato
def procurement_requests_for(
self, player: bool
) -> List[AircraftProcurementRequest]:
if player:
return self.blue_procurement_requests
return self.red_procurement_requests
def transit_network_for(self, player: bool) -> TransitNetwork:
if player:
return self.blue_transit_network
return self.red_transit_network
def generate_conditions(self) -> Conditions:
return Conditions.generate(
self.theater, self.current_day, self.current_turn_time_of_day, self.settings
self.aircraft_inventory = GlobalAircraftInventory(
self.theater.controlpoints
)
def sanitize_sides(self) -> None:
self.sanitize_sides()
self.on_load()
def generate_conditions(self) -> Conditions:
return Conditions.generate(self.theater, self.date,
self.current_turn_time_of_day, self.settings)
def sanitize_sides(self):
"""
Make sure the opposing factions are using different countries
:return:
@@ -201,347 +123,247 @@ class Game:
else:
self.enemy_country = "Russia"
def faction_for(self, player: bool) -> Faction:
if player:
return self.player_faction
return self.enemy_faction
@property
def player_faction(self) -> Faction:
return db.FACTIONS[self.player_name]
def faker_for(self, player: bool) -> Faker:
if player:
return self.blue_faker
return self.red_faker
@property
def enemy_faction(self) -> Faction:
return db.FACTIONS[self.enemy_name]
def air_wing_for(self, player: bool) -> AirWing:
if player:
return self.blue_air_wing
return self.red_air_wing
def country_for(self, player: bool) -> str:
if player:
return self.player_country
return self.enemy_country
def bullseye_for(self, player: bool) -> Bullseye:
if player:
return self.blue_bullseye
return self.red_bullseye
def _generate_player_event(
self, event_class: Type[Event], player_cp: ControlPoint, enemy_cp: ControlPoint
) -> None:
self.events.append(
event_class(
self,
player_cp,
enemy_cp,
enemy_cp.position,
self.player_faction.name,
self.enemy_faction.name,
)
)
def _generate_events(self) -> None:
for front_line in self.theater.conflicts():
self._generate_player_event(
FrontlineAttackEvent,
front_line.blue_cp,
front_line.red_cp,
)
def adjust_budget(self, amount: float, player: bool) -> None:
if player:
self.budget += amount
def _roll(self, prob, mult):
if self.settings.version == "dev":
# always generate all events for dev
return 100
else:
self.enemy_budget += amount
return random.randint(1, 100) <= prob * mult
def process_player_income(self) -> None:
self.budget += Income(self, player=True).total
def _generate_player_event(self, event_class, player_cp, enemy_cp):
self.events.append(event_class(self, player_cp, enemy_cp, enemy_cp.position, self.player_name, self.enemy_name))
def process_enemy_income(self) -> None:
# TODO: Clean up save compat.
if not hasattr(self, "enemy_budget"):
self.enemy_budget = 0
self.enemy_budget += Income(self, player=False).total
def _generate_events(self):
for front_line in self.theater.conflicts(True):
self._generate_player_event(FrontlineAttackEvent,
front_line.control_point_a,
front_line.control_point_b)
@staticmethod
def initiate_event(event: Event) -> UnitMap:
# assert event in self.events
def commision_unit_types(self, cp: ControlPoint, for_task: Task) -> List[UnitType]:
importance_factor = (cp.importance - IMPORTANCE_LOW) / (IMPORTANCE_HIGH - IMPORTANCE_LOW)
if for_task == AirDefence and not self.settings.sams:
return [x for x in db.find_unittype(AirDefence, self.enemy_name) if x not in db.SAM_BAN]
else:
return db.choose_units(for_task, importance_factor, COMMISION_UNIT_VARIETY, self.enemy_name)
def _commision_units(self, cp: ControlPoint):
for for_task in [CAS, CAP, AirDefence]:
limit = COMMISION_LIMITS_FACTORS[for_task] * math.pow(cp.importance,
COMMISION_LIMITS_SCALE) * self.settings.multiplier
missing_units = limit - cp.base.total_units(for_task)
if missing_units > 0:
awarded_points = COMMISION_AMOUNTS_FACTORS[for_task] * math.pow(cp.importance,
COMMISION_AMOUNTS_SCALE) * self.settings.multiplier
points_to_spend = cp.base.append_commision_points(for_task, awarded_points)
if points_to_spend > 0:
unittypes = self.commision_unit_types(cp, for_task)
if len(unittypes) > 0:
d = {random.choice(unittypes): points_to_spend}
logging.info("Commision {}: {}".format(cp, d))
cp.base.commision_units(d)
@property
def budget_reward_amount(self):
reward = 0
if len(self.theater.player_points()) > 0:
reward = PLAYER_BUDGET_BASE * len(self.theater.player_points())
for cp in self.theater.player_points():
for g in cp.ground_objects:
if g.category in REWARDS.keys():
reward = reward + REWARDS[g.category]
return reward
else:
return reward
def _budget_player(self):
self.budget += self.budget_reward_amount
def awacs_expense_commit(self):
self.budget -= AWACS_BUDGET_COST
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 units_delivery_remove(self, event: Event):
if event in self.events:
self.events.remove(event)
def initiate_event(self, event: Event):
#assert event in self.events
logging.info("Generating {} (regular)".format(event))
return event.generate()
event.generate()
def finish_event(self, event: Event, debriefing: Debriefing) -> None:
def finish_event(self, event: Event, debriefing: Debriefing):
logging.info("Finishing event {}".format(event))
event.commit(debriefing)
if event.is_successfull(debriefing):
self.budget += event.bonus()
if event in self.events:
self.events.remove(event)
else:
logging.info("finish_event: event not in the events!")
def on_load(self, game_still_initializing: bool = False) -> None:
if not hasattr(self, "name_generator"):
self.name_generator = naming.namegen
# Hack: Replace the global name generator state with the state from the save
# game.
#
# We need to persist this state so that names generated after game load don't
# conflict with those generated before exit.
naming.namegen = self.name_generator
LuaPluginManager.load_settings(self.settings)
def is_player_attack(self, event):
if isinstance(event, Event):
return event and event.attacker_name and event.attacker_name == self.player_name
else:
return event and event.name and event.name == self.player_name
def on_load(self) -> None:
ObjectiveDistanceCache.set_theater(self.theater)
self.compute_conflicts_position()
if not game_still_initializing:
self.compute_threat_zones()
self.blue_faker = Faker(self.faction_for(player=True).locales)
self.red_faker = Faker(self.faction_for(player=False).locales)
# set the settings in all plugins
for plugin in LuaPluginManager().getPlugins():
plugin.setSettings(self.settings)
def reset_ato(self) -> None:
self.blue_ato.clear()
self.red_ato.clear()
# Save game compatibility.
def finish_turn(self, skipped: bool = False) -> None:
"""Finalizes the current turn and advances to the next turn.
# TODO: Remove in 2.3.
if not hasattr(self, "conditions"):
self.conditions = self.generate_conditions()
This handles the turn-end portion of passing a turn. Initialization of the next
turn is handled by `initialize_turn`. These are separate processes because while
turns may be initialized more than once under some circumstances (see the
documentation for `initialize_turn`), `finish_turn` performs the work that
should be guaranteed to happen only once per turn:
* Turn counter increment.
* Delivering units ordered the previous turn.
* Transfer progress.
* Squadron replenishment.
* Income distribution.
* Base strength (front line position) adjustment.
* Weather/time-of-day generation.
Some actions (like transit network assembly) will happen both here and in
`initialize_turn`. We need the network to be up to date so we can account for
base captures when processing the transfers that occurred last turn, but we also
need it to be up to date in the case of a re-initialization in `initialize_turn`
(such as to account for a cheat base capture) so that orders are only placed
where a supply route exists to the destination. This is a relatively cheap
operation so duplicating the effort is not a problem.
Args:
skipped: True if the turn was skipped.
"""
self.informations.append(
Information("End of turn #" + str(self.turn), "-" * 40, 0)
)
def pass_turn(self, no_action: bool = False) -> None:
logging.info("Pass turn")
self.informations.append(Information("End of turn #" + str(self.turn), "-" * 40, 0))
self.turn += 1
# Need to recompute before transfers and deliveries to account for captures.
# This happens in in initialize_turn as well, because cheating doesn't advance a
# turn but can capture bases so we need to recompute there as well.
self.compute_transit_networks()
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()
# Must happen *before* unit deliveries are handled, or else new units will spawn
# one hop ahead. ControlPoint.process_turn handles unit deliveries.
self.transfers.perform_transfers()
self._enemy_reinforcement()
self._budget_player()
# Needs to happen *before* planning transfers so we don't cancel them.
self.reset_ato()
for control_point in self.theater.controlpoints:
control_point.process_turn(self)
self.blue_air_wing.replenish()
self.red_air_wing.replenish()
if not skipped:
if not no_action and self.turn > 1:
for cp in self.theater.player_points():
cp.base.affect_strength(+PLAYER_BASE_STRENGTH_RECOVERY)
elif self.turn > 1:
else:
for cp in self.theater.player_points():
if not cp.is_carrier and not cp.is_lha:
cp.base.affect_strength(-PLAYER_BASE_STRENGTH_RECOVERY)
self.conditions = self.generate_conditions()
self.process_enemy_income()
self.process_player_income()
def begin_turn_0(self) -> None:
"""Initialization for the first turn of the game."""
self.turn = 0
self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None:
"""Ends the current turn and initializes the new turn.
Called both when skipping a turn or by ending the turn as the result of combat.
Args:
no_action: True if the turn was skipped.
"""
logging.info("Pass turn")
with logged_duration("Turn finalization"):
self.finish_turn(no_action)
with logged_duration("Turn initialization"):
self.initialize_turn()
# Autosave progress
persistency.autosave(self)
def check_win_loss(self) -> TurnState:
player_airbases = {
cp for cp in self.theater.player_points() if cp.runway_is_operational()
}
if not player_airbases:
return TurnState.LOSS
enemy_airbases = {
cp for cp in self.theater.enemy_points() if cp.runway_is_operational()
}
if not enemy_airbases:
return TurnState.WIN
return TurnState.CONTINUE
def set_bullseye(self) -> None:
player_cp, enemy_cp = self.theater.closest_opposing_control_points()
self.blue_bullseye = Bullseye(enemy_cp.position)
self.red_bullseye = Bullseye(player_cp.position)
def initialize_turn(self, for_red: bool = True, for_blue: bool = True) -> None:
"""Performs turn initialization for the specified players.
Turn initialization performs all of the beginning-of-turn actions. *End-of-turn*
processing happens in `pass_turn` (despite the name, it's called both for
skipping the turn and ending the turn after combat).
Special care needs to be taken here because initialization can occur more than
once per turn. A number of events can require re-initializing a turn:
* Cheat capture. Bases changing hands invalidates many missions in both ATOs,
purchase orders, threat zones, transit networks, etc. Practically speaking,
after a base capture the turn needs to be treated as fully new. The game might
even be over after a capture.
* Cheat front line position. CAS missions are no longer in the correct location,
and the ground planner may also need changes.
* Selling/buying units at TGOs. Selling a TGO might leave missions in the ATO
with invalid targets. Buying a new SAM (or even replacing some units in a SAM)
potentially changes the threat zone and may alter mission priorities and
flight planning.
Most of the work is delegated to initialize_turn_for, which handles the
coalition-specific turn initialization. In some cases only one coalition will be
(re-) initialized. This is the case when buying or selling TGO units, since we
don't want to force the player to redo all their planning just because they
repaired a SAM, but should replan opfor when that happens. On the other hand,
base captures are significant enough (and likely enough to be the first thing
the player does in a turn) that we replan blue as well. Front lines are less
impactful but also likely to be early, so they also cause a blue replan.
Args:
for_red: True if opfor should be re-initialized.
for_blue: True if the player coalition should be re-initialized.
"""
def initialize_turn(self) -> None:
self.events = []
self._generate_events()
self.set_bullseye()
# Update statistics
self.game_stats.update(self)
# Check for win or loss condition
turn_state = self.check_win_loss()
if turn_state in (TurnState.LOSS, TurnState.WIN):
return self.process_win_loss(turn_state)
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
# Plan Coalition specific turn
if for_red:
self.initialize_turn_for(player=False)
if for_blue:
self.initialize_turn_for(player=True)
# Plan GroundWar
# Plan flights & combat for next turn
self.__culling_points = self.compute_conflicts_position()
self.ground_planners = {}
self.blue_ato.clear()
self.red_ato.clear()
CoalitionMissionPlanner(self, is_player=True).plan_missions()
CoalitionMissionPlanner(self, is_player=False).plan_missions()
for cp in self.theater.controlpoints:
if cp.has_frontline:
gplanner = GroundPlanner(cp, self)
gplanner.plan_groundwar()
self.ground_planners[cp.id] = gplanner
def initialize_turn_for(self, player: bool) -> None:
"""Processes coalition-specific turn initialization.
For more information on turn initialization in general, see the documentation
for `Game.initialize_turn`.
Args:
player: True if the player coalition is being initialized. False for opfor
initialization.
def _enemy_reinforcement(self):
"""
Compute and commision reinforcement for enemy bases
"""
self.ato_for(player).clear()
self.air_wing_for(player).reset()
self.aircraft_inventory.reset()
for cp in self.theater.controlpoints:
self.aircraft_inventory.set_from_control_point(cp)
# Refund all pending deliveries for opfor and if player
# has automate_aircraft_reinforcements
if (not player and not cp.captured) or (
player
and cp.captured
and self.settings.automate_aircraft_reinforcements
):
cp.pending_unit_deliveries.refund_all(self)
MAX_ARMOR = 30 * self.settings.multiplier
MAX_AIRCRAFT = 25 * self.settings.multiplier
# Plan flights & combat for next turn
with logged_duration("Computing conflict positions"):
self.compute_conflicts_position()
with logged_duration("Threat zone computation"):
self.compute_threat_zones()
with logged_duration("Transit network identification"):
self.compute_transit_networks()
self.ground_planners = {}
production = 0.0
for enemy_point in self.theater.enemy_points():
for g in enemy_point.ground_objects:
if g.category in REWARDS.keys():
production = production + REWARDS[g.category]
self.procurement_requests_for(player).clear()
production = production * 0.75
budget_for_armored_units = production / 2
budget_for_aircraft = production / 2
with logged_duration("Procurement of airlift assets"):
self.transfers.order_airlift_assets()
with logged_duration("Transport planning"):
self.transfers.plan_transports()
potential_cp_armor = []
for cp in self.theater.enemy_points():
for cpe in cp.connected_points:
if cpe.captured and cp.base.total_armor < MAX_ARMOR:
potential_cp_armor.append(cp)
if len(potential_cp_armor) == 0:
potential_cp_armor = self.theater.enemy_points()
if not player or (
player and self.settings.auto_ato_behavior is not AutoAtoBehavior.Disabled
):
color = "Blue" if player else "Red"
with logged_duration(f"{color} mission planning"):
mission_planner = CoalitionMissionPlanner(self, player)
mission_planner.plan_missions()
i = 0
potential_units = db.FACTIONS[self.enemy_name].frontline_units
self.plan_procurement_for(player)
print("Enemy Recruiting")
print(potential_cp_armor)
print(budget_for_armored_units)
print(potential_units)
def plan_procurement_for(self, for_player: bool) -> 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. After that the budget will be spend proportionally based on how much is already invested
if len(potential_units) > 0 and len(potential_cp_armor) > 0:
while budget_for_armored_units > 0:
i = i + 1
if i > 50 or budget_for_armored_units <= 0:
break
target_cp = random.choice(potential_cp_armor)
if target_cp.base.total_armor >= MAX_ARMOR:
continue
unit = random.choice(potential_units)
price = db.PRICES[unit] * 2
budget_for_armored_units -= price * 2
target_cp.base.armor[unit] = target_cp.base.armor.get(unit, 0) + 2
info = Information("Enemy Reinforcement", unit.id + " x 2 at " + target_cp.name, self.turn)
print(str(info))
self.informations.append(info)
if for_player:
self.budget = ProcurementAi(
self,
for_player=True,
faction=self.player_faction,
manage_runways=self.settings.automate_runway_repair,
manage_front_line=self.settings.automate_front_line_reinforcements,
manage_aircraft=self.settings.automate_aircraft_reinforcements,
).spend_budget(self.budget)
else:
self.enemy_budget = ProcurementAi(
self,
for_player=False,
faction=self.enemy_faction,
manage_runways=True,
manage_front_line=True,
manage_aircraft=True,
).spend_budget(self.enemy_budget)
if budget_for_armored_units > 0:
budget_for_aircraft += budget_for_armored_units
def message(self, text: str) -> None:
self.informations.append(Information(text, turn=self.turn))
potential_units = [u for u in db.FACTIONS[self.enemy_name].aircrafts
if u in db.UNIT_BY_TASK[CAS] or u in db.UNIT_BY_TASK[CAP]]
if len(potential_units) > 0 and len(potential_cp_armor) > 0:
while budget_for_aircraft > 0:
i = i + 1
if i > 50 or budget_for_aircraft <= 0:
break
target_cp = random.choice(potential_cp_armor)
if target_cp.base.total_planes >= MAX_AIRCRAFT:
continue
unit = random.choice(potential_units)
price = db.PRICES[unit] * 2
budget_for_aircraft -= price * 2
target_cp.base.aircraft[unit] = target_cp.base.aircraft.get(unit, 0) + 2
info = Information("Enemy Reinforcement", unit.id + " x 2 at " + target_cp.name, self.turn)
print(str(info))
self.informations.append(info)
@property
def current_turn_time_of_day(self) -> TimeOfDay:
@@ -551,157 +373,105 @@ class Game:
def current_day(self) -> date:
return self.date + timedelta(days=self.turn // 4)
def next_unit_id(self) -> int:
def next_unit_id(self):
"""
Next unit id for pre-generated units
"""
self.current_unit_id += 1
return self.current_unit_id
def next_group_id(self) -> int:
def next_group_id(self):
"""
Next unit id for pre-generated units
"""
self.current_group_id += 1
return self.current_group_id
def compute_transit_networks(self) -> None:
self.blue_transit_network = self.compute_transit_network_for(player=True)
self.red_transit_network = self.compute_transit_network_for(player=False)
def compute_transit_network_for(self, player: bool) -> TransitNetwork:
return TransitNetworkBuilder(self.theater, player).build()
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) -> None:
def compute_conflicts_position(self):
"""
Compute the current conflict center position(s), mainly used for culling calculation
:return: List of points of interests
"""
zones = []
points = []
# By default, use the existing frontline conflict position
for front_line in self.theater.conflicts():
position = Conflict.frontline_position(front_line, self.theater)
zones.append(position[0])
zones.append(front_line.blue_cp.position)
zones.append(front_line.red_cp.position)
for cp in self.theater.controlpoints:
# If do_not_cull_carrier is enabled, add carriers as culling point
if self.settings.perf_do_not_cull_carrier:
if cp.is_carrier or cp.is_lha:
zones.append(cp.position)
position = Conflict.frontline_position(self.theater,
front_line.control_point_a,
front_line.control_point_b)
points.append(position[0])
points.append(front_line.control_point_a.position)
points.append(front_line.control_point_b.position)
# If there is no conflict take the center point between the two nearest opposing bases
if len(zones) == 0:
if len(points) == 0:
cpoint = None
min_distance = math.inf
min_distance = sys.maxsize
for cp in self.theater.player_points():
for cp2 in self.theater.enemy_points():
d = cp.position.distance_to_point(cp2.position)
if d < min_distance:
min_distance = d
cpoint = Point(
(cp.position.x + cp2.position.x) / 2,
(cp.position.y + cp2.position.y) / 2,
)
zones.append(cp.position)
zones.append(cp2.position)
cpoint = Point((cp.position.x + cp2.position.x) / 2, (cp.position.y + cp2.position.y) / 2)
points.append(cp.position)
points.append(cp2.position)
break
if cpoint is not None:
break
if cpoint is not None:
zones.append(cpoint)
packages = itertools.chain(self.blue_ato.packages, self.red_ato.packages)
for package in packages:
if package.primary_task is FlightType.BARCAP:
# BARCAPs will be planned at most locations on smaller theaters,
# rendering culling fairly useless. BARCAP packages don't really
# need the ground detail since they're defensive. SAMs nearby
# are only interesting if there are enemies in the area, and if
# there are they won't be culled because of the enemy's mission.
continue
zones.append(package.target.position)
points.append(cpoint)
# Else 0,0, since we need a default value
# (in this case this means the whole map is owned by the same player, so it is not an issue)
if len(zones) == 0:
zones.append(Point(0, 0))
if len(points) == 0:
points.append(Point(0, 0))
self.__culling_zones = zones
return points
def add_destroyed_units(self, data: dict[str, Union[float, str]]) -> None:
pos = Point(cast(float, data["x"]), cast(float, data["z"]))
def add_destroyed_units(self, data):
pos = Point(data["x"], data["z"])
if self.theater.is_on_land(pos):
self.__destroyed_units.append(data)
def get_destroyed_units(self) -> list[dict[str, Union[float, str]]]:
def get_destroyed_units(self):
return self.__destroyed_units
def position_culled(self, pos: Point) -> bool:
def position_culled(self, pos):
"""
Check if unit can be generated at given position depending on culling performance settings
:param pos: Position you are tryng to spawn stuff at
:return: True if units can not be added at given position
"""
if not self.settings.perf_culling:
if self.settings.perf_culling == False:
return False
for z in self.__culling_zones:
if z.distance_to_point(pos) < self.settings.perf_culling_distance * 1000:
return False
return True
else:
for c in self.__culling_points:
if c.distance_to_point(pos) < self.settings.perf_culling_distance * 1000:
return False
return True
def get_culling_zones(self) -> list[Point]:
def get_culling_points(self):
"""
Check culling points
:return: List of culling zones
:return: List of culling points
"""
return self.__culling_zones
return self.__culling_points
# 1 = red, 2 = blue
def get_player_coalition_id(self) -> int:
def get_player_coalition_id(self):
return 2
def get_enemy_coalition_id(self) -> int:
def get_enemy_coalition_id(self):
return 1
def get_player_coalition(self) -> Coalition:
def get_player_coalition(self):
return Coalition.Blue
def get_enemy_coalition(self) -> Coalition:
def get_enemy_coalition(self):
return Coalition.Red
def get_player_color(self) -> str:
def get_player_color(self):
return "blue"
def get_enemy_color(self) -> str:
return "red"
def process_win_loss(self, turn_state: TurnState) -> None:
if turn_state is TurnState.WIN:
self.message(
"Congratulations, you are victorious! Start a new campaign to continue."
)
elif turn_state is TurnState.LOSS:
self.message("Game Over, you lose. Start a new campaign to continue.")
def get_enemy_color(self):
return "red"

View File

@@ -1,55 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from game.db import REWARDS
if TYPE_CHECKING:
from game import Game
@dataclass(frozen=True)
class BuildingIncome:
name: str
category: str
number: int
income_per_building: int
@property
def income(self) -> int:
return self.number * self.income_per_building
class Income:
def __init__(self, game: Game, player: bool) -> None:
if player:
self.multiplier = game.settings.player_income_multiplier
else:
self.multiplier = game.settings.enemy_income_multiplier
self.control_points = []
self.buildings = []
names = set()
for cp in game.theater.control_points_for(player):
if cp.income_per_turn:
self.control_points.append(cp)
for tgo in cp.ground_objects:
names.add(tgo.obj_name)
for name in names:
count = 0
tgos = game.theater.find_ground_objects_by_obj_name(name)
category = tgos[0].category
if category not in REWARDS:
continue
for tgo in tgos:
if not tgo.is_dead:
count += 1
self.buildings.append(
BuildingIncome(name, category, count, REWARDS[category])
)
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 = (self.total_buildings + self.from_bases) * self.multiplier

View File

@@ -1,19 +1,11 @@
import datetime
class Information():
class Information:
def __init__(self, title: str = "", text: str = "", turn: int = 0) -> None:
def __init__(self, title="", text="", turn=0):
self.title = title
self.text = text
self.turn = turn
self.timestamp = datetime.datetime.now()
def __str__(self) -> str:
return "[{}][{}] {} {}".format(
self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if self.timestamp is not None
else "",
self.turn,
self.title,
self.text,
)
def __str__(self):
s = "[" + str(self.turn) + "] " + self.title + "\n" + self.text
return s

View File

@@ -1,16 +1,11 @@
"""Inventory management APIs."""
from __future__ import annotations
from collections import defaultdict
from typing import Dict, Iterable, Iterator, Set, Tuple, TYPE_CHECKING, Type
from typing import Dict, Iterable, Iterator, Set, Tuple
from dcs.unittype import FlyingType
from dcs.unittype import UnitType
from game.dcs.aircrafttype import AircraftType
from gen.flights.flight import Flight
if TYPE_CHECKING:
from game.theater import ControlPoint
from theater import ControlPoint
class ControlPointAircraftInventory:
@@ -18,9 +13,9 @@ class ControlPointAircraftInventory:
def __init__(self, control_point: ControlPoint) -> None:
self.control_point = control_point
self.inventory: Dict[AircraftType, int] = defaultdict(int)
self.inventory: Dict[UnitType, int] = defaultdict(int)
def add_aircraft(self, aircraft: AircraftType, count: int) -> None:
def add_aircraft(self, aircraft: UnitType, count: int) -> None:
"""Adds aircraft to the inventory.
Args:
@@ -29,7 +24,7 @@ class ControlPointAircraftInventory:
"""
self.inventory[aircraft] += count
def remove_aircraft(self, aircraft: AircraftType, count: int) -> None:
def remove_aircraft(self, aircraft: UnitType, count: int) -> None:
"""Removes aircraft from the inventory.
Args:
@@ -43,31 +38,28 @@ class ControlPointAircraftInventory:
available = self.inventory[aircraft]
if available < count:
raise ValueError(
f"Cannot remove {count} {aircraft} from "
f"Cannot remove {count} {aircraft.id} from "
f"{self.control_point.name}. Only have {available}."
)
self.inventory[aircraft] -= count
def available(self, aircraft: AircraftType) -> int:
def available(self, aircraft: UnitType) -> int:
"""Returns the number of available aircraft of the given type.
Args:
aircraft: The type of aircraft to query.
"""
try:
return self.inventory[aircraft]
except KeyError:
return 0
return self.inventory[aircraft]
@property
def types_available(self) -> Iterator[AircraftType]:
def types_available(self) -> Iterator[UnitType]:
"""Iterates over all available aircraft types."""
for aircraft, count in self.inventory.items():
if count > 0:
yield aircraft
@property
def all_aircraft(self) -> Iterator[Tuple[AircraftType, int]]:
def all_aircraft(self) -> Iterator[Tuple[UnitType, int]]:
"""Iterates over all available aircraft types, including amounts."""
for aircraft, count in self.inventory.items():
if count > 0:
@@ -80,7 +72,6 @@ class ControlPointAircraftInventory:
class GlobalAircraftInventory:
"""Game-wide aircraft inventory."""
def __init__(self, control_points: Iterable[ControlPoint]) -> None:
self.inventories: Dict[ControlPoint, ControlPointAircraftInventory] = {
cp: ControlPointAircraftInventory(cp) for cp in control_points
@@ -102,20 +93,18 @@ class GlobalAircraftInventory:
inventory.add_aircraft(aircraft, count)
def for_control_point(
self, control_point: ControlPoint
) -> ControlPointAircraftInventory:
self,
control_point: ControlPoint) -> ControlPointAircraftInventory:
"""Returns the inventory specific to the given control point."""
return self.inventories[control_point]
@property
def available_types_for_player(self) -> Iterator[AircraftType]:
def available_types_for_player(self) -> Iterator[UnitType]:
"""Iterates over all aircraft types available to the player."""
seen: Set[AircraftType] = set()
seen: Set[UnitType] = set()
for control_point, inventory in self.inventories.items():
if control_point.captured:
for aircraft in inventory.types_available:
if not control_point.can_operate(aircraft):
continue
if aircraft not in seen:
seen.add(aircraft)
yield aircraft

View File

@@ -0,0 +1,14 @@
class DestroyedUnit:
"""
Store info about a destroyed unit
"""
x: int
y: int
name: str
def __init__(self, x , y, name):
self.x = x
self.y = y
self.name = name

View File

@@ -0,0 +1,13 @@
from theater import ControlPoint
class FrontlineData:
"""
This Data structure will store information about an existing frontline
"""
def __init__(self, from_cp:ControlPoint, to_cp: ControlPoint):
self.to_cp = to_cp
self.from_cp = from_cp
self.enemy_units_position = []
self.blue_units_position = []

View File

@@ -1,10 +1,4 @@
from __future__ import annotations
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
from typing import List
class FactionTurnMetadata:
"""
@@ -15,7 +9,7 @@ class FactionTurnMetadata:
vehicles_count: int = 0
sam_count: int = 0
def __init__(self) -> None:
def __init__(self):
self.aircraft_count = 0
self.vehicles_count = 0
self.sam_count = 0
@@ -26,10 +20,10 @@ class GameTurnMetadata:
Store metadata about a game turn
"""
allied_units: FactionTurnMetadata
enemy_units: FactionTurnMetadata
allied_units:FactionTurnMetadata
enemy_units:FactionTurnMetadata
def __init__(self) -> None:
def __init__(self):
self.allied_units = FactionTurnMetadata()
self.enemy_units = FactionTurnMetadata()
@@ -39,19 +33,15 @@ class GameStats:
Store statistics for the current game
"""
def __init__(self) -> None:
def __init__(self):
self.data_per_turn: List[GameTurnMetadata] = []
def update(self, game: Game) -> None:
def update(self, game):
"""
Save data for current turn
:param game: Game we want to save the data about
"""
# Remove the current turn if its just an update for this turn
if 0 < game.turn < len(self.data_per_turn):
del self.data_per_turn[-1]
turn_data = GameTurnMetadata()
for cp in game.theater.controlpoints:
@@ -63,3 +53,4 @@ class GameStats:
turn_data.enemy_units.vehicles_count += sum(cp.base.armor.values())
self.data_per_turn.append(turn_data)

View File

@@ -1,273 +0,0 @@
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 NavMeshError(RuntimeError):
pass
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.intersects(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 NavMeshError(
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 NavMeshError(f"Origin point {origin} is outside the navmesh")
destination_poly = self.localize(destination)
if destination_poly is None:
raise NavMeshError(
f"Destination 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(200).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

@@ -0,0 +1,40 @@
from dcs.terrain.terrain import Terrain
from gen.conflictgen import Conflict
from .operation import Operation
from .. import db
MAX_DISTANCE_BETWEEN_GROUPS = 12000
class FrontlineAttackOperation(Operation):
interceptors = None # type: db.AssignedUnitsDict
escort = None # type: db.AssignedUnitsDict
strikegroup = None # type: db.AssignedUnitsDict
attackers = None # type: db.ArmorDict
defenders = None # type: db.ArmorDict
def prepare(self, terrain: Terrain, is_quick: bool):
super(FrontlineAttackOperation, self).prepare(terrain, is_quick)
if self.defender_name == self.game.player_name:
self.attackers_starting_position = None
self.defenders_starting_position = None
conflict = Conflict.frontline_cas_conflict(
attacker_name=self.attacker_name,
defender_name=self.defender_name,
attacker=self.current_mission.country(self.attacker_country),
defender=self.current_mission.country(self.defender_country),
from_cp=self.from_cp,
to_cp=self.to_cp,
theater=self.game.theater
)
self.initialize(mission=self.current_mission,
conflict=conflict)
def generate(self):
self.briefinggen.title = "Frontline CAS"
self.briefinggen.description = "Provide CAS for the ground forces attacking enemy lines. Operation will be considered successful if total number of enemy units will be lower than your own by a factor of 1.5 (i.e. with 12 units from both sides, enemy forces need to be reduced to at least 8), meaning that you (and, probably, your wingmans) should concentrate on destroying the enemy units. Target base strength will be lowered as a result. Be advised that your flight will not attack anything until you explicitly tell them so by comms menu."
super(FrontlineAttackOperation, self).generate()

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Iterable, List, Set, TYPE_CHECKING, cast
from typing import List, Optional, Set
from dcs import Mission
from dcs.action import DoScript, DoScriptFile
@@ -11,224 +9,182 @@ from dcs.coalition import Coalition
from dcs.countries import country_dict
from dcs.lua.parse import loads
from dcs.mapping import Point
from dcs.terrain.terrain import Terrain
from dcs.translation import String
from dcs.triggers import TriggerStart
from dcs.unittype import UnitType
from game.plugins import LuaPluginManager
from game.theater.theatergroundobject import TheaterGroundObject
from gen import Conflict, FlightType, VisualGenerator
from gen.aircraft import AircraftConflictGenerator, FlightData
from gen.aircraft import AIRCRAFT_DATA, AircraftConflictGenerator, FlightData
from gen.airfields import AIRFIELD_DATA
from gen.airsupportgen import AirSupport, AirSupportConflictGenerator
from gen.armor import GroundConflictGenerator, JtacInfo
from gen.beacons import load_beacons_for_terrain
from gen.briefinggen import BriefingGenerator, MissionInfoGenerator
from gen.cargoshipgen import CargoShipGenerator
from gen.convoygen import ConvoyGenerator
from gen.briefinggen import BriefingGenerator
from gen.environmentgen import EnvironmentGenerator
from gen.forcedoptionsgen import ForcedOptionsGenerator
from gen.groundobjectsgen import GroundObjectsGenerator
from gen.kneeboard import KneeboardGenerator
from gen.naming import namegen
from gen.radios import RadioFrequency, RadioRegistry
from gen.tacan import TacanRegistry
from gen.triggergen import TRIGGER_RADIUS_MEDIUM, TriggersGenerator
from plugin import LuaPluginManager
from theater import ControlPoint
from .. import db
from ..theater import Airfield, FrontLine
from ..unitmap import UnitMap
if TYPE_CHECKING:
from game import Game
from ..debriefing import Debriefing
class Operation:
"""Static class for managing the final Mission generation"""
attackers_starting_position = None # type: db.StartingPosition
defenders_starting_position = None # type: db.StartingPosition
current_mission: Mission
airgen: AircraftConflictGenerator
airsupportgen: AirSupportConflictGenerator
groundobjectgen: GroundObjectsGenerator
radio_registry: RadioRegistry
tacan_registry: TacanRegistry
game: Game
current_mission = None # type: Mission
regular_mission = None # type: Mission
quick_mission = None # type: Mission
conflict = None # type: Conflict
airgen = None # type: AircraftConflictGenerator
triggersgen = None # type: TriggersGenerator
airsupportgen = None # type: AirSupportConflictGenerator
visualgen = None # type: VisualGenerator
groundobjectgen = None # type: GroundObjectsGenerator
briefinggen = None # type: BriefingGenerator
forcedoptionsgen = None # type: ForcedOptionsGenerator
radio_registry: Optional[RadioRegistry] = None
tacan_registry: Optional[TacanRegistry] = None
environment_settings = None
trigger_radius = TRIGGER_RADIUS_MEDIUM
is_quick = None
player_awacs_enabled = True
# TODO: #436 Generate Air Support for red
enemy_awacs_enabled = True
ca_slots = 1
unit_map: UnitMap
jtacs: List[JtacInfo] = []
plugin_scripts: List[str] = []
is_awacs_enabled = False
ca_slots = 0
@classmethod
def prepare(cls, game: Game) -> None:
def __init__(self,
game,
attacker_name: str,
defender_name: str,
from_cp: ControlPoint,
departure_cp: ControlPoint,
to_cp: ControlPoint):
self.game = game
self.attacker_name = attacker_name
self.attacker_country = db.FACTIONS[attacker_name].country
self.defender_name = defender_name
self.defender_country = db.FACTIONS[defender_name].country
print(self.defender_country, self.attacker_country)
self.from_cp = from_cp
self.departure_cp = departure_cp
self.to_cp = to_cp
self.is_quick = False
self.plugin_scripts: List[str] = []
def units_of(self, country_name: str) -> List[UnitType]:
return []
def is_successfull(self, debriefing: Debriefing) -> bool:
return True
@property
def is_player_attack(self) -> bool:
return self.from_cp.captured
def initialize(self, mission: Mission, conflict: Conflict):
self.current_mission = mission
self.conflict = conflict
self.briefinggen = BriefingGenerator(self.current_mission,
self.conflict, self.game)
def prepare(self, terrain: Terrain, is_quick: bool):
with open("resources/default_options.lua", "r") as f:
options_dict = loads(f.read())["options"]
cls._set_mission(Mission(game.theater.terrain))
cls.game = game
cls._setup_mission_coalitions()
cls.current_mission.options.load_from_dict(options_dict)
@classmethod
def air_conflict(cls) -> Conflict:
assert cls.game
player_cp, enemy_cp = cls.game.theater.closest_opposing_control_points()
mid_point = player_cp.position.point_from_heading(
player_cp.position.heading_between_point(enemy_cp.position),
player_cp.position.distance_to_point(enemy_cp.position) / 2,
)
return Conflict(
cls.game.theater,
FrontLine(player_cp, enemy_cp),
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
mid_point,
)
self.current_mission = Mission(terrain)
@classmethod
def _set_mission(cls, mission: Mission) -> None:
cls.current_mission = mission
print(self.game.player_country)
print(country_dict[db.country_id_from_name(self.game.player_country)])
print(country_dict[db.country_id_from_name(self.game.player_country)]())
@classmethod
def _setup_mission_coalitions(cls) -> None:
cls.current_mission.coalition["blue"] = Coalition(
"blue", bullseye=cls.game.blue_bullseye.to_pydcs()
)
cls.current_mission.coalition["red"] = Coalition(
"red", bullseye=cls.game.red_bullseye.to_pydcs()
)
# Setup coalition :
self.current_mission.coalition["blue"] = Coalition("blue")
self.current_mission.coalition["red"] = Coalition("red")
p_country = cls.game.player_country
e_country = cls.game.enemy_country
cls.current_mission.coalition["blue"].add_country(
country_dict[db.country_id_from_name(p_country)]()
)
cls.current_mission.coalition["red"].add_country(
country_dict[db.country_id_from_name(e_country)]()
)
p_country = self.game.player_country
e_country = self.game.enemy_country
self.current_mission.coalition["blue"].add_country(country_dict[db.country_id_from_name(p_country)]())
self.current_mission.coalition["red"].add_country(country_dict[db.country_id_from_name(e_country)]())
@classmethod
def inject_lua_trigger(cls, contents: str, comment: str) -> None:
print([c for c in self.current_mission.coalition["blue"].countries.keys()])
print([c for c in self.current_mission.coalition["red"].countries.keys()])
if is_quick:
self.quick_mission = self.current_mission
else:
self.regular_mission = self.current_mission
self.current_mission.options.load_from_dict(options_dict)
self.is_quick = is_quick
if is_quick:
self.attackers_starting_position = None
self.defenders_starting_position = None
else:
self.attackers_starting_position = self.departure_cp.at
# TODO: Is this possible?
if self.to_cp is not None:
self.defenders_starting_position = self.to_cp.at
else:
self.defenders_starting_position = None
def inject_lua_trigger(self, contents: str, comment: str) -> None:
trigger = TriggerStart(comment=comment)
trigger.add_action(DoScript(String(contents)))
cls.current_mission.triggerrules.triggers.append(trigger)
self.current_mission.triggerrules.triggers.append(trigger)
@classmethod
def bypass_plugin_script(cls, mnemonic: str) -> None:
cls.plugin_scripts.append(mnemonic)
def bypass_plugin_script(self, mnemonic: str) -> None:
self.plugin_scripts.append(mnemonic)
@classmethod
def inject_plugin_script(
cls, plugin_mnemonic: str, script: str, script_mnemonic: str
) -> None:
if script_mnemonic in cls.plugin_scripts:
logging.debug(f"Skipping already loaded {script} for {plugin_mnemonic}")
def inject_plugin_script(self, plugin_mnemonic: str, script: str,
script_mnemonic: str) -> None:
if script_mnemonic in self.plugin_scripts:
logging.debug(
f"Skipping already loaded {script} for {plugin_mnemonic}"
)
else:
cls.plugin_scripts.append(script_mnemonic)
self.plugin_scripts.append(script_mnemonic)
plugin_path = Path("./resources/plugins", plugin_mnemonic)
script_path = Path(plugin_path, script)
if not script_path.exists():
logging.error(f"Cannot find {script_path} for plugin {plugin_mnemonic}")
logging.error(
f"Cannot find {script_path} for plugin {plugin_mnemonic}"
)
return
trigger = TriggerStart(comment=f"Load {script_mnemonic}")
filename = script_path.resolve()
fileref = cls.current_mission.map_resource.add_resource_file(filename)
fileref = self.current_mission.map_resource.add_resource_file(filename)
trigger.add_action(DoScriptFile(fileref))
cls.current_mission.triggerrules.triggers.append(trigger)
self.current_mission.triggerrules.triggers.append(trigger)
@classmethod
def notify_info_generators(
cls,
groundobjectgen: GroundObjectsGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
airgen: AircraftConflictGenerator,
) -> None:
"""Generates subscribed MissionInfoGenerator objects (currently kneeboards and briefings)"""
def generate(self):
radio_registry = RadioRegistry()
tacan_registry = TacanRegistry()
gens: List[MissionInfoGenerator] = [
KneeboardGenerator(cls.current_mission, cls.game),
BriefingGenerator(cls.current_mission, cls.game),
]
for gen in gens:
for dynamic_runway in groundobjectgen.runways.values():
gen.add_dynamic_runway(dynamic_runway)
for tanker in airsupportgen.air_support.tankers:
if tanker.blue:
gen.add_tanker(tanker)
for aewc in airsupportgen.air_support.awacs:
if aewc.blue:
gen.add_awacs(aewc)
for jtac in jtacs:
if jtac.blue:
gen.add_jtac(jtac)
for flight in airgen.flights:
gen.add_flight(flight)
gen.generate()
@classmethod
def create_unit_map(cls) -> None:
cls.unit_map = UnitMap()
for control_point in cls.game.theater.controlpoints:
if isinstance(control_point, Airfield):
cls.unit_map.add_airfield(control_point)
@classmethod
def create_radio_registries(cls) -> None:
# Dedup beacon/radio frequencies, since some maps have some frequencies
# used multiple times.
beacons = load_beacons_for_terrain(self.game.theater.terrain.name)
unique_map_frequencies: Set[RadioFrequency] = set()
cls._create_tacan_registry(unique_map_frequencies)
cls._create_radio_registry(unique_map_frequencies)
assert cls.radio_registry is not None
for frequency in unique_map_frequencies:
cls.radio_registry.reserve(frequency)
@classmethod
def assign_channels_to_flights(
cls, flights: List[FlightData], air_support: AirSupport
) -> None:
"""Assigns preset radio channels for client flights."""
for flight in flights:
if not flight.client_units:
continue
flight.aircraft_type.assign_channels_for_flight(flight, air_support)
@classmethod
def _create_tacan_registry(
cls, unique_map_frequencies: Set[RadioFrequency]
) -> None:
"""
Dedup beacon/radio frequencies, since some maps have some frequencies
used multiple times.
"""
cls.tacan_registry = TacanRegistry()
beacons = load_beacons_for_terrain(cls.game.theater.terrain.name)
for beacon in beacons:
unique_map_frequencies.add(beacon.frequency)
if beacon.is_tacan:
if beacon.channel is None:
logging.error(f"TACAN beacon has no channel: {beacon.callsign}")
logging.error(
f"TACAN beacon has no channel: {beacon.callsign}")
else:
cls.tacan_registry.reserve(beacon.tacan_channel)
tacan_registry.reserve(beacon.tacan_channel)
@classmethod
def _create_radio_registry(
cls, unique_map_frequencies: Set[RadioFrequency]
) -> None:
cls.radio_registry = RadioRegistry()
for data in AIRFIELD_DATA.values():
if data.theater == cls.game.theater.terrain.name and data.atc:
for airfield, data in AIRFIELD_DATA.items():
if data.theater == self.game.theater.terrain.name:
unique_map_frequencies.add(data.atc.hf)
unique_map_frequencies.add(data.atc.vhf_fm)
unique_map_frequencies.add(data.atc.vhf_am)
@@ -236,39 +192,35 @@ class Operation:
# No need to reserve ILS or TACAN because those are in the
# beacon list.
@classmethod
def _generate_ground_units(cls) -> None:
cls.groundobjectgen = GroundObjectsGenerator(
cls.current_mission,
cls.game,
cls.radio_registry,
cls.tacan_registry,
cls.unit_map,
)
cls.groundobjectgen.generate()
for frequency in unique_map_frequencies:
radio_registry.reserve(frequency)
@classmethod
def _generate_destroyed_units(cls) -> None:
"""Add destroyed units to the Mission"""
for d in cls.game.get_destroyed_units():
# Set mission time and weather conditions.
EnvironmentGenerator(self.current_mission,
self.game.conditions).generate()
# Generate ground object first
groundobjectgen = GroundObjectsGenerator(
self.current_mission,
self.conflict,
self.game,
radio_registry,
tacan_registry
)
groundobjectgen.generate()
# Generate destroyed units
for d in self.game.get_destroyed_units():
try:
type_name = d["type"]
if not isinstance(type_name, str):
raise TypeError(
"Expected the type of the destroyed static to be a string"
)
utype = db.unit_type_from_name(type_name)
utype = db.unit_type_from_name(d["type"])
except KeyError:
continue
pos = Point(cast(float, d["x"]), cast(float, d["z"]))
if (
utype is not None
and not cls.game.position_culled(pos)
and cls.game.settings.perf_destroyed_units
):
cls.current_mission.static_group(
country=cls.current_mission.country(cls.game.player_country),
pos = Point(d["x"], d["z"])
if utype is not None and not self.game.position_culled(pos) and self.game.settings.perf_destroyed_units:
self.current_mission.static_group(
country=self.current_mission.country(self.game.player_country),
name="",
_type=utype,
hidden=True,
@@ -277,307 +229,209 @@ class Operation:
dead=True,
)
@classmethod
def generate(cls) -> UnitMap:
"""Build the final Mission to be exported"""
cls.create_unit_map()
cls.create_radio_registries()
# Set mission time and weather conditions.
EnvironmentGenerator(cls.current_mission, cls.game.conditions).generate()
cls._generate_ground_units()
cls._generate_transports()
cls._generate_destroyed_units()
cls._generate_air_units()
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
cls._generate_ground_conflicts()
# Air Support (Tanker & Awacs)
airsupportgen = AirSupportConflictGenerator(
self.current_mission, self.conflict, self.game, radio_registry,
tacan_registry)
airsupportgen.generate(self.is_awacs_enabled)
# Triggers
triggersgen = TriggersGenerator(cls.current_mission, cls.game)
triggersgen.generate()
# Generate Activity on the map
airgen = AircraftConflictGenerator(
self.current_mission, self.conflict, self.game.settings, self.game,
radio_registry)
airgen.generate_flights(
self.current_mission.country(self.game.player_country),
self.game.blue_ato,
groundobjectgen.runways
)
airgen.generate_flights(
self.current_mission.country(self.game.enemy_country),
self.game.red_ato,
groundobjectgen.runways
)
# Generate ground units on frontline everywhere
jtacs: List[JtacInfo] = []
for front_line in self.game.theater.conflicts(True):
player_cp = front_line.control_point_a
enemy_cp = front_line.control_point_b
conflict = Conflict.frontline_cas_conflict(self.attacker_name, self.defender_name,
self.current_mission.country(self.attacker_country),
self.current_mission.country(self.defender_country),
player_cp, enemy_cp, self.game.theater)
# Generate frontline ops
player_gp = self.game.ground_planners[player_cp.id].units_per_cp[enemy_cp.id]
enemy_gp = self.game.ground_planners[enemy_cp.id].units_per_cp[player_cp.id]
groundConflictGen = GroundConflictGenerator(self.current_mission, conflict, self.game, player_gp, enemy_gp, player_cp.stances[enemy_cp.id])
groundConflictGen.generate()
jtacs.extend(groundConflictGen.jtacs)
# Setup combined arms parameters
cls.current_mission.groundControl.pilot_can_control_vehicles = cls.ca_slots > 0
cls.current_mission.groundControl.blue_tactical_commander = cls.ca_slots
cls.current_mission.groundControl.blue_observer = 1
self.current_mission.groundControl.pilot_can_control_vehicles = self.ca_slots > 0
if self.game.player_country in [country.name for country in self.current_mission.coalition["blue"].countries.values()]:
self.current_mission.groundControl.blue_tactical_commander = self.ca_slots
else:
self.current_mission.groundControl.red_tactical_commander = self.ca_slots
# Triggers
triggersgen = TriggersGenerator(self.current_mission, self.conflict,
self.game)
triggersgen.generate()
# Options
forcedoptionsgen = ForcedOptionsGenerator(cls.current_mission, cls.game)
forcedoptionsgen = ForcedOptionsGenerator(self.current_mission,
self.conflict, self.game)
forcedoptionsgen.generate()
# Generate Visuals Smoke Effects
visualgen = VisualGenerator(cls.current_mission, cls.game)
if cls.game.settings.perf_smoke_gen:
visualgen = VisualGenerator(self.current_mission, self.conflict,
self.game)
if self.game.settings.perf_smoke_gen:
visualgen.generate()
cls.generate_lua(cls.airgen, cls.airsupportgen, cls.jtacs)
luaData = {}
luaData["AircraftCarriers"] = {}
luaData["Tankers"] = {}
luaData["AWACs"] = {}
luaData["JTACs"] = {}
luaData["TargetPoints"] = {}
# Inject Plugins Lua Scripts and data
cls.plugin_scripts.clear()
for plugin in LuaPluginManager.plugins():
if plugin.enabled:
plugin.inject_scripts(cls)
plugin.inject_configuration(cls)
self.assign_channels_to_flights(airgen.flights,
airsupportgen.air_support)
cls.assign_channels_to_flights(
cls.airgen.flights, cls.airsupportgen.air_support
)
cls.notify_info_generators(
cls.groundobjectgen, cls.airsupportgen, cls.jtacs, cls.airgen
)
cls.reset_naming_ids()
return cls.unit_map
kneeboard_generator = KneeboardGenerator(self.current_mission)
for dynamic_runway in groundobjectgen.runways.values():
self.briefinggen.add_dynamic_runway(dynamic_runway)
@classmethod
def _generate_air_units(cls) -> None:
"""Generate the air units for the Operation"""
# Air Support (Tanker & Awacs)
assert cls.radio_registry and cls.tacan_registry
cls.airsupportgen = AirSupportConflictGenerator(
cls.current_mission,
cls.air_conflict(),
cls.game,
cls.radio_registry,
cls.tacan_registry,
)
cls.airsupportgen.generate()
# Generate Aircraft Activity on the map
cls.airgen = AircraftConflictGenerator(
cls.current_mission,
cls.game.settings,
cls.game,
cls.radio_registry,
cls.tacan_registry,
cls.unit_map,
air_support=cls.airsupportgen.air_support,
)
cls.airgen.clear_parking_slots()
cls.airgen.generate_flights(
cls.current_mission.country(cls.game.player_country),
cls.game.blue_ato,
cls.groundobjectgen.runways,
)
cls.airgen.generate_flights(
cls.current_mission.country(cls.game.enemy_country),
cls.game.red_ato,
cls.groundobjectgen.runways,
)
cls.airgen.spawn_unused_aircraft(
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
)
@classmethod
def _generate_ground_conflicts(cls) -> None:
"""For each frontline in the Operation, generate the ground conflicts and JTACs"""
cls.jtacs = []
for front_line in cls.game.theater.conflicts():
player_cp = front_line.blue_cp
enemy_cp = front_line.red_cp
conflict = Conflict.frontline_cas_conflict(
cls.game.player_faction.name,
cls.game.enemy_faction.name,
cls.current_mission.country(cls.game.player_country),
cls.current_mission.country(cls.game.enemy_country),
front_line,
cls.game.theater,
)
# Generate frontline ops
player_gp = cls.game.ground_planners[player_cp.id].units_per_cp[enemy_cp.id]
enemy_gp = cls.game.ground_planners[enemy_cp.id].units_per_cp[player_cp.id]
ground_conflict_gen = GroundConflictGenerator(
cls.current_mission,
conflict,
cls.game,
player_gp,
enemy_gp,
player_cp.stances[enemy_cp.id],
cls.unit_map,
)
ground_conflict_gen.generate()
cls.jtacs.extend(ground_conflict_gen.jtacs)
@classmethod
def _generate_transports(cls) -> None:
"""Generates convoys for unit transfers by road."""
ConvoyGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
CargoShipGenerator(cls.current_mission, cls.game, cls.unit_map).generate()
@classmethod
def reset_naming_ids(cls) -> None:
namegen.reset_numbers()
@classmethod
def generate_lua(
cls,
airgen: AircraftConflictGenerator,
airsupportgen: AirSupportConflictGenerator,
jtacs: List[JtacInfo],
) -> None:
# TODO: Refactor this
luaData = {
"AircraftCarriers": {},
"Tankers": {},
"AWACs": {},
"JTACs": {},
"TargetPoints": {},
"RedAA": {},
"BlueAA": {},
} # type: ignore
for i, tanker in enumerate(airsupportgen.air_support.tankers):
luaData["Tankers"][i] = {
"dcsGroupName": tanker.group_name,
for tanker in airsupportgen.air_support.tankers:
self.briefinggen.add_tanker(tanker)
kneeboard_generator.add_tanker(tanker)
luaData["Tankers"][tanker.callsign] = {
"dcsGroupName": tanker.dcsGroupName,
"callsign": tanker.callsign,
"variant": tanker.variant,
"radio": tanker.freq.mhz,
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name,
"tacan": str(tanker.tacan.number) + tanker.tacan.band.name
}
for i, awacs in enumerate(airsupportgen.air_support.awacs):
luaData["AWACs"][i] = {
"dcsGroupName": awacs.group_name,
"callsign": awacs.callsign,
"radio": awacs.freq.mhz,
}
if self.is_awacs_enabled:
for awacs in airsupportgen.air_support.awacs:
self.briefinggen.add_awacs(awacs)
kneeboard_generator.add_awacs(awacs)
luaData["AWACs"][awacs.callsign] = {
"dcsGroupName": awacs.dcsGroupName,
"callsign": awacs.callsign,
"radio": awacs.freq.mhz
}
for i, jtac in enumerate(jtacs):
luaData["JTACs"][i] = {
"dcsGroupName": jtac.group_name,
for jtac in jtacs:
self.briefinggen.add_jtac(jtac)
kneeboard_generator.add_jtac(jtac)
luaData["JTACs"][jtac.callsign] = {
"dcsGroupName": jtac.dcsGroupName,
"callsign": jtac.callsign,
"zone": jtac.region,
"dcsUnit": jtac.unit_name,
"laserCode": jtac.code,
"laserCode": jtac.code
}
flight_count = 0
for flight in airgen.flights:
if flight.friendly and flight.flight_type in [
FlightType.ANTISHIP,
FlightType.DEAD,
FlightType.SEAD,
FlightType.STRIKE,
]:
flightType = str(flight.flight_type)
self.briefinggen.add_flight(flight)
kneeboard_generator.add_flight(flight)
if flight.friendly and flight.flight_type in [FlightType.ANTISHIP, FlightType.DEAD, FlightType.SEAD, FlightType.STRIKE]:
flightType = flight.flight_type.name
flightTarget = flight.package.target
if flightTarget:
flightTargetName = None
flightTargetType = None
if isinstance(flightTarget, TheaterGroundObject):
if hasattr(flightTarget, 'obj_name'):
flightTargetName = flightTarget.obj_name
flightTargetType = (
flightType + f" TGT ({flightTarget.category})"
)
elif hasattr(flightTarget, "name"):
flightTargetType = flightType + f" TGT ({flightTarget.category})"
elif hasattr(flightTarget, 'name'):
flightTargetName = flightTarget.name
flightTargetType = flightType + " TGT (Airbase)"
luaData["TargetPoints"][flight_count] = {
luaData["TargetPoints"][flightTargetName] = {
"name": flightTargetName,
"type": flightTargetType,
"position": {
"x": flightTarget.position.x,
"y": flightTarget.position.y,
},
"position": { "x": flightTarget.position.x, "y": flightTarget.position.y}
}
flight_count += 1
for cp in cls.game.theater.controlpoints:
for ground_object in cp.ground_objects:
if ground_object.might_have_aa and not ground_object.is_dead:
for g in ground_object.groups:
threat_range = ground_object.threat_range(g)
self.briefinggen.generate()
kneeboard_generator.generate()
if not threat_range:
continue
faction = "BlueAA" if cp.captured else "RedAA"
luaData[faction][g.name] = {
"name": ground_object.name,
"range": threat_range.meters,
"position": {
"x": ground_object.position.x,
"y": ground_object.position.y,
},
}
# set a LUA table with data from Liberation that we want to set
# at the moment it contains Liberation's install path, and an overridable definition for the JTACAutoLase function
# later, we'll add data about the units and points having been generated, in order to facilitate the configuration of the plugin lua scripts
state_location = "[[" + os.path.abspath(".") + "]]"
lua = (
"""
-- setting configuration table
env.info("DCSLiberation|: setting configuration table")
lua = """
-- setting configuration table
env.info("DCSLiberation|: setting configuration table")
-- all data in this table is overridable.
dcsLiberation = {}
-- all data in this table is overridable.
dcsLiberation = {}
-- the base location for state.json; if non-existent, it'll be replaced with LIBERATION_EXPORT_DIR, TEMP, or DCS working directory
dcsLiberation.installPath="""
+ state_location
+ """
-- the base location for state.json; if non-existent, it'll be replaced with LIBERATION_EXPORT_DIR, TEMP, or DCS working directory
dcsLiberation.installPath=""" + state_location + """
"""
)
"""
# Process the tankers
lua += """
-- list the tankers generated by Liberation
dcsLiberation.Tankers = {
"""
-- list the tankers generated by Liberation
dcsLiberation.Tankers = {
"""
for key in luaData["Tankers"]:
data = luaData["Tankers"][key]
dcsGroupName = data["dcsGroupName"]
dcsGroupName= data["dcsGroupName"]
callsign = data["callsign"]
variant = data["variant"]
tacan = data["tacan"]
radio = data["radio"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', variant='{variant}', tacan='{tacan}', radio='{radio}' }}, \n"
# lua += f" {{name='{dcsGroupName}', description='{callsign} ({variant})', information='Tacan:{tacan} Radio:{radio}' }}, \n"
#lua += f" {{name='{dcsGroupName}', description='{callsign} ({variant})', information='Tacan:{tacan} Radio:{radio}' }}, \n"
lua += "}"
# Process the AWACSes
lua += """
-- list the AWACs generated by Liberation
dcsLiberation.AWACs = {
"""
-- list the AWACs generated by Liberation
dcsLiberation.AWACs = {
"""
for key in luaData["AWACs"]:
data = luaData["AWACs"][key]
dcsGroupName = data["dcsGroupName"]
dcsGroupName= data["dcsGroupName"]
callsign = data["callsign"]
radio = data["radio"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', radio='{radio}' }}, \n"
# lua += f" {{name='{dcsGroupName}', description='{callsign} (AWACS)', information='Radio:{radio}' }}, \n"
#lua += f" {{name='{dcsGroupName}', description='{callsign} (AWACS)', information='Radio:{radio}' }}, \n"
lua += "}"
# Process the JTACs
lua += """
-- list the JTACs generated by Liberation
dcsLiberation.JTACs = {
"""
-- list the JTACs generated by Liberation
dcsLiberation.JTACs = {
"""
for key in luaData["JTACs"]:
data = luaData["JTACs"][key]
dcsGroupName = data["dcsGroupName"]
dcsGroupName= data["dcsGroupName"]
callsign = data["callsign"]
zone = data["zone"]
laserCode = data["laserCode"]
dcsUnit = data["dcsUnit"]
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone={repr(zone)}, laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
lua += f" {{dcsGroupName='{dcsGroupName}', callsign='{callsign}', zone='{zone}', laserCode='{laserCode}', dcsUnit='{dcsUnit}' }}, \n"
#lua += f" {{name='{dcsGroupName}', description='JTAC {callsign} ', information='Laser:{laserCode}', jtac={laserCode} }}, \n"
lua += "}"
# Process the Target Points
lua += """
-- list the target points generated by Liberation
dcsLiberation.TargetPoints = {
"""
-- list the target points generated by Liberation
dcsLiberation.TargetPoints = {
"""
for key in luaData["TargetPoints"]:
data = luaData["TargetPoints"][key]
name = data["name"]
@@ -585,47 +439,79 @@ class Operation:
positionX = data["position"]["x"]
positionY = data["position"]["y"]
lua += f" {{name='{name}', pointType='{pointType}', positionX='{positionX}', positionY='{positionY}' }}, \n"
# lua += f" {{name='{pointType} {name}', point{{x={positionX}, z={positionY} }} }}, \n"
#lua += f" {{name='{pointType} {name}', point{{x={positionX}, z={positionY} }} }}, \n"
lua += "}"
lua += """
-- list the airbases generated by Liberation
-- dcsLiberation.Airbases = {}
-- list the airbases generated by Liberation
-- dcsLiberation.Airbases = {}
-- list the aircraft carriers generated by Liberation
-- dcsLiberation.Carriers = {}
-- list the aircraft carriers generated by Liberation
-- dcsLiberation.Carriers = {}
-- list the Red AA generated by Liberation
dcsLiberation.RedAA = {
"""
for key in luaData["RedAA"]:
data = luaData["RedAA"][key]
name = data["name"]
radius = data["range"]
positionX = data["position"]["x"]
positionY = data["position"]["y"]
lua += f" {{dcsGroupName='{key}', name='{name}', range='{radius}', positionX='{positionX}', positionY='{positionY}' }}, \n"
lua += "}"
-- later, we'll add more data to the table
lua += """
"""
-- list the Blue AA generated by Liberation
dcsLiberation.BlueAA = {
"""
for key in luaData["BlueAA"]:
data = luaData["BlueAA"][key]
name = data["name"]
radius = data["range"]
positionX = data["position"]["x"]
positionY = data["position"]["y"]
lua += f" {{dcsGroupName='{key}', name='{name}', range='{radius}', positionX='{positionX}', positionY='{positionY}' }}, \n"
lua += "}"
lua += """
"""
trigger = TriggerStart(comment="Set DCS Liberation data")
trigger.add_action(DoScript(String(lua)))
Operation.current_mission.triggerrules.triggers.append(trigger)
self.current_mission.triggerrules.triggers.append(trigger)
# Inject Plugins Lua Scripts and data
for plugin in LuaPluginManager().getPlugins():
plugin.injectScripts(self)
plugin.injectConfiguration(self)
self.assign_channels_to_flights(airgen.flights,
airsupportgen.air_support)
kneeboard_generator = KneeboardGenerator(self.current_mission)
for dynamic_runway in groundobjectgen.runways.values():
self.briefinggen.add_dynamic_runway(dynamic_runway)
for tanker in airsupportgen.air_support.tankers:
self.briefinggen.add_tanker(tanker)
kneeboard_generator.add_tanker(tanker)
if self.is_awacs_enabled:
for awacs in airsupportgen.air_support.awacs:
self.briefinggen.add_awacs(awacs)
kneeboard_generator.add_awacs(awacs)
for jtac in jtacs:
self.briefinggen.add_jtac(jtac)
kneeboard_generator.add_jtac(jtac)
for flight in airgen.flights:
self.briefinggen.add_flight(flight)
kneeboard_generator.add_flight(flight)
self.briefinggen.generate()
kneeboard_generator.generate()
def assign_channels_to_flights(self, flights: List[FlightData],
air_support: AirSupport) -> None:
"""Assigns preset radio channels for client flights."""
for flight in flights:
if not flight.client_units:
continue
self.assign_channels_to_flight(flight, air_support)
def assign_channels_to_flight(self, flight: FlightData,
air_support: AirSupport) -> None:
"""Assigns preset radio channels for a client flight."""
airframe = flight.aircraft_type
try:
aircraft_data = AIRCRAFT_DATA[airframe.id]
except KeyError:
logging.warning(f"No aircraft data for {airframe.id}")
return
if aircraft_data.channel_allocator is not None:
aircraft_data.channel_allocator.assign_channels_for_flight(
flight, air_support
)

View File

@@ -1,48 +1,52 @@
from __future__ import annotations
import logging
import os
import pickle
import shutil
from pathlib import Path
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from game import Game
from typing import Optional
_dcs_saved_game_folder: Optional[str] = None
_file_abs_path = None
def setup(user_folder: str) -> None:
def setup(user_folder: str):
global _dcs_saved_game_folder
_dcs_saved_game_folder = user_folder
if not save_dir().exists():
save_dir().mkdir(parents=True)
_file_abs_path = os.path.join(base_path(), "default.liberation")
def base_path() -> str:
global _dcs_saved_game_folder
assert _dcs_saved_game_folder
return _dcs_saved_game_folder
def save_dir() -> Path:
return Path(base_path()) / "Liberation" / "Saves"
def _save_file() -> str:
return os.path.join(base_path(), "default.liberation")
def _temporary_save_file() -> str:
return str(save_dir() / "tmpsave.liberation")
return os.path.join(base_path(), "tmpsave.liberation")
def _autosave_path() -> str:
return str(save_dir() / "autosave.liberation")
return os.path.join(base_path(), "autosave.liberation")
def _save_file_exists() -> bool:
return os.path.exists(_save_file())
def mission_path_for(name: str) -> str:
return os.path.join(base_path(), "Missions", name)
return os.path.join(base_path(), "Missions", "{}".format(name))
def load_game(path: str) -> Optional[Game]:
def restore_game():
if not _save_file_exists():
return None
with open(_save_file(), "rb") as f:
try:
save = pickle.load(f)
return save
except Exception:
logging.exception("Invalid Save game")
return None
def load_game(path):
with open(path, "rb") as f:
try:
save = pickle.load(f)
@@ -53,7 +57,7 @@ def load_game(path: str) -> Optional[Game]:
return None
def save_game(game: Game) -> bool:
def save_game(game) -> bool:
try:
with open(_temporary_save_file(), "wb") as f:
pickle.dump(game, f)
@@ -64,7 +68,7 @@ def save_game(game: Game) -> bool:
return False
def autosave(game: Game) -> bool:
def autosave(game) -> bool:
"""
Autosave to the autosave location
:param game: Game to save
@@ -77,3 +81,4 @@ def autosave(game: Game) -> bool:
except Exception:
logging.exception("Could not save game")
return False

View File

@@ -1,2 +0,0 @@
from .luaplugin import LuaPlugin
from .manager import LuaPluginManager

View File

@@ -1,187 +0,0 @@
from __future__ import annotations
import json
import logging
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, TYPE_CHECKING, Type
from game.settings import Settings
if TYPE_CHECKING:
from game.operation.operation import Operation
class LuaPluginWorkOrder:
def __init__(
self, parent_mnemonic: str, filename: str, mnemonic: str, disable: bool
) -> None:
self.parent_mnemonic = parent_mnemonic
self.filename = filename
self.mnemonic = mnemonic
self.disable = disable
def work(self, operation: Type[Operation]) -> None:
if self.disable:
operation.bypass_plugin_script(self.mnemonic)
else:
operation.inject_plugin_script(
self.parent_mnemonic, self.filename, self.mnemonic
)
class PluginSettings:
def __init__(self, identifier: str, enabled_by_default: bool) -> None:
self.identifier = identifier
self.enabled_by_default = enabled_by_default
self.settings = Settings()
self.initialize_settings()
def set_settings(self, settings: Settings) -> None:
self.settings = settings
self.initialize_settings()
def initialize_settings(self) -> None:
# Plugin options are saved in the game's Settings, but it's possible for
# plugins to change across loads. If new plugins are added or new
# options added to those plugins, initialize the new settings.
self.settings.initialize_plugin_option(self.identifier, self.enabled_by_default)
@property
def enabled(self) -> bool:
return self.settings.plugin_option(self.identifier)
def set_enabled(self, enabled: bool) -> None:
self.settings.set_plugin_option(self.identifier, enabled)
class LuaPluginOption(PluginSettings):
def __init__(self, identifier: str, name: str, enabled_by_default: bool) -> None:
super().__init__(identifier, enabled_by_default)
self.name = name
@dataclass(frozen=True)
class LuaPluginDefinition:
identifier: str
name: str
present_in_ui: bool
enabled_by_default: bool
options: List[LuaPluginOption]
work_orders: List[LuaPluginWorkOrder]
config_work_orders: List[LuaPluginWorkOrder]
@classmethod
def from_json(cls, name: str, path: Path) -> LuaPluginDefinition:
data = json.loads(path.read_text())
options = []
for option in data.get("specificOptions"):
option_id = option["mnemonic"]
options.append(
LuaPluginOption(
identifier=f"{name}.{option_id}",
name=option.get("nameInUI", name),
enabled_by_default=option.get("defaultValue"),
)
)
work_orders = []
for work_order in data.get("scriptsWorkOrders"):
work_orders.append(
LuaPluginWorkOrder(
name,
work_order.get("file"),
work_order["mnemonic"],
work_order.get("disable", False),
)
)
config_work_orders = []
for work_order in data.get("configurationWorkOrders"):
config_work_orders.append(
LuaPluginWorkOrder(
name,
work_order.get("file"),
work_order["mnemonic"],
work_order.get("disable", False),
)
)
return cls(
identifier=name,
name=data["nameInUI"],
present_in_ui=not data.get("skipUI", False),
enabled_by_default=data.get("defaultValue", False),
options=options,
work_orders=work_orders,
config_work_orders=config_work_orders,
)
class LuaPlugin(PluginSettings):
def __init__(self, definition: LuaPluginDefinition) -> None:
self.definition = definition
super().__init__(self.definition.identifier, self.definition.enabled_by_default)
@property
def name(self) -> str:
return self.definition.name
@property
def show_in_ui(self) -> bool:
return self.definition.present_in_ui
@property
def options(self) -> List[LuaPluginOption]:
return self.definition.options
@classmethod
def from_json(cls, name: str, path: Path) -> Optional[LuaPlugin]:
try:
definition = LuaPluginDefinition.from_json(name, path)
except KeyError:
logging.exception("Required plugin configuration value missing")
return None
return cls(definition)
def set_settings(self, settings: Settings) -> None:
super().set_settings(settings)
for option in self.definition.options:
option.set_settings(self.settings)
def inject_scripts(self, operation: Type[Operation]) -> None:
for work_order in self.definition.work_orders:
work_order.work(operation)
def inject_configuration(self, operation: Type[Operation]) -> None:
# inject the plugin options
if self.options:
option_decls = []
for option in self.options:
enabled = str(option.enabled).lower()
name = option.identifier
option_decls.append(f" dcsLiberation.plugins.{name} = {enabled}")
joined_options = "\n".join(option_decls)
lua = textwrap.dedent(
f"""\
-- {self.identifier} plugin configuration.
if dcsLiberation then
if not dcsLiberation.plugins then
dcsLiberation.plugins = {{}}
end
dcsLiberation.plugins.{self.identifier} = {{}}
{joined_options}
end
"""
)
operation.inject_lua_trigger(lua, f"{self.identifier} plugin configuration")
for work_order in self.definition.config_work_orders:
work_order.work(operation)

View File

@@ -1,51 +0,0 @@
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional
from game.settings import Settings
from game.plugins.luaplugin import LuaPlugin
class LuaPluginManager:
_plugins_loaded = False
_plugins: Dict[str, LuaPlugin] = {}
@classmethod
def _load_plugins(cls) -> None:
plugins_path = Path("resources/plugins")
path = plugins_path / "plugins.json"
if not path.exists():
raise RuntimeError(f"{path} does not exist. Cannot continue.")
logging.info(f"Reading plugins list from {path}")
data = json.loads(path.read_text())
for name in data:
plugin_path = plugins_path / name / "plugin.json"
if not plugin_path.exists():
raise RuntimeError(
f"Invalid plugin configuration: required plugin {name} "
f"does not exist at {plugin_path}"
)
logging.info(f"Loading plugin {name} from {plugin_path}")
plugin = LuaPlugin.from_json(name, plugin_path)
if plugin is not None:
cls._plugins[name] = plugin
cls._plugins_loaded = True
@classmethod
def _get_plugins(cls) -> Dict[str, LuaPlugin]:
if not cls._plugins_loaded:
cls._load_plugins()
return cls._plugins
@classmethod
def plugins(cls) -> List[LuaPlugin]:
return list(cls._get_plugins().values())
@classmethod
def load_settings(cls, settings: Settings) -> None:
for plugin in cls.plugins():
plugin.set_settings(settings)

View File

@@ -1,17 +0,0 @@
from __future__ import annotations
from dcs import Point
class PointWithHeading(Point):
def __init__(self) -> None:
super(PointWithHeading, self).__init__(0, 0)
self.heading = 0
@staticmethod
def from_point(point: Point, heading: int) -> PointWithHeading:
p = PointWithHeading()
p.x = point.x
p.y = point.y
p.heading = heading
return p

View File

@@ -1,9 +0,0 @@
from typing import Protocol
from dcs import Point
class Positioned(Protocol):
@property
def position(self) -> Point:
raise NotImplementedError

View File

@@ -1,369 +0,0 @@
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Iterator, List, Optional, TYPE_CHECKING, Tuple
from game import db
from game.data.groundunitclass import GroundUnitClass
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.theater import ControlPoint, MissionTarget
from game.utils import Distance
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
if TYPE_CHECKING:
from game import Game
FRONTLINE_RESERVES_FACTOR = 1.3
@dataclass(frozen=True)
class AircraftProcurementRequest:
near: MissionTarget
range: Distance
task_capability: FlightType
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:
def __init__(
self,
game: Game,
for_player: bool,
faction: Faction,
manage_runways: bool,
manage_front_line: bool,
manage_aircraft: bool,
) -> None:
self.game = game
self.is_player = for_player
self.air_wing = game.air_wing_for(for_player)
self.faction = faction
self.manage_runways = manage_runways
self.manage_front_line = manage_front_line
self.manage_aircraft = manage_aircraft
self.threat_zones = self.game.threat_zone_for(not self.is_player)
def calculate_ground_unit_budget_share(self) -> float:
armor_investment = 0
aircraft_investment = 0
# faction has no ground units
if (
len(self.faction.artillery_units) == 0
and len(self.faction.frontline_units) == 0
):
return 0
# faction has no planes
if len(self.faction.aircrafts) == 0:
return 1
for cp in self.owned_points:
cp_ground_units = cp.allocated_ground_units(self.game.transfers)
armor_investment += cp_ground_units.total_value
cp_aircraft = cp.allocated_aircraft(self.game)
aircraft_investment += cp_aircraft.total_value
total_investment = aircraft_investment + armor_investment
if total_investment == 0:
# Turn 0 or all units were destroyed. Either way, split 30/70.
return 0.3
# the more planes we have, the more ground units we want and vice versa
ground_unit_share = aircraft_investment / total_investment
if ground_unit_share > 1.0:
raise ValueError
return ground_unit_share
def spend_budget(self, budget: float) -> float:
if self.manage_runways:
budget = self.repair_runways(budget)
if self.manage_front_line:
armor_budget = budget * self.calculate_ground_unit_budget_share()
budget -= 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:
budget = self.purchase_aircraft(budget)
return budget
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/dcs-liberation/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/dcs-liberation/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 += aircraft.price
return total
def repair_runways(self, budget: float) -> float:
for control_point in self.owned_points:
if budget < db.RUNWAY_REPAIR_COST:
break
if control_point.runway_can_be_repaired:
control_point.begin_runway_repair()
budget -= db.RUNWAY_REPAIR_COST
if self.is_player:
self.game.message(
"OPFOR has begun repairing the runway at " f"{control_point}"
)
else:
self.game.message(
"We have begun repairing the runway at " f"{control_point}"
)
return budget
def affordable_ground_unit_of_class(
self, budget: float, unit_class: GroundUnitClass
) -> Optional[GroundUnitType]:
faction_units = set(self.faction.frontline_units) | set(
self.faction.artillery_units
)
of_class = {u for u in faction_units if u.unit_class is unit_class}
# faction has no access to needed unit type, take a random unit
if not of_class:
of_class = faction_units
affordable_units = [u for u in of_class if u.price <= budget]
if not affordable_units:
return None
return random.choice(affordable_units)
def reinforce_front_line(self, budget: float) -> float:
if not self.faction.frontline_units and not self.faction.artillery_units:
return budget
# TODO: Attempt to transfer from reserves.
while budget > 0:
cp = self.ground_reinforcement_candidate()
if cp is None:
break
most_needed_type = self.most_needed_unit_class(cp)
unit = self.affordable_ground_unit_of_class(budget, most_needed_type)
if unit is None:
# Can't afford any more units.
break
budget -= unit.price
cp.pending_unit_deliveries.order({unit: 1})
return budget
def most_needed_unit_class(self, cp: ControlPoint) -> GroundUnitClass:
worst_balanced: Optional[GroundUnitClass] = None
worst_fulfillment = math.inf
for unit_class in GroundUnitClass:
if not self.faction.has_access_to_unittype(unit_class):
continue
current_ratio = self.cost_ratio_of_ground_unit(cp, unit_class)
desired_ratio = (
self.faction.doctrine.ground_unit_procurement_ratios.for_unit_class(
unit_class
)
)
if not desired_ratio:
continue
if current_ratio >= desired_ratio:
continue
fulfillment = current_ratio / desired_ratio
if fulfillment < worst_fulfillment:
worst_fulfillment = fulfillment
worst_balanced = unit_class
if worst_balanced is None:
return GroundUnitClass.Tank
return worst_balanced
def _affordable_aircraft_for_task(
self,
task: FlightType,
airbase: ControlPoint,
number: int,
max_price: float,
) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(task):
if unit not in self.faction.aircrafts:
continue
if unit.price * number > max_price:
continue
if not airbase.can_operate(unit):
continue
for squadron in self.air_wing.squadrons_for(unit):
if task in squadron.auto_assignable_mission_types:
break
else:
continue
# Affordable, compatible, and we have a squadron capable of the task. 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(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
return self._affordable_aircraft_for_task(
request.task_capability, airbase, request.number, budget
)
def fulfill_aircraft_request(
self, request: AircraftProcurementRequest, budget: float
) -> Tuple[float, bool]:
for airbase in self.best_airbases_for(request):
unit = self.affordable_aircraft_for(request, airbase, budget)
if unit is None:
# Can't afford any aircraft capable of performing the
# required mission that can operate from this airbase. We
# might be able to afford aircraft at other airbases though,
# in the case where the airbase we attempted to use is only
# able to operate expensive aircraft.
continue
budget -= unit.price * request.number
airbase.pending_unit_deliveries.order({unit: request.number})
return budget, True
return budget, False
def purchase_aircraft(self, budget: float) -> float:
for request in self.game.procurement_requests_for(self.is_player):
if not list(self.best_airbases_for(request)):
# No airbases in range of this request. Skip it.
continue
budget, fulfilled = self.fulfill_aircraft_request(request, budget)
if not fulfilled:
# The request was not fulfilled because we could not afford any suitable
# aircraft. Rather than continuing, which could proceed to buy tons of
# cheap escorts that will never allow us to plan a strike package, stop
# buying so we can save the budget until a turn where we *can* afford to
# fill the package.
break
return budget
@property
def owned_points(self) -> List[ControlPoint]:
if self.is_player:
return self.game.theater.player_points()
else:
return self.game.theater.enemy_points()
def best_airbases_for(
self, request: AircraftProcurementRequest
) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.operational_airfields_within(request.range):
if not cp.is_friendly(self.is_player):
continue
if cp.unclaimed_parking(self.game) < request.number:
continue
if self.threat_zones.threatened(cp.position):
threatened.append(cp)
yield cp
yield from threatened
def ground_reinforcement_candidate(self) -> Optional[ControlPoint]:
worst_supply = math.inf
understaffed: Optional[ControlPoint] = None
# Prefer to buy front line units at active front lines that are not
# already overloaded.
for cp in self.owned_points:
if not cp.has_active_frontline:
continue
if not cp.has_ground_unit_source(self.game):
# No source of ground units, so can't buy anything.
continue
purchase_target = cp.frontline_unit_count_limit * FRONTLINE_RESERVES_FACTOR
allocated = cp.allocated_ground_units(self.game.transfers)
if allocated.total >= purchase_target:
# Control point is already sufficiently defended.
continue
if allocated.total < worst_supply:
worst_supply = allocated.total
understaffed = cp
if understaffed is not None:
return understaffed
# Otherwise buy reserves, but don't exceed the amount defined in the settings.
# These units do not exist in the world until the CP becomes
# 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 cp.is_global:
continue
if not cp.can_recruit_ground_units(self.game):
continue
allocated = cp.allocated_ground_units(self.game.transfers)
if allocated.total >= self.game.settings.reserves_procurement_target:
continue
if allocated.total < worst_supply:
worst_supply = allocated.total
understaffed = cp
return understaffed
def cost_ratio_of_ground_unit(
self, control_point: ControlPoint, unit_class: GroundUnitClass
) -> float:
allocations = control_point.allocated_ground_units(self.game.transfers)
class_cost = 0
total_cost = 0
for unit_type, count in allocations.all.items():
cost = unit_type.price * count
total_cost += cost
if unit_type.unit_class is unit_class:
class_cost += cost
if not total_cost:
return 0
return class_cost / total_cost

View File

@@ -1,41 +0,0 @@
from __future__ import annotations
import logging
import timeit
from collections import defaultdict
from contextlib import contextmanager
from datetime import timedelta
from types import TracebackType
from typing import Iterator, Optional, Type
@contextmanager
def logged_duration(event: str) -> Iterator[None]:
start = timeit.default_timer()
yield
end = timeit.default_timer()
logging.debug("%s took %s", event, timedelta(seconds=end - start))
class MultiEventTracer:
def __init__(self) -> None:
self.events: dict[str, timedelta] = defaultdict(timedelta)
def __enter__(self) -> MultiEventTracer:
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
for event, duration in self.events.items():
logging.debug("%s took %s", event, duration)
@contextmanager
def trace(self, event: str) -> Iterator[None]:
start = timeit.default_timer()
yield
end = timeit.default_timer()
self.events[event] += timedelta(seconds=end - start)

View File

@@ -1,298 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from gen import FlightData, AirSupport
class RadioChannelAllocator:
"""Base class for radio channel allocators."""
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
"""Assigns mission frequencies to preset channels for the flight."""
raise NotImplementedError
@classmethod
def from_cfg(cls, cfg: dict[str, Any]) -> RadioChannelAllocator:
return cls()
@classmethod
def name(cls) -> str:
raise NotImplementedError
@dataclass(frozen=True)
class CommonRadioChannelAllocator(RadioChannelAllocator):
"""Radio channel allocator suitable for most aircraft.
Most of the aircraft with preset channels available have one or more radios
with 20 or more channels available (typically per-radio, but this is not the
case for the JF-17).
"""
#: Index of the radio used for intra-flight communications. Matches the
#: index of the panel_radio field of the pydcs.dcs.planes object.
inter_flight_radio_index: Optional[int]
#: Index of the radio used for intra-flight communications. Matches the
#: index of the panel_radio field of the pydcs.dcs.planes object.
intra_flight_radio_index: Optional[int]
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
if self.intra_flight_radio_index is not None:
flight.assign_channel(
self.intra_flight_radio_index, 1, flight.intra_flight_channel
)
if self.inter_flight_radio_index is None:
return
# For cases where the inter-flight and intra-flight radios share presets
# (the JF-17 only has one set of channels, even though it can use two
# channels simultaneously), start assigning inter-flight channels at 2.
radio_id = self.inter_flight_radio_index
if self.intra_flight_radio_index == radio_id:
first_channel = 2
else:
first_channel = 1
last_channel = flight.num_radio_channels(radio_id)
channel_alloc = iter(range(first_channel, last_channel + 1))
if flight.departure.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.departure.atc)
# TODO: If there ever are multiple AWACS, limit to mission relevant.
for awacs in air_support.awacs:
flight.assign_channel(radio_id, next(channel_alloc), awacs.freq)
if flight.arrival != flight.departure and flight.arrival.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.arrival.atc)
try:
# TODO: Skip incompatible tankers.
for tanker in air_support.tankers:
flight.assign_channel(radio_id, next(channel_alloc), tanker.freq)
if flight.divert is not None and flight.divert.atc is not None:
flight.assign_channel(radio_id, next(channel_alloc), flight.divert.atc)
except StopIteration:
# Any remaining channels are nice-to-haves, but not necessary for
# the few aircraft with a small number of channels available.
pass
@classmethod
def from_cfg(cls, cfg: dict[str, Any]) -> CommonRadioChannelAllocator:
return CommonRadioChannelAllocator(
inter_flight_radio_index=cfg["inter_flight_radio_index"],
intra_flight_radio_index=cfg["intra_flight_radio_index"],
)
@classmethod
def name(cls) -> str:
return "common"
@dataclass(frozen=True)
class NoOpChannelAllocator(RadioChannelAllocator):
"""Channel allocator for aircraft that don't support preset channels."""
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
pass
@classmethod
def name(cls) -> str:
return "noop"
@dataclass(frozen=True)
class FarmerRadioChannelAllocator(RadioChannelAllocator):
"""Preset channel allocator for the MiG-19P."""
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
# The Farmer only has 6 preset channels. It also only has a VHF radio,
# and currently our ATC data and AWACS are only in the UHF band.
radio_id = 1
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
# TODO: Assign 4-6 to VHF frequencies of departure, arrival, and divert.
# TODO: Assign 2 and 3 to AWACS if it is VHF.
@classmethod
def name(cls) -> str:
return "farmer"
@dataclass(frozen=True)
class ViggenRadioChannelAllocator(RadioChannelAllocator):
"""Preset channel allocator for the AJS37."""
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
# The Viggen's preset channels are handled differently from other
# aircraft. The aircraft automatically configures channels for every
# allied flight in the game (including AWACS) and for every airfield. As
# such, we don't need to allocate any of those. There are seven presets
# we can modify, however: three channels for the main radio intended for
# communication with wingmen, and four emergency channels for the backup
# radio. We'll set the first channel of the main radio to the
# intra-flight channel, and the first three emergency channels to each
# of the flight plan's airfields. The fourth emergency channel is always
# the guard channel.
radio_id = 1
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
if flight.departure.atc is not None:
flight.assign_channel(radio_id, 4, flight.departure.atc)
if flight.arrival.atc is not None:
flight.assign_channel(radio_id, 5, flight.arrival.atc)
# TODO: Assign divert to 6 when we support divert airfields.
@classmethod
def name(cls) -> str:
return "viggen"
@dataclass(frozen=True)
class SCR522RadioChannelAllocator(RadioChannelAllocator):
"""Preset channel allocator for the SCR522 WW2 radios. (4 channels)"""
def assign_channels_for_flight(
self, flight: FlightData, air_support: AirSupport
) -> None:
radio_id = 1
flight.assign_channel(radio_id, 1, flight.intra_flight_channel)
if flight.departure.atc is not None:
flight.assign_channel(radio_id, 2, flight.departure.atc)
if flight.arrival.atc is not None:
flight.assign_channel(radio_id, 3, flight.arrival.atc)
# TODO : Some GCI on Channel 4 ?
@classmethod
def name(cls) -> str:
return "SCR-522"
class ChannelNamer:
"""Base class allowing channel name customization per-aircraft.
Most aircraft will want to customize this behavior, but the default is
reasonable for any aircraft with numbered radios.
"""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
"""Returns the name of the channel for the given radio and channel."""
return f"COMM{radio_id} Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "default"
class SingleRadioChannelNamer(ChannelNamer):
"""Channel namer for the aircraft with only a single radio.
Aircraft like the MiG-19P and the MiG-21bis only have a single radio, so
it's not necessary for us to name the radio when naming the channel.
"""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
return f"Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "single"
class HueyChannelNamer(ChannelNamer):
"""Channel namer for the UH-1H."""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
return f"COM3 Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "huey"
class MirageChannelNamer(ChannelNamer):
"""Channel namer for the M-2000."""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
radio_name = ["V/UHF", "UHF"][radio_id - 1]
return f"{radio_name} Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "mirage"
class TomcatChannelNamer(ChannelNamer):
"""Channel namer for the F-14."""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
radio_name = ["UHF", "VHF/UHF"][radio_id - 1]
return f"{radio_name} Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "tomcat"
class ViggenChannelNamer(ChannelNamer):
"""Channel namer for the AJS37."""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
if channel_id >= 4:
channel_letter = "EFGH"[channel_id - 4]
return f"FR 24 {channel_letter}"
return f"FR 22 Special {channel_id}"
@classmethod
def name(cls) -> str:
return "viggen"
class ViperChannelNamer(ChannelNamer):
"""Channel namer for the F-16."""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
return f"COM{radio_id} Ch {channel_id}"
@classmethod
def name(cls) -> str:
return "viper"
class SCR522ChannelNamer(ChannelNamer):
"""
Channel namer for P-51 & P-47D
"""
@staticmethod
def channel_name(radio_id: int, channel_id: int) -> str:
if channel_id > 3:
return "?"
else:
return f"Button " + "ABCD"[channel_id - 1]
@classmethod
def name(cls) -> str:
return "SCR-522"

View File

@@ -1,48 +0,0 @@
"""Tools for aiding in save compat removal after compatibility breaks."""
from collections import Callable
from typing import TypeVar
from game.version import MAJOR_VERSION
ReturnT = TypeVar("ReturnT")
class DeprecatedSaveCompatError(RuntimeError):
def __init__(self, function_name: str) -> None:
super().__init__(
f"{function_name} has save compat code for a different major version."
)
def has_save_compat_for(
major: int,
) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
"""Declares a function or method as having save compat code for a given version.
If the function has save compatibility for the current major version, there is no
change in behavior.
If the function has save compatibility for a *different* (future or past) major
version, DeprecatedSaveCompatError will be raised during startup. Since a break in
save compatibility is the definition of a major version break, there's no need to
keep around old save compat code; it only serves to mask initialization bugs.
Args:
major: The major version for which the decorated function has save
compatibility.
Returns:
The decorated function or method.
Raises:
DeprecatedSaveCompatError: The decorated function has save compat code for
another version of liberation, and that code (and the decorator declaring it)
should be removed from this branch.
"""
def decorator(func: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
if major != MAJOR_VERSION:
raise DeprecatedSaveCompatError(func.__name__)
return func
return decorator

View File

@@ -1,89 +0,0 @@
from __future__ import annotations
from game.theater.theatergroundobject import NAME_BY_CATEGORY
from dcs.triggers import TriggerZone
from typing import Iterable, List
class SceneryGroupError(RuntimeError):
"""Error for when there are insufficient conditions to create a SceneryGroup."""
pass
class SceneryGroup:
"""Store information about a scenery objective."""
def __init__(
self, zone_def: TriggerZone, zones: Iterable[TriggerZone], category: str
) -> None:
self.zone_def = zone_def
self.zones = zones
self.position = zone_def.position
self.category = category
@staticmethod
def from_trigger_zones(trigger_zones: Iterable[TriggerZone]) -> List[SceneryGroup]:
"""Define scenery objectives based on their encompassing blue/red circle."""
zone_definitions = []
white_zones = []
scenery_groups = []
# Aggregate trigger zones into different groups based on color.
for zone in trigger_zones:
if SceneryGroup.is_blue(zone):
zone_definitions.append(zone)
if SceneryGroup.is_white(zone):
white_zones.append(zone)
# For each objective definition.
for zone_def in zone_definitions:
zone_def_radius = zone_def.radius
zone_def_position = zone_def.position
zone_def_name = zone_def.name
if len(zone_def.properties) == 0:
raise SceneryGroupError(
"Undefined SceneryGroup category in TriggerZone: " + zone_def_name
)
# Arbitrary campaign design requirement: First property must define the category.
zone_def_category = zone_def.properties[1].get("value").lower()
valid_white_zones = []
for zone in list(white_zones):
if zone.position.distance_to_point(zone_def_position) < zone_def_radius:
valid_white_zones.append(zone)
white_zones.remove(zone)
if len(valid_white_zones) > 0 and zone_def_category in NAME_BY_CATEGORY:
scenery_groups.append(
SceneryGroup(zone_def, valid_white_zones, zone_def_category)
)
elif len(valid_white_zones) == 0:
raise SceneryGroupError(
"No white triggerzones found in: " + zone_def_name
)
elif zone_def_category not in NAME_BY_CATEGORY:
raise SceneryGroupError(
"Incorrect TriggerZone category definition for: "
+ zone_def_name
+ " in campaign definition. TriggerZone category: "
+ zone_def_category
)
return scenery_groups
@staticmethod
def is_blue(zone: TriggerZone) -> bool:
# Blue in RGB is [0 Red], [0 Green], [1 Blue]. Ignore the fourth position: Transparency.
return zone.color[1] == 0 and zone.color[2] == 0 and zone.color[3] == 1
@staticmethod
def is_white(zone: TriggerZone) -> bool:
# White in RGB is [1 Red], [1 Green], [1 Blue]. Ignore the fourth position: Transparency.
return zone.color[1] == 1 and zone.color[2] == 1 and zone.color[3] == 1

View File

@@ -1,110 +1,54 @@
from dataclasses import dataclass, field
from datetime import timedelta
from enum import Enum, unique
from typing import Dict, Optional, Any
from plugin import LuaPluginManager
from dcs.forcedoptions import ForcedOptions
@unique
class AutoAtoBehavior(Enum):
Disabled = "Disabled"
Never = "Never assign player pilots"
Default = "No preference"
Prefer = "Prefer player pilots"
@dataclass
class Settings:
# Difficulty settings
player_skill: str = "Good"
enemy_skill: str = "Average"
ai_pilot_levelling: bool = True
enemy_vehicle_skill: str = "Average"
map_coalition_visibility: ForcedOptions.Views = ForcedOptions.Views.All
labels: str = "Full"
only_player_takeoff: bool = True # Legacy parameter do not use
night_disabled: bool = False
external_views_allowed: bool = True
supercarrier: bool = False
generate_marks: bool = True
manpads: bool = True
version: Optional[str] = None
player_income_multiplier: float = 1.0
enemy_income_multiplier: float = 1.0
def __init__(self):
# Generator settings
self.inverted = False
self.do_not_generate_carrier = False # TODO : implement
self.do_not_generate_lha = False # TODO : implement
self.do_not_generate_player_navy = True # TODO : implement
self.do_not_generate_enemy_navy = True # TODO : implement
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = False
# Difficulty settings
self.player_skill = "Good"
self.enemy_skill = "Average"
self.enemy_vehicle_skill = "Average"
self.map_coalition_visibility = "All Units"
self.labels = "Full"
self.only_player_takeoff = True # Legacy parameter do not use
self.night_disabled = False
self.external_views_allowed = True
self.supercarrier = False
self.multiplier = 1
self.generate_marks = True
self.sams = True # Legacy parameter do not use
self.cold_start = False # Legacy parameter do not use
self.version = None
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = 12
# Performance oriented
self.perf_red_alert_state = True
self.perf_smoke_gen = True
self.perf_artillery = True
self.perf_moving_units = True
self.perf_infantry = True
self.perf_ai_parking_start = True
self.perf_destroyed_units = True
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = 4
# Performance culling
self.perf_culling = False
self.perf_culling_distance = 100
default_start_type: str = "Cold"
# LUA Plugins system
self.plugins = {}
for plugin in LuaPluginManager().getPlugins():
plugin.setSettings(self)
# Mission specific
desired_player_mission_duration: timedelta = timedelta(minutes=60)
# Campaign management
automate_runway_repair: bool = False
automate_front_line_reinforcements: bool = False
automate_aircraft_reinforcements: bool = False
restrict_weapons_by_date: bool = False
disable_legacy_aewc: bool = True
disable_legacy_tanker: bool = True
generate_dark_kneeboard: bool = False
invulnerable_player_pilots: bool = True
auto_ato_behavior: AutoAtoBehavior = AutoAtoBehavior.Default
auto_ato_player_missions_asap: bool = True
# Cheating
self.show_red_ato = False
# Performance oriented
perf_red_alert_state: bool = True
perf_smoke_gen: bool = True
perf_smoke_spacing = 1600
perf_artillery: bool = True
perf_moving_units: bool = True
perf_infantry: bool = True
perf_destroyed_units: bool = True
reserves_procurement_target: int = 10
# Performance culling
perf_culling: bool = False
perf_culling_distance: int = 100
perf_do_not_cull_carrier = True
# LUA Plugins system
plugins: Dict[str, bool] = field(default_factory=dict)
# Cheating
show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
never_delay_player_flights: bool = False
@staticmethod
def plugin_settings_key(identifier: str) -> str:
return f"plugins.{identifier}"
def initialize_plugin_option(self, identifier: str, default_value: bool) -> None:
try:
self.plugin_option(identifier)
except KeyError:
self.set_plugin_option(identifier, default_value)
def plugin_option(self, identifier: str) -> bool:
return self.plugins[self.plugin_settings_key(identifier)]
def set_plugin_option(self, identifier: str, enabled: bool) -> None:
self.plugins[self.plugin_settings_key(identifier)] = enabled
def __setstate__(self, state: dict[str, Any]) -> None:
def __setstate__(self, state) -> None:
# __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a

View File

@@ -1,456 +0,0 @@
from __future__ import annotations
import itertools
import logging
import random
from collections import defaultdict
from dataclasses import dataclass, field
from enum import unique, Enum
from pathlib import Path
from typing import (
Tuple,
TYPE_CHECKING,
Optional,
Iterator,
Sequence,
Any,
)
import yaml
from faker import Faker
from game.dcs.aircrafttype import AircraftType
from game.settings import AutoAtoBehavior
if TYPE_CHECKING:
from game import Game
from gen.flights.flight import FlightType
@dataclass
class PilotRecord:
missions_flown: int = field(default=0)
@unique
class PilotStatus(Enum):
Active = "Active"
OnLeave = "On leave"
Dead = "Dead"
@dataclass
class Pilot:
name: str
player: bool = field(default=False)
status: PilotStatus = field(default=PilotStatus.Active)
record: PilotRecord = field(default_factory=PilotRecord)
@property
def alive(self) -> bool:
return self.status is not PilotStatus.Dead
@property
def on_leave(self) -> bool:
return self.status is PilotStatus.OnLeave
def send_on_leave(self) -> None:
if self.status is not PilotStatus.Active:
raise RuntimeError("Only active pilots may be sent on leave")
self.status = PilotStatus.OnLeave
def return_from_leave(self) -> None:
if self.status is not PilotStatus.OnLeave:
raise RuntimeError("Only pilots on leave may be returned from leave")
self.status = PilotStatus.Active
def kill(self) -> None:
self.status = PilotStatus.Dead
@classmethod
def random(cls, faker: Faker) -> Pilot:
return Pilot(faker.name())
@dataclass
class Squadron:
name: str
nickname: Optional[str]
country: str
role: str
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
#: The pool of pilots that have not yet been assigned to the squadron. This only
#: happens when a preset squadron defines more preset pilots than the squadron limit
#: allows. This pool will be consumed before random pilots are generated.
pilot_pool: list[Pilot]
current_roster: list[Pilot] = field(default_factory=list, init=False, hash=False)
available_pilots: list[Pilot] = field(
default_factory=list, init=False, hash=False, compare=False
)
auto_assignable_mission_types: set[FlightType] = field(
init=False, hash=False, compare=False
)
# We need a reference to the Game so that we can access the Faker without needing to
# persist it to the save game, or having to reconstruct it (it's not cheap) each
# time we create or load a squadron.
game: Game = field(hash=False, compare=False)
player: bool
def __post_init__(self) -> None:
if any(p.status is not PilotStatus.Active for p in self.pilot_pool):
raise ValueError("Squadrons can only be created with active pilots.")
self._recruit_pilots(self.game.settings.squadron_pilot_limit)
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
if self.nickname is None:
return self.name
return f'{self.name} "{self.nickname}"'
@property
def pilot_limits_enabled(self) -> bool:
return self.game.settings.enable_squadron_pilot_limits
def claim_new_pilot_if_allowed(self) -> Optional[Pilot]:
if self.pilot_limits_enabled:
return None
self._recruit_pilots(1)
return self.available_pilots.pop()
def claim_available_pilot(self) -> Optional[Pilot]:
if not self.available_pilots:
return self.claim_new_pilot_if_allowed()
# For opfor, so player/AI option is irrelevant.
if not self.player:
return self.available_pilots.pop()
preference = self.game.settings.auto_ato_behavior
# No preference, so the first pilot is fine.
if preference is AutoAtoBehavior.Default:
return self.available_pilots.pop()
prefer_players = preference is AutoAtoBehavior.Prefer
for pilot in self.available_pilots:
if pilot.player == prefer_players:
self.available_pilots.remove(pilot)
return pilot
# No pilot was found that matched the user's preference.
#
# If they chose to *never* assign players and only players remain in the pool,
# we cannot fill the slot with the available pilots.
#
# If they only *prefer* players and we're out of players, just return an AI
# pilot.
if not prefer_players:
return self.claim_new_pilot_if_allowed()
return self.available_pilots.pop()
def claim_pilot(self, pilot: Pilot) -> None:
if pilot not in self.available_pilots:
raise ValueError(
f"Cannot assign {pilot} to {self} because they are not available"
)
self.available_pilots.remove(pilot)
def return_pilot(self, pilot: Pilot) -> None:
self.available_pilots.append(pilot)
def return_pilots(self, pilots: Sequence[Pilot]) -> None:
# Return in reverse so that returning two pilots and then getting two more
# results in the same ordering. This happens commonly when resetting rosters in
# the UI, when we clear the roster because the UI is updating, then end up
# repopulating the same size flight from the same squadron.
self.available_pilots.extend(reversed(pilots))
def _recruit_pilots(self, count: int) -> None:
new_pilots = self.pilot_pool[:count]
self.pilot_pool = self.pilot_pool[count:]
count -= len(new_pilots)
new_pilots.extend([Pilot(self.faker.name()) for _ in range(count)])
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
replenish_count = min(
self.game.settings.squadron_replenishment_rate,
self._number_of_unfilled_pilot_slots,
)
if replenish_count > 0:
self._recruit_pilots(replenish_count)
def return_all_pilots(self) -> None:
self.available_pilots = list(self.active_pilots)
@staticmethod
def send_on_leave(pilot: Pilot) -> None:
pilot.send_on_leave()
def return_from_leave(self, pilot: Pilot) -> None:
if not self.has_unfilled_pilot_slots:
raise RuntimeError(
f"Cannot return {pilot} from leave because {self} is full"
)
pilot.return_from_leave()
@property
def faker(self) -> Faker:
return self.game.faker_for(self.player)
def _pilots_with_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status == status]
def _pilots_without_status(self, status: PilotStatus) -> list[Pilot]:
return [p for p in self.current_roster if p.status != status]
@property
def active_pilots(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.Active)
@property
def pilots_on_leave(self) -> list[Pilot]:
return self._pilots_with_status(PilotStatus.OnLeave)
@property
def number_of_pilots_including_inactive(self) -> int:
return len(self.current_roster)
@property
def _number_of_unfilled_pilot_slots(self) -> int:
return self.game.settings.squadron_pilot_limit - len(self.active_pilots)
@property
def number_of_available_pilots(self) -> int:
return len(self.available_pilots)
def can_provide_pilots(self, count: int) -> bool:
return not self.pilot_limits_enabled or self.number_of_available_pilots >= count
@property
def has_available_pilots(self) -> bool:
return not self.pilot_limits_enabled or bool(self.available_pilots)
@property
def has_unfilled_pilot_slots(self) -> bool:
return not self.pilot_limits_enabled or self._number_of_unfilled_pilot_slots > 0
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
@classmethod
def from_yaml(cls, path: Path, game: Game, player: bool) -> Squadron:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
from gen.flights.flight import FlightType
with path.open(encoding="utf8") as squadron_file:
data = yaml.safe_load(squadron_file)
name = data["aircraft"]
try:
unit_type = AircraftType.named(name)
except KeyError as ex:
raise KeyError(f"Could not find any aircraft named {name}") from ex
pilots = [Pilot(n, player=False) for n in data.get("pilots", [])]
pilots.extend([Pilot(n, player=True) for n in data.get("players", [])])
mission_types = [FlightType.from_name(n) for n in data["mission_types"]]
tasks = tasks_for_aircraft(unit_type)
for mission_type in list(mission_types):
if mission_type not in tasks:
logging.error(
f"Squadron has mission type {mission_type} but {unit_type} is not "
f"capable of that task: {path}"
)
mission_types.remove(mission_type)
return Squadron(
name=data["name"],
nickname=data.get("nickname"),
country=data["country"],
role=data["role"],
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
pilot_pool=pilots,
game=game,
player=player,
)
def __setstate__(self, state: dict[str, Any]) -> None:
# TODO: Remove save compat.
if "auto_assignable_mission_types" not in state:
state["auto_assignable_mission_types"] = set(state["mission_types"])
self.__dict__.update(state)
class SquadronLoader:
def __init__(self, game: Game, player: bool) -> None:
self.game = game
self.player = player
@staticmethod
def squadron_directories() -> Iterator[Path]:
from game import persistency
yield Path(persistency.base_path()) / "Liberation/Squadrons"
yield Path("resources/squadrons")
def load(self) -> dict[AircraftType, list[Squadron]]:
squadrons: dict[AircraftType, list[Squadron]] = defaultdict(list)
country = self.game.country_for(self.player)
faction = self.game.faction_for(self.player)
any_country = country.startswith("Combined Joint Task Forces ")
for directory in self.squadron_directories():
for path, squadron in self.load_squadrons_from(directory):
if not any_country and squadron.country != country:
logging.debug(
"Not using squadron for non-matching country (is "
f"{squadron.country}, need {country}: {path}"
)
continue
if squadron.aircraft not in faction.aircrafts:
logging.debug(
f"Not using squadron because {faction.name} cannot use "
f"{squadron.aircraft}: {path}"
)
continue
logging.debug(
f"Found {squadron.name} {squadron.aircraft} {squadron.role} "
f"compatible with {faction.name}"
)
squadrons[squadron.aircraft].append(squadron)
# Convert away from defaultdict because defaultdict doesn't unpickle so we don't
# want it in the save state.
return dict(squadrons)
def load_squadrons_from(self, directory: Path) -> Iterator[Tuple[Path, Squadron]]:
logging.debug(f"Looking for factions in {directory}")
# First directory level is the aircraft type so that historical squadrons that
# have flown multiple airframes can be defined as many times as needed. The main
# load() method is responsible for filtering out squadrons that aren't
# compatible with the faction.
for squadron_path in directory.glob("*/*.yaml"):
try:
yield squadron_path, Squadron.from_yaml(
squadron_path, self.game, self.player
)
except Exception as ex:
raise RuntimeError(
f"Failed to load squadron defined by {squadron_path}"
) from ex
class AirWing:
def __init__(self, game: Game, player: bool) -> None:
from gen.flights.ai_flight_planner_db import tasks_for_aircraft
self.game = game
self.player = player
self.squadrons = SquadronLoader(game, player).load()
count = itertools.count(1)
for aircraft in game.faction_for(player).aircrafts:
if aircraft in self.squadrons:
continue
self.squadrons[aircraft] = [
Squadron(
name=f"Squadron {next(count):03}",
nickname=self.random_nickname(),
country=game.country_for(player),
role="Flying Squadron",
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
pilot_pool=[],
game=game,
player=player,
)
]
def squadrons_for(self, aircraft: AircraftType) -> Sequence[Squadron]:
return self.squadrons[aircraft]
def can_auto_plan(self, task: FlightType) -> bool:
try:
next(self.auto_assignable_for_task(task))
return True
except StopIteration:
return False
def auto_assignable_for_task(self, task: FlightType) -> Iterator[Squadron]:
for squadron in self.iter_squadrons():
if squadron.can_auto_assign(task):
yield squadron
def auto_assignable_for_task_with_type(
self, aircraft: AircraftType, task: FlightType
) -> Iterator[Squadron]:
for squadron in self.squadrons_for(aircraft):
if squadron.can_auto_assign(task) and squadron.has_available_pilots:
yield squadron
def squadron_for(self, aircraft: AircraftType) -> Squadron:
return self.squadrons_for(aircraft)[0]
def iter_squadrons(self) -> Iterator[Squadron]:
return itertools.chain.from_iterable(self.squadrons.values())
def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index]
def replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()
def reset(self) -> None:
for squadron in self.iter_squadrons():
squadron.return_all_pilots()
@property
def size(self) -> int:
return sum(len(s) for s in self.squadrons.values())
@staticmethod
def _make_random_nickname() -> str:
from gen.naming import ANIMALS
animal = random.choice(ANIMALS)
adjective = random.choice(
(
None,
"Red",
"Blue",
"Green",
"Golden",
"Black",
"Fighting",
"Flying",
)
)
if adjective is None:
return animal.title()
return f"{adjective} {animal}".title()
def random_nickname(self) -> str:
while True:
nickname = self._make_random_nickname()
for squadron in self.iter_squadrons():
if squadron.nickname == nickname:
break
else:
return nickname

View File

@@ -1,86 +0,0 @@
import itertools
import logging
from typing import Any
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.dcs.unittype import UnitType
BASE_MAX_STRENGTH = 1.0
BASE_MIN_STRENGTH = 0.0
class Base:
def __init__(self) -> None:
self.aircraft: dict[AircraftType, int] = {}
self.armor: dict[GroundUnitType, int] = {}
self.strength = 1.0
@property
def total_aircraft(self) -> int:
return sum(self.aircraft.values())
@property
def total_armor(self) -> int:
return sum(self.armor.values())
@property
def total_armor_value(self) -> int:
total = 0
for unit_type, count in self.armor.items():
total += unit_type.price * count
return total
def total_units_of_type(self, unit_type: UnitType[Any]) -> int:
return sum(
[
c
for t, c in itertools.chain(self.aircraft.items(), self.armor.items())
if t == unit_type
]
)
def commission_units(self, units: dict[Any, int]) -> None:
for unit_type, unit_count in units.items():
if unit_count <= 0:
continue
target_dict: dict[Any, int]
if isinstance(unit_type, AircraftType):
target_dict = self.aircraft
elif isinstance(unit_type, GroundUnitType):
target_dict = self.armor
else:
logging.error(f"Unexpected unit type of {unit_type}")
return
target_dict[unit_type] = target_dict.get(unit_type, 0) + unit_count
def commit_losses(self, units_lost: dict[Any, int]) -> None:
for unit_type, count in units_lost.items():
target_dict: dict[Any, int]
if unit_type in self.aircraft:
target_dict = self.aircraft
elif unit_type in self.armor:
target_dict = self.armor
else:
print("Base didn't find event type {}".format(unit_type))
continue
if unit_type not in target_dict:
print("Base didn't find event type {}".format(unit_type))
continue
target_dict[unit_type] = max(target_dict[unit_type] - count, 0)
if target_dict[unit_type] == 0:
del target_dict[unit_type]
def affect_strength(self, amount: float) -> None:
self.strength += amount
if self.strength > BASE_MAX_STRENGTH:
self.strength = BASE_MAX_STRENGTH
elif self.strength <= 0:
self.strength = BASE_MIN_STRENGTH
def set_strength_to_minimum(self) -> None:
self.strength = BASE_MIN_STRENGTH

View File

@@ -1,26 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, TYPE_CHECKING
from dcs import Point
from game.theater import LatLon
if TYPE_CHECKING:
from game.theater import ConflictTheater
@dataclass
class Bullseye:
position: Point
@classmethod
def from_pydcs(cls, bulls: Dict[str, float]) -> Bullseye:
return cls(Point(bulls["x"], bulls["y"]))
def to_pydcs(self) -> Dict[str, float]:
return {"x": self.position.x, "y": self.position.y}
def to_lat_lon(self, theater: ConflictTheater) -> LatLon:
return theater.point_to_ll(self.position)

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=33,
false_easting=-99516.9999999732,
false_northing=-4998114.999999984,
scale_factor=0.9996,
)

View File

@@ -1,883 +0,0 @@
from __future__ import annotations
import itertools
import math
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
from dcs import Mission
from dcs.countries import (
CombinedJointTaskForcesBlue,
CombinedJointTaskForcesRed,
)
from dcs.country import Country
from dcs.mapping import Point
from dcs.planes import F_15C
from dcs.ships import (
HandyWind,
Stennis,
USS_Arleigh_Burke_IIa,
LHA_Tarawa,
)
from dcs.statics import Fortification, Warehouse
from dcs.terrain import (
caucasus,
nevada,
normandy,
persiangulf,
syria,
thechannel,
marianaislands,
)
from dcs.terrain.terrain import Airport, Terrain
from dcs.unitgroup import (
ShipGroup,
StaticGroup,
VehicleGroup,
PlaneGroup,
)
from dcs.vehicles import AirDefence, Armor, MissilesSS, Unarmed
from pyproj import CRS, Transformer
from shapely import geometry, ops
from .controlpoint import (
Airfield,
Carrier,
ControlPoint,
Fob,
Lha,
MissionTarget,
OffMapSpawn,
)
from .frontline import FrontLine
from .landmap import Landmap, load_landmap, poly_contains
from .latlon import LatLon
from .projections import TransverseMercator
from ..point_with_heading import PointWithHeading
from ..positioned import Positioned
from ..profiling import logged_duration
from ..scenery_group import SceneryGroup
from ..utils import Distance, meters
if TYPE_CHECKING:
from . import TheaterGroundObject
SIZE_TINY = 150
SIZE_SMALL = 600
SIZE_REGULAR = 1000
SIZE_BIG = 2000
SIZE_LARGE = 3000
IMPORTANCE_LOW = 1
IMPORTANCE_MEDIUM = 1.2
IMPORTANCE_HIGH = 1.4
class MizCampaignLoader:
BLUE_COUNTRY = CombinedJointTaskForcesBlue()
RED_COUNTRY = CombinedJointTaskForcesRed()
OFF_MAP_UNIT_TYPE = F_15C.id
CV_UNIT_TYPE = Stennis.id
LHA_UNIT_TYPE = LHA_Tarawa.id
FRONT_LINE_UNIT_TYPE = Armor.M_113.id
SHIPPING_LANE_UNIT_TYPE = HandyWind.id
FOB_UNIT_TYPE = Unarmed.SKP_11.id
FARP_HELIPAD = "SINGLE_HELIPAD"
OFFSHORE_STRIKE_TARGET_UNIT_TYPE = Fortification.Oil_platform.id
SHIP_UNIT_TYPE = USS_Arleigh_Burke_IIa.id
MISSILE_SITE_UNIT_TYPE = MissilesSS.Scud_B.id
COASTAL_DEFENSE_UNIT_TYPE = MissilesSS.Hy_launcher.id
# Multiple options for air defenses so campaign designers can more accurately see
# the coverage of their IADS for the expected type.
LONG_RANGE_SAM_UNIT_TYPES = {
AirDefence.Patriot_ln.id,
AirDefence.S_300PS_5P85C_ln.id,
AirDefence.S_300PS_5P85D_ln.id,
}
MEDIUM_RANGE_SAM_UNIT_TYPES = {
AirDefence.Hawk_ln.id,
AirDefence.S_75M_Volhov.id,
AirDefence._5p73_s_125_ln.id,
}
SHORT_RANGE_SAM_UNIT_TYPES = {
AirDefence.M1097_Avenger.id,
AirDefence.Rapier_fsa_launcher.id,
AirDefence._2S6_Tunguska.id,
AirDefence.Strela_1_9P31.id,
}
AAA_UNIT_TYPES = {
AirDefence.Flak18.id,
AirDefence.Vulcan.id,
AirDefence.ZSU_23_4_Shilka.id,
}
EWR_UNIT_TYPE = AirDefence._1L13_EWR.id
ARMOR_GROUP_UNIT_TYPE = Armor.M_1_Abrams.id
FACTORY_UNIT_TYPE = Fortification.Workshop_A.id
AMMUNITION_DEPOT_UNIT_TYPE = Warehouse._Ammunition_depot.id
STRIKE_TARGET_UNIT_TYPE = Fortification.Tech_combine.id
def __init__(self, miz: Path, theater: ConflictTheater) -> None:
self.theater = theater
self.mission = Mission()
with logged_duration("Loading miz"):
self.mission.load_file(str(miz))
self.control_point_id = itertools.count(1000)
# If there are no red carriers there usually aren't red units. Make sure
# both countries are initialized so we don't have to deal with None.
if self.mission.country(self.BLUE_COUNTRY.name) is None:
self.mission.coalition["blue"].add_country(self.BLUE_COUNTRY)
if self.mission.country(self.RED_COUNTRY.name) is None:
self.mission.coalition["red"].add_country(self.RED_COUNTRY)
@staticmethod
def control_point_from_airport(airport: Airport) -> ControlPoint:
# The wiki says this is a legacy property and to just use regular.
size = SIZE_REGULAR
# The importance is taken from the periodicity of the airport's
# warehouse divided by 10. 30 is the default, and out of range (valid
# values are between 1.0 and 1.4). If it is used, pick the default
# importance.
if airport.periodicity == 30:
importance = IMPORTANCE_MEDIUM
else:
importance = airport.periodicity / 10
cp = Airfield(airport, size, importance)
cp.captured = airport.is_blue()
# Use the unlimited aircraft option to determine if an airfield should
# be owned by the player when the campaign is "inverted".
cp.captured_invert = airport.unlimited_aircrafts
return cp
def country(self, blue: bool) -> Country:
country = self.mission.country(
self.BLUE_COUNTRY.name if blue else self.RED_COUNTRY.name
)
# Should be guaranteed because we initialized them.
assert country
return country
@property
def blue(self) -> Country:
return self.country(blue=True)
@property
def red(self) -> Country:
return self.country(blue=False)
def off_map_spawns(self, blue: bool) -> Iterator[PlaneGroup]:
for group in self.country(blue).plane_group:
if group.units[0].type == self.OFF_MAP_UNIT_TYPE:
yield group
def carriers(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.CV_UNIT_TYPE:
yield group
def lhas(self, blue: bool) -> Iterator[ShipGroup]:
for group in self.country(blue).ship_group:
if group.units[0].type == self.LHA_UNIT_TYPE:
yield group
def fobs(self, blue: bool) -> Iterator[VehicleGroup]:
for group in self.country(blue).vehicle_group:
if group.units[0].type == self.FOB_UNIT_TYPE:
yield group
@property
def ships(self) -> Iterator[ShipGroup]:
for group in self.red.ship_group:
if group.units[0].type == self.SHIP_UNIT_TYPE:
yield group
@property
def offshore_strike_targets(self) -> Iterator[StaticGroup]:
for group in self.red.static_group:
if group.units[0].type == self.OFFSHORE_STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def missile_sites(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.MISSILE_SITE_UNIT_TYPE:
yield group
@property
def coastal_defenses(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type == self.COASTAL_DEFENSE_UNIT_TYPE:
yield group
@property
def long_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.LONG_RANGE_SAM_UNIT_TYPES:
yield group
@property
def medium_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.MEDIUM_RANGE_SAM_UNIT_TYPES:
yield group
@property
def short_range_sams(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.SHORT_RANGE_SAM_UNIT_TYPES:
yield group
@property
def aaa(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.AAA_UNIT_TYPES:
yield group
@property
def ewrs(self) -> Iterator[VehicleGroup]:
for group in self.red.vehicle_group:
if group.units[0].type in self.EWR_UNIT_TYPE:
yield group
@property
def armor_groups(self) -> Iterator[VehicleGroup]:
for group in itertools.chain(self.blue.vehicle_group, self.red.vehicle_group):
if group.units[0].type in self.ARMOR_GROUP_UNIT_TYPE:
yield group
@property
def helipads(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type == self.FARP_HELIPAD:
yield group
@property
def factories(self) -> Iterator[StaticGroup]:
for group in self.blue.static_group:
if group.units[0].type in self.FACTORY_UNIT_TYPE:
yield group
@property
def ammunition_depots(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.AMMUNITION_DEPOT_UNIT_TYPE:
yield group
@property
def strike_targets(self) -> Iterator[StaticGroup]:
for group in itertools.chain(self.blue.static_group, self.red.static_group):
if group.units[0].type in self.STRIKE_TARGET_UNIT_TYPE:
yield group
@property
def scenery(self) -> List[SceneryGroup]:
return SceneryGroup.from_trigger_zones(self.mission.triggers._zones)
@cached_property
def control_points(self) -> Dict[int, ControlPoint]:
control_points = {}
for airport in self.mission.terrain.airport_list():
if airport.is_blue() or airport.is_red():
control_point = self.control_point_from_airport(airport)
control_points[control_point.id] = control_point
for blue in (False, True):
for group in self.off_map_spawns(blue):
control_point = OffMapSpawn(
next(self.control_point_id), str(group.name), group.position
)
control_point.captured = blue
control_point.captured_invert = group.late_activation
control_points[control_point.id] = control_point
for ship in self.carriers(blue):
# TODO: Name the carrier.
control_point = Carrier(
"carrier", ship.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for ship in self.lhas(blue):
# TODO: Name the LHA.db
control_point = Lha("lha", ship.position, next(self.control_point_id))
control_point.captured = blue
control_point.captured_invert = ship.late_activation
control_points[control_point.id] = control_point
for fob in self.fobs(blue):
control_point = Fob(
str(fob.name), fob.position, next(self.control_point_id)
)
control_point.captured = blue
control_point.captured_invert = fob.late_activation
control_points[control_point.id] = control_point
return control_points
@property
def front_line_path_groups(self) -> Iterator[VehicleGroup]:
for group in self.country(blue=True).vehicle_group:
if group.units[0].type == self.FRONT_LINE_UNIT_TYPE:
yield group
@property
def shipping_lane_groups(self) -> Iterator[ShipGroup]:
for group in self.country(blue=True).ship_group:
if group.units[0].type == self.SHIPPING_LANE_UNIT_TYPE:
yield group
def add_supply_routes(self) -> None:
for group in self.front_line_path_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_convoy_route(destination, waypoints)
self.control_points[destination.id].create_convoy_route(
origin, list(reversed(waypoints))
)
def add_shipping_lanes(self) -> None:
for group in self.shipping_lane_groups:
# The unit will have its first waypoint at the source CP and the final
# waypoint at the destination CP. Each waypoint defines the path of the
# cargo ship.
waypoints = [p.position for p in group.points]
origin = self.theater.closest_control_point(waypoints[0])
if origin is None:
raise RuntimeError(
f"No control point near the first waypoint of {group.name}"
)
destination = self.theater.closest_control_point(waypoints[-1])
if destination is None:
raise RuntimeError(
f"No control point near the final waypoint of {group.name}"
)
self.control_points[origin.id].create_shipping_lane(destination, waypoints)
self.control_points[destination.id].create_shipping_lane(
origin, list(reversed(waypoints))
)
def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
def add_preset_locations(self) -> None:
for static in self.offshore_strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.offshore_strike_locations.append(
PointWithHeading.from_point(static.position, static.units[0].heading)
)
for ship in self.ships:
closest, distance = self.objective_info(ship)
closest.preset_locations.ships.append(
PointWithHeading.from_point(ship.position, ship.units[0].heading)
)
for group in self.missile_sites:
closest, distance = self.objective_info(group)
closest.preset_locations.missile_sites.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.coastal_defenses:
closest, distance = self.objective_info(group)
closest.preset_locations.coastal_defenses.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.long_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.long_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.medium_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.medium_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.short_range_sams:
closest, distance = self.objective_info(group)
closest.preset_locations.short_range_sams.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.aaa:
closest, distance = self.objective_info(group)
closest.preset_locations.aaa.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.ewrs:
closest, distance = self.objective_info(group)
closest.preset_locations.ewrs.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for group in self.armor_groups:
closest, distance = self.objective_info(group)
closest.preset_locations.armor_groups.append(
PointWithHeading.from_point(group.position, group.units[0].heading)
)
for static in self.helipads:
closest, distance = self.objective_info(static)
closest.helipads.append(
PointWithHeading.from_point(static.position, static.units[0].heading)
)
for static in self.factories:
closest, distance = self.objective_info(static)
closest.preset_locations.factories.append(
PointWithHeading.from_point(static.position, static.units[0].heading)
)
for static in self.ammunition_depots:
closest, distance = self.objective_info(static)
closest.preset_locations.ammunition_depots.append(
PointWithHeading.from_point(static.position, static.units[0].heading)
)
for static in self.strike_targets:
closest, distance = self.objective_info(static)
closest.preset_locations.strike_locations.append(
PointWithHeading.from_point(static.position, static.units[0].heading)
)
for scenery_group in self.scenery:
closest, distance = self.objective_info(scenery_group)
closest.preset_locations.scenery.append(scenery_group)
def populate_theater(self) -> None:
for control_point in self.control_points.values():
self.theater.add_controlpoint(control_point)
self.add_preset_locations()
self.add_supply_routes()
self.add_shipping_lanes()
@dataclass
class ReferencePoint:
world_coordinates: Point
image_coordinates: Point
class ConflictTheater:
terrain: Terrain
reference_points: Tuple[ReferencePoint, ReferencePoint]
overview_image: str
landmap: Optional[Landmap]
"""
land_poly = None # type: Polygon
"""
daytime_map: Dict[str, Tuple[int, int]]
def __init__(self) -> None:
self.controlpoints: List[ControlPoint] = []
self.point_to_ll_transformer = Transformer.from_crs(
self.projection_parameters.to_crs(), CRS("WGS84")
)
self.ll_to_point_transformer = Transformer.from_crs(
CRS("WGS84"), self.projection_parameters.to_crs()
)
"""
self.land_poly = geometry.Polygon(self.landmap[0][0])
for x in self.landmap[1]:
self.land_poly = self.land_poly.difference(geometry.Polygon(x))
"""
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["point_to_ll_transformer"]
del state["ll_to_point_transformer"]
return state
def __setstate__(self, state: Dict[str, Any]) -> None:
self.__dict__.update(state)
# Regenerate any state that was not persisted.
self.point_to_ll_transformer = Transformer.from_crs(
self.projection_parameters.to_crs(), CRS("WGS84")
)
self.ll_to_point_transformer = Transformer.from_crs(
CRS("WGS84"), self.projection_parameters.to_crs()
)
def add_controlpoint(self, point: ControlPoint) -> None:
self.controlpoints.append(point)
def find_ground_objects_by_obj_name(
self, obj_name: str
) -> list[TheaterGroundObject[Any]]:
found = []
for cp in self.controlpoints:
for g in cp.ground_objects:
if g.obj_name == obj_name:
found.append(g)
return found
def is_in_sea(self, point: Point) -> bool:
if not self.landmap:
return False
if self.is_on_land(point):
return False
for exclusion_zone in self.landmap.exclusion_zones:
if poly_contains(point.x, point.y, exclusion_zone):
return False
for sea in self.landmap.sea_zones:
if poly_contains(point.x, point.y, sea):
return True
return False
def is_on_land(self, point: Point) -> bool:
if not self.landmap:
return True
is_point_included = False
if poly_contains(point.x, point.y, self.landmap.inclusion_zones):
is_point_included = True
if not is_point_included:
return False
for exclusion_zone in self.landmap.exclusion_zones:
if poly_contains(point.x, point.y, exclusion_zone):
return False
return True
def nearest_land_pos(self, near: Point, extend_dist: int = 50) -> Point:
"""Returns the nearest point inside a land exclusion zone from point
`extend_dist` determines how far inside the zone the point should be placed"""
if self.is_on_land(near):
return near
point = geometry.Point(near.x, near.y)
nearest_points = []
if not self.landmap:
raise RuntimeError("Landmap not initialized")
for inclusion_zone in self.landmap.inclusion_zones:
nearest_pair = ops.nearest_points(point, inclusion_zone)
nearest_points.append(nearest_pair[1])
min_distance = point.distance(nearest_points[0]) # type: geometry.Point
nearest_point = nearest_points[0] # type: geometry.Point
for pt in nearest_points[1:]:
distance = point.distance(pt)
if distance < min_distance:
min_distance = distance
nearest_point = pt
assert isinstance(nearest_point, geometry.Point)
point = Point(point.x, point.y)
nearest_point = Point(nearest_point.x, nearest_point.y)
new_point = point.point_from_heading(
point.heading_between_point(nearest_point),
point.distance_to_point(nearest_point) + extend_dist,
)
return new_point
def control_points_for(self, player: bool) -> Iterator[ControlPoint]:
for point in self.controlpoints:
if point.captured == player:
yield point
def player_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=True))
def conflicts(self) -> Iterator[FrontLine]:
for player_cp in [x for x in self.controlpoints if x.captured]:
for enemy_cp in [
x for x in player_cp.connected_points if not x.is_friendly_to(player_cp)
]:
yield FrontLine(player_cp, enemy_cp)
def enemy_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=False))
def closest_control_point(self, point: Point) -> ControlPoint:
closest = 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
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]:
"""
Returns a tuple of the two nearest opposing ControlPoints in theater.
(player_cp, enemy_cp)
"""
seen = set()
min_distance = math.inf
closest_blue = None
closest_red = None
for blue_cp in self.player_points():
for red_cp in self.enemy_points():
if (blue_cp, red_cp) in seen:
continue
seen.add((blue_cp, red_cp))
seen.add((red_cp, blue_cp))
dist = red_cp.position.distance_to_point(blue_cp.position)
if dist < min_distance:
closest_red = red_cp
closest_blue = blue_cp
min_distance = dist
assert closest_blue is not None
assert closest_red is not None
return closest_blue, closest_red
def find_control_point_by_id(self, id: int) -> ControlPoint:
for i in self.controlpoints:
if i.id == id:
return i
raise KeyError(f"Cannot find ControlPoint with ID {id}")
@staticmethod
def from_json(directory: Path, data: Dict[str, Any]) -> ConflictTheater:
theaters = {
"Caucasus": CaucasusTheater,
"Nevada": NevadaTheater,
"Persian Gulf": PersianGulfTheater,
"Normandy": NormandyTheater,
"The Channel": TheChannelTheater,
"Syria": SyriaTheater,
}
theater = theaters[data["theater"]]
t = theater()
miz = data.get("miz", None)
if miz is None:
raise RuntimeError(
"Old format (non-miz) campaigns are no longer supported."
)
with logged_duration("Importing miz data"):
MizCampaignLoader(directory / miz, t).populate_theater()
return t
@property
def projection_parameters(self) -> TransverseMercator:
raise NotImplementedError
def point_to_ll(self, point: Point) -> LatLon:
lat, lon = self.point_to_ll_transformer.transform(point.x, point.y)
return LatLon(lat, lon)
def ll_to_point(self, ll: LatLon) -> Point:
x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude)
return Point(x, y)
class CaucasusTheater(ConflictTheater):
terrain = caucasus.Caucasus()
overview_image = "caumap.gif"
reference_points = (
ReferencePoint(caucasus.Gelendzhik.position, Point(176, 298)),
ReferencePoint(caucasus.Batumi.position, Point(1307, 1205)),
)
landmap = load_landmap("resources\\caulandmap.p")
daytime_map = {
"dawn": (6, 9),
"day": (9, 18),
"dusk": (18, 20),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .caucasus import PARAMETERS
return PARAMETERS
class PersianGulfTheater(ConflictTheater):
terrain = persiangulf.PersianGulf()
overview_image = "persiangulf.gif"
reference_points = (
ReferencePoint(persiangulf.Jiroft.position, Point(1692, 1343)),
ReferencePoint(persiangulf.Liwa_AFB.position, Point(358, 3238)),
)
landmap = load_landmap("resources\\gulflandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
"dusk": (16, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .persiangulf import PARAMETERS
return PARAMETERS
class NevadaTheater(ConflictTheater):
terrain = nevada.Nevada()
overview_image = "nevada.gif"
reference_points = (
ReferencePoint(nevada.Mina_Airport_3Q0.position, Point(252, 295)),
ReferencePoint(nevada.Laughlin_Airport.position, Point(844, 909)),
)
landmap = load_landmap("resources\\nevlandmap.p")
daytime_map = {
"dawn": (4, 6),
"day": (6, 17),
"dusk": (17, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .nevada import PARAMETERS
return PARAMETERS
class NormandyTheater(ConflictTheater):
terrain = normandy.Normandy()
overview_image = "normandy.gif"
reference_points = (
ReferencePoint(normandy.Needs_Oar_Point.position, Point(515, 329)),
ReferencePoint(normandy.Evreux.position, Point(2029, 1709)),
)
landmap = load_landmap("resources\\normandylandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (10, 17),
"dusk": (17, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .normandy import PARAMETERS
return PARAMETERS
class TheChannelTheater(ConflictTheater):
terrain = thechannel.TheChannel()
overview_image = "thechannel.gif"
reference_points = (
ReferencePoint(thechannel.Abbeville_Drucat.position, Point(2005, 2390)),
ReferencePoint(thechannel.Detling.position, Point(706, 382)),
)
landmap = load_landmap("resources\\channellandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (10, 17),
"dusk": (17, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .thechannel import PARAMETERS
return PARAMETERS
class SyriaTheater(ConflictTheater):
terrain = syria.Syria()
overview_image = "syria.gif"
reference_points = (
ReferencePoint(syria.Eyn_Shemer.position, Point(564, 1289)),
ReferencePoint(syria.Tabqa.position, Point(1329, 491)),
)
landmap = load_landmap("resources\\syrialandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
"dusk": (16, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .syria import PARAMETERS
return PARAMETERS
class MarianaIslandsTheater(ConflictTheater):
terrain = marianaislands.MarianaIslands()
overview_image = "marianaislands.gif"
landmap = load_landmap("resources\\marianaislandslandmap.p")
daytime_map = {
"dawn": (6, 8),
"day": (8, 16),
"dusk": (16, 18),
"night": (0, 5),
}
@property
def projection_parameters(self) -> TransverseMercator:
from .marianaislands import PARAMETERS
return PARAMETERS

File diff suppressed because it is too large Load Diff

View File

@@ -1,183 +0,0 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Iterator, List, Tuple, Any
from dcs.mapping import Point
from gen.flights.flight import FlightType
from .controlpoint import (
ControlPoint,
MissionTarget,
)
from ..utils import pairwise
FRONTLINE_MIN_CP_DISTANCE = 5000
@dataclass
class FrontLineSegment:
"""
Describes a line segment of a FrontLine
"""
point_a: Point
point_b: Point
@property
def attack_heading(self) -> float:
"""The heading of the frontline segment from player to enemy control point"""
return self.point_a.heading_between_point(self.point_b)
@property
def attack_distance(self) -> float:
"""Length of the segment"""
return self.point_a.distance_to_point(self.point_b)
class FrontLine(MissionTarget):
"""Defines a front line location between two control points.
Front lines are the area where ground combat happens.
Overwrites the entirety of MissionTarget __init__ method to allow for
dynamic position calculation.
"""
def __init__(
self,
blue_point: ControlPoint,
red_point: ControlPoint,
) -> None:
self.blue_cp = blue_point
self.red_cp = red_point
try:
route = list(blue_point.convoy_route_to(red_point))
except KeyError:
# Some campaigns are air only and the mission generator currently relies on
# *some* "front line" being drawn between these two. In this case there will
# be no supply route to follow. Just create an arbitrary route between the
# two points.
route = [blue_point.position, red_point.position]
# Snap the beginning and end points to the CPs rather than the convoy waypoints,
# which are on roads.
route[0] = blue_point.position
route[-1] = red_point.position
self.segments: List[FrontLineSegment] = [
FrontLineSegment(a, b) for a, b in pairwise(route)
]
super().__init__(
f"Front line {blue_point}/{red_point}",
self.point_from_a(self._position_distance),
)
def __setstate__(self, state: dict[str, Any]) -> None:
self.__dict__.update(state)
if not hasattr(self, "position"):
self.position = self.point_from_a(self._position_distance)
def control_point_hostile_to(self, player: bool) -> ControlPoint:
if player:
return self.red_cp
return self.blue_cp
def is_friendly(self, to_player: bool) -> bool:
"""Returns True if the objective is in friendly territory."""
return False
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
yield from [
FlightType.CAS,
FlightType.AEWC,
FlightType.REFUELING
# TODO: FlightType.TROOP_TRANSPORT
# TODO: FlightType.EVAC
]
yield from super().mission_types(for_player)
@property
def points(self) -> Iterator[Point]:
yield self.segments[0].point_a
for segment in self.segments:
yield segment.point_b
@property
def control_points(self) -> Tuple[ControlPoint, ControlPoint]:
"""Returns a tuple of the two control points."""
return self.blue_cp, self.red_cp
@property
def attack_distance(self) -> float:
"""The total distance of all segments"""
return sum(i.attack_distance for i in self.segments)
@property
def attack_heading(self) -> float:
"""The heading of the active attack segment from player to enemy control point"""
return self.active_segment.attack_heading
@property
def active_segment(self) -> FrontLineSegment:
"""The FrontLine segment where there can be an active conflict"""
if self._position_distance <= self.segments[0].attack_distance:
return self.segments[0]
remaining_dist = self._position_distance
for segment in self.segments:
if remaining_dist <= segment.attack_distance:
return segment
else:
remaining_dist -= segment.attack_distance
logging.error(
"Frontline attack distance is greater than the sum of its segments"
)
return self.segments[0]
def point_from_a(self, distance: float) -> Point:
"""
Returns a point {distance} away from control_point_a along the frontline segments.
"""
if distance < self.segments[0].attack_distance:
return self.blue_cp.position.point_from_heading(
self.segments[0].attack_heading, distance
)
remaining_dist = distance
for segment in self.segments:
if remaining_dist < segment.attack_distance:
return segment.point_a.point_from_heading(
segment.attack_heading, remaining_dist
)
else:
remaining_dist -= segment.attack_distance
raise RuntimeError(
f"Could not find front line point {distance} from {self.blue_cp}"
)
@property
def _position_distance(self) -> float:
"""
The distance from point "a" where the conflict should occur
according to the current strength of each control point
"""
total_strength = self.blue_cp.base.strength + self.red_cp.base.strength
if self.blue_cp.base.strength == 0:
return self._adjust_for_min_dist(0)
if self.red_cp.base.strength == 0:
return self._adjust_for_min_dist(self.attack_distance)
strength_pct = self.blue_cp.base.strength / total_strength
return self._adjust_for_min_dist(strength_pct * self.attack_distance)
def _adjust_for_min_dist(self, distance: float) -> float:
"""
Ensures the frontline conflict is never located within the minimum distance
constant of either end control point.
"""
if (distance > self.attack_distance / 2) and (
distance + FRONTLINE_MIN_CP_DISTANCE > self.attack_distance
):
distance = self.attack_distance - FRONTLINE_MIN_CP_DISTANCE
elif (distance < self.attack_distance / 2) and (
distance < FRONTLINE_MIN_CP_DISTANCE
):
distance = FRONTLINE_MIN_CP_DISTANCE
return distance

View File

@@ -1,40 +0,0 @@
from dataclasses import dataclass
import pickle
from functools import cached_property
from typing import Optional, Tuple, Union
import logging
from shapely import geometry
from shapely.geometry import MultiPolygon, Polygon
@dataclass(frozen=True)
class Landmap:
inclusion_zones: MultiPolygon
exclusion_zones: MultiPolygon
sea_zones: MultiPolygon
def __post_init__(self) -> None:
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]:
try:
with open(filename, "rb") as f:
return pickle.load(f)
except:
logging.exception(f"Failed to load landmap {filename}")
return None
def poly_contains(x: float, y: float, poly: Union[MultiPolygon, Polygon]) -> bool:
return poly.contains(geometry.Point(x, y))

View File

@@ -1,34 +0,0 @@
from dataclasses import dataclass
from typing import List, Tuple
@dataclass(frozen=True)
class LatLon:
latitude: float
longitude: float
def as_list(self) -> List[float]:
return [self.latitude, self.longitude]
@staticmethod
def _components(dimension: float) -> Tuple[int, int, float]:
degrees = int(dimension)
minutes = int(dimension * 60 % 60)
seconds = dimension * 3600 % 60
return degrees, minutes, seconds
def _format_component(
self, dimension: float, hemispheres: Tuple[str, str], seconds_precision: int
) -> str:
hemisphere = hemispheres[0] if dimension >= 0 else hemispheres[1]
degrees, minutes, seconds = self._components(dimension)
return f"{degrees}°{minutes:02}'{seconds:02.{seconds_precision}f}\"{hemisphere}"
def format_dms(self, include_decimal_seconds: bool = False) -> str:
precision = 2 if include_decimal_seconds else 0
return " ".join(
[
self._format_component(self.latitude, ("N", "S"), precision),
self._format_component(self.longitude, ("E", "W"), precision),
]
)

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=147,
false_easting=238417.99999989968,
false_northing=-1491840.000000048,
scale_factor=0.9996,
)

View File

@@ -1,50 +0,0 @@
from __future__ import annotations
from collections import Sequence
from typing import Iterator, TYPE_CHECKING, List, Union
from dcs.mapping import Point
from dcs.unit import Unit
if TYPE_CHECKING:
from gen.flights.flight import FlightType
class MissionTarget:
def __init__(self, name: str, position: Point) -> None:
"""Initializes a mission target.
Args:
name: The name of the mission target.
position: The location of the mission target.
"""
self.name = name
self.position = position
def distance_to(self, other: MissionTarget) -> float:
"""Computes the distance to the given mission target."""
return self.position.distance_to_point(other.position)
def is_friendly(self, to_player: bool) -> bool:
"""Returns True if the objective is in friendly territory."""
raise NotImplementedError
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if self.is_friendly(for_player):
yield FlightType.BARCAP
else:
yield from [
FlightType.ESCORT,
FlightType.TARCAP,
FlightType.SEAD_ESCORT,
FlightType.SWEEP,
# TODO: FlightType.ELINT,
# TODO: FlightType.EWAR,
# TODO: FlightType.RECON,
]
@property
def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return []

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=-117,
false_easting=-193996.80999964548,
false_northing=-4410028.063999966,
scale_factor=0.9996,
)

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=-3,
false_easting=-195526.00000000204,
false_northing=-5484812.999999951,
scale_factor=0.9996,
)

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=57,
false_easting=75755.99999999645,
false_northing=-2894933.0000000377,
scale_factor=0.9996,
)

View File

@@ -1,31 +0,0 @@
from dataclasses import dataclass
from pyproj import CRS
@dataclass(frozen=True)
class TransverseMercator:
central_meridian: int
false_easting: float
false_northing: float
scale_factor: float
def to_crs(self) -> CRS:
return CRS.from_proj4(
" ".join(
[
"+proj=tmerc",
"+lat_0=0",
f"+lon_0={self.central_meridian}",
f"+k_0={self.scale_factor}",
f"+x_0={self.false_easting}",
f"+y_0={self.false_northing}",
"+towgs84=0,0,0,0,0,0,0",
"+units=m",
"+vunits=m",
"+ellps=WGS84",
"+no_defs",
"+axis=neu",
]
)
)

View File

@@ -1,637 +0,0 @@
from __future__ import annotations
import logging
import pickle
import random
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Iterable, List, Set
from dcs.mapping import Point
from dcs.task import CAP, CAS, PinpointStrike
from dcs.vehicles import AirDefence
from game import Game, db
from game.factions.faction import Faction
from game.scenery_group import SceneryGroup
from game.theater import Carrier, Lha, PointWithHeading
from game.theater.theatergroundobject import (
BuildingGroundObject,
CarrierGroundObject,
EwrGroundObject,
FactoryGroundObject,
LhaGroundObject,
MissileSiteGroundObject,
SamGroundObject,
ShipGroundObject,
SceneryGroundObject,
VehicleGroupGroundObject,
CoastalSiteGroundObject,
)
from game.version import VERSION
from gen import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group
from gen.defenses.armor_group_generator import generate_armor_group
from gen.fleet.ship_group_generator import (
generate_carrier_group,
generate_lha_group,
generate_ship_group,
)
from gen.missiles.missiles_group_generator import generate_missile_group
from gen.sam.airdefensegroupgenerator import AirDefenseRange
from gen.sam.ewr_group_generator import generate_ewr_group
from gen.sam.sam_group_generator import generate_anti_air_group
from . import (
ConflictTheater,
ControlPoint,
ControlPointType,
Fob,
OffMapSpawn,
)
from ..profiling import logged_duration
from ..settings import Settings
GroundObjectTemplates = Dict[str, Dict[str, Any]]
UNIT_VARIETY = 6
UNIT_AMOUNT_FACTOR = 16
UNIT_COUNT_IMPORTANCE_LOG = 1.3
COUNT_BY_TASK = {
PinpointStrike: 12,
CAP: 8,
CAS: 4,
AirDefence: 1,
}
@dataclass(frozen=True)
class GeneratorSettings:
start_date: datetime
player_budget: int
enemy_budget: int
midgame: bool
inverted: bool
no_carrier: bool
no_lha: bool
no_player_navy: bool
no_enemy_navy: bool
@dataclass
class ModSettings:
a4_skyhawk: bool = False
f22_raptor: bool = False
hercules: bool = False
jas39_gripen: bool = False
su57_felon: bool = False
frenchpack: bool = False
high_digit_sams: bool = False
class GameGenerator:
def __init__(
self,
player: Faction,
enemy: Faction,
theater: ConflictTheater,
settings: Settings,
generator_settings: GeneratorSettings,
mod_settings: ModSettings,
) -> None:
self.player = player
self.enemy = enemy
self.theater = theater
self.settings = settings
self.generator_settings = generator_settings
self.mod_settings = mod_settings
def generate(self) -> Game:
with logged_duration("TGO population"):
# Reset name generator
namegen.reset()
self.prepare_theater()
game = Game(
player_faction=self.player.apply_mod_settings(self.mod_settings),
enemy_faction=self.enemy.apply_mod_settings(self.mod_settings),
theater=self.theater,
start_date=self.generator_settings.start_date,
settings=self.settings,
player_budget=self.generator_settings.player_budget,
enemy_budget=self.generator_settings.enemy_budget,
)
GroundObjectGenerator(game, self.generator_settings).generate()
game.settings.version = VERSION
game.begin_turn_0()
return game
def prepare_theater(self) -> None:
to_remove: List[ControlPoint] = []
# Auto-capture half the bases if midgame.
if self.generator_settings.midgame:
control_points = self.theater.controlpoints
for control_point in control_points[: len(control_points) // 2]:
control_point.captured = True
# Remove carrier and lha, invert situation if needed
for cp in self.theater.controlpoints:
if isinstance(cp, Carrier) and self.generator_settings.no_carrier:
to_remove.append(cp)
elif isinstance(cp, Lha) and self.generator_settings.no_lha:
to_remove.append(cp)
if self.generator_settings.inverted:
cp.captured = cp.captured_invert
# do remove
for cp in to_remove:
self.theater.controlpoints.remove(cp)
# TODO: Fix this. This captures all bases for blue.
# reapply midgame inverted if needed
if self.generator_settings.midgame and self.generator_settings.inverted:
for i, cp in enumerate(reversed(self.theater.controlpoints)):
if i > len(self.theater.controlpoints):
break
else:
cp.captured = True
class ControlPointGroundObjectGenerator:
def __init__(
self,
game: Game,
generator_settings: GeneratorSettings,
control_point: ControlPoint,
) -> None:
self.game = game
self.generator_settings = generator_settings
self.control_point = control_point
@property
def faction_name(self) -> str:
if self.control_point.captured:
return self.game.player_faction.name
else:
return self.game.enemy_faction.name
@property
def faction(self) -> Faction:
return db.FACTIONS[self.faction_name]
def generate(self) -> bool:
self.control_point.connected_objectives = []
if self.faction.navy_generators:
# Even airbases can generate navies if they are close enough to the water.
self.generate_navy()
return True
def generate_navy(self) -> None:
skip_player_navy = self.generator_settings.no_player_navy
if self.control_point.captured and skip_player_navy:
return
skip_enemy_navy = self.generator_settings.no_enemy_navy
if not self.control_point.captured and skip_enemy_navy:
return
for position in self.control_point.preset_locations.ships:
self.generate_ship_at(position)
def generate_ship_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
g = ShipGroundObject(
namegen.random_objective_name(), group_id, position, self.control_point
)
group = generate_ship_group(self.game, g, self.faction_name)
g.groups = []
if group is not None:
g.groups.append(group)
self.control_point.connected_objectives.append(g)
class NoOpGroundObjectGenerator(ControlPointGroundObjectGenerator):
def generate(self) -> bool:
return True
class CarrierGroundObjectGenerator(ControlPointGroundObjectGenerator):
def generate(self) -> bool:
if not super().generate():
return False
carrier_names = self.faction.carrier_names
if not carrier_names:
logging.info(
f"Skipping generation of {self.control_point.name} because "
f"{self.faction_name} has no carriers"
)
return False
# Create ground object group
group_id = self.game.next_group_id()
g = CarrierGroundObject(
namegen.random_objective_name(), group_id, self.control_point
)
group = generate_carrier_group(self.faction_name, self.game, g)
g.groups = []
if group is not None:
g.groups.append(group)
self.control_point.connected_objectives.append(g)
self.control_point.name = random.choice(carrier_names)
return True
class LhaGroundObjectGenerator(ControlPointGroundObjectGenerator):
def generate(self) -> bool:
if not super().generate():
return False
lha_names = self.faction.helicopter_carrier_names
if not lha_names:
logging.info(
f"Skipping generation of {self.control_point.name} because "
f"{self.faction_name} has no LHAs"
)
return False
# Create ground object group
group_id = self.game.next_group_id()
g = LhaGroundObject(
namegen.random_objective_name(), group_id, self.control_point
)
group = generate_lha_group(self.faction_name, self.game, g)
g.groups = []
if group is not None:
g.groups.append(group)
self.control_point.connected_objectives.append(g)
self.control_point.name = random.choice(lha_names)
return True
class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
def __init__(
self,
game: Game,
generator_settings: GeneratorSettings,
control_point: ControlPoint,
templates: GroundObjectTemplates,
) -> None:
super().__init__(game, generator_settings, control_point)
self.templates = templates
def generate(self) -> bool:
if not super().generate():
return False
self.generate_ground_points()
return True
def generate_ground_points(self) -> None:
"""Generate ground objects and AA sites for the control point."""
self.generate_armor_groups()
self.generate_aa()
self.generate_ewrs()
self.generate_scenery_sites()
self.generate_strike_targets()
self.generate_offshore_strike_targets()
self.generate_factories()
self.generate_ammunition_depots()
if self.faction.missiles:
self.generate_missile_sites()
if self.faction.coastal_defenses:
self.generate_coastal_sites()
def generate_armor_groups(self) -> None:
for position in self.control_point.preset_locations.armor_groups:
self.generate_armor_at(position)
def generate_armor_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
g = VehicleGroupGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
)
group = generate_armor_group(self.faction_name, self.game, g)
if group is None:
logging.error(
"Could not generate armor group for %s at %s",
g.name,
self.control_point,
)
return
g.groups = [group]
self.control_point.connected_objectives.append(g)
def generate_aa(self) -> None:
presets = self.control_point.preset_locations
for position in presets.long_range_sams:
self.generate_aa_at(
position,
ranges=[
{AirDefenseRange.Long},
{AirDefenseRange.Medium},
{AirDefenseRange.Short},
{AirDefenseRange.AAA},
],
)
for position in presets.medium_range_sams:
self.generate_aa_at(
position,
ranges=[
{AirDefenseRange.Medium},
{AirDefenseRange.Short},
{AirDefenseRange.AAA},
],
)
for position in presets.short_range_sams:
self.generate_aa_at(
position,
ranges=[{AirDefenseRange.Short}, {AirDefenseRange.AAA}],
)
for position in presets.aaa:
self.generate_aa_at(
position,
ranges=[{AirDefenseRange.AAA}],
)
def generate_ewrs(self) -> None:
presets = self.control_point.preset_locations
for position in presets.ewrs:
self.generate_ewr_at(position)
def generate_strike_target_at(self, category: str, position: Point) -> None:
obj_name = namegen.random_objective_name()
template = random.choice(list(self.templates[category].values()))
object_id = 0
group_id = self.game.next_group_id()
# TODO: Create only one TGO per objective, each with multiple units.
for unit in template:
object_id += 1
template_point = Point(unit["offset"].x, unit["offset"].y)
g = BuildingGroundObject(
obj_name,
category,
group_id,
object_id,
position + template_point,
unit["heading"],
self.control_point,
unit["type"],
)
self.control_point.connected_objectives.append(g)
def generate_ammunition_depots(self) -> None:
for position in self.control_point.preset_locations.ammunition_depots:
self.generate_strike_target_at(category="ammo", position=position)
def generate_factories(self) -> None:
for position in self.control_point.preset_locations.factories:
self.generate_factory_at(position)
def generate_factory_at(self, point: PointWithHeading) -> None:
obj_name = namegen.random_objective_name()
group_id = self.game.next_group_id()
g = FactoryGroundObject(
obj_name,
group_id,
point,
point.heading,
self.control_point,
)
self.control_point.connected_objectives.append(g)
def generate_aa_at(
self, position: Point, ranges: Iterable[Set[AirDefenseRange]]
) -> None:
group_id = self.game.next_group_id()
g = SamGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
)
groups = generate_anti_air_group(self.game, g, self.faction, ranges)
if not groups:
logging.error(
"Could not generate air defense group for %s at %s",
g.name,
self.control_point,
)
return
g.groups = groups
self.control_point.connected_objectives.append(g)
def generate_ewr_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
g = EwrGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
)
group = generate_ewr_group(self.game, g, self.faction)
if group is None:
logging.error(
"Could not generate ewr group for %s at %s",
g.name,
self.control_point,
)
return
g.groups = [group]
self.control_point.connected_objectives.append(g)
def generate_scenery_sites(self) -> None:
presets = self.control_point.preset_locations
for scenery_group in presets.scenery:
self.generate_tgo_for_scenery(scenery_group)
def generate_tgo_for_scenery(self, scenery: SceneryGroup) -> None:
obj_name = namegen.random_objective_name()
category = scenery.category
group_id = self.game.next_group_id()
object_id = 0
# Each nested trigger zone is a target/building/unit for an objective.
for zone in scenery.zones:
object_id += 1
local_position = zone.position
local_dcs_identifier = zone.name
g = SceneryGroundObject(
obj_name,
category,
group_id,
object_id,
local_position,
self.control_point,
local_dcs_identifier,
zone,
)
self.control_point.connected_objectives.append(g)
return
def generate_missile_sites(self) -> None:
for position in self.control_point.preset_locations.missile_sites:
self.generate_missile_site_at(position)
def generate_missile_site_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
g = MissileSiteGroundObject(
namegen.random_objective_name(), group_id, position, self.control_point
)
group = generate_missile_group(self.game, g, self.faction_name)
g.groups = []
if group is not None:
g.groups.append(group)
self.control_point.connected_objectives.append(g)
return
def generate_coastal_sites(self) -> None:
for position in self.control_point.preset_locations.coastal_defenses:
self.generate_coastal_site_at(position)
def generate_coastal_site_at(self, position: PointWithHeading) -> None:
group_id = self.game.next_group_id()
g = CoastalSiteGroundObject(
namegen.random_objective_name(),
group_id,
position,
self.control_point,
position.heading,
)
group = generate_coastal_group(self.game, g, self.faction_name)
g.groups = []
if group is not None:
g.groups.append(group)
self.control_point.connected_objectives.append(g)
return
def generate_strike_targets(self) -> None:
building_set = list(set(self.faction.building_set) - {"oil"})
if not building_set:
logging.error("Faction has no buildings defined")
return
for position in self.control_point.preset_locations.strike_locations:
category = random.choice(building_set)
self.generate_strike_target_at(category, position)
def generate_offshore_strike_targets(self) -> None:
if "oil" not in self.faction.building_set:
logging.error("Faction does not support offshore strike targets")
return
for position in self.control_point.preset_locations.offshore_strike_locations:
self.generate_strike_target_at("oil", position)
class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
def generate(self) -> bool:
self.generate_fob()
self.generate_armor_groups()
self.generate_factories()
self.generate_ammunition_depots()
self.generate_aa()
self.generate_ewrs()
self.generate_scenery_sites()
self.generate_strike_targets()
self.generate_offshore_strike_targets()
if self.faction.missiles:
self.generate_missile_sites()
if self.faction.coastal_defenses:
self.generate_coastal_sites()
return True
def generate_fob(self) -> None:
category = "fob"
obj_name = self.control_point.name
template = random.choice(list(self.templates[category].values()))
point = self.control_point.position
# Pick from preset locations
object_id = 0
group_id = self.game.next_group_id()
# TODO: Create only one TGO per objective, each with multiple units.
for unit in template:
object_id += 1
template_point = Point(unit["offset"].x, unit["offset"].y)
g = BuildingGroundObject(
obj_name,
category,
group_id,
object_id,
point + template_point,
unit["heading"],
self.control_point,
unit["type"],
is_fob_structure=True,
)
self.control_point.connected_objectives.append(g)
class GroundObjectGenerator:
def __init__(self, game: Game, generator_settings: GeneratorSettings) -> None:
self.game = game
self.generator_settings = generator_settings
with open("resources/groundobject_templates.p", "rb") as f:
self.templates: GroundObjectTemplates = pickle.load(f)
def generate(self) -> None:
# Copied so we can remove items from the original list without breaking
# the iterator.
control_points = list(self.game.theater.controlpoints)
for control_point in control_points:
if not self.generate_for_control_point(control_point):
self.game.theater.controlpoints.remove(control_point)
def generate_for_control_point(self, control_point: ControlPoint) -> bool:
generator: ControlPointGroundObjectGenerator
if control_point.cptype == ControlPointType.AIRCRAFT_CARRIER_GROUP:
generator = CarrierGroundObjectGenerator(
self.game, self.generator_settings, control_point
)
elif control_point.cptype == ControlPointType.LHA_GROUP:
generator = LhaGroundObjectGenerator(
self.game, self.generator_settings, control_point
)
elif isinstance(control_point, OffMapSpawn):
generator = NoOpGroundObjectGenerator(
self.game, self.generator_settings, control_point
)
elif isinstance(control_point, Fob):
generator = FobGroundObjectGenerator(
self.game, self.generator_settings, control_point, self.templates
)
else:
generator = AirbaseGroundObjectGenerator(
self.game, self.generator_settings, control_point, self.templates
)
return generator.generate()

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=39,
false_easting=282801.00000003993,
false_northing=-3879865.9999999935,
scale_factor=0.9996,
)

View File

@@ -1,625 +0,0 @@
from __future__ import annotations
import itertools
import logging
from collections import Sequence
from typing import Iterator, List, TYPE_CHECKING, Union, Generic, TypeVar, Any
from dcs.mapping import Point
from dcs.triggers import TriggerZone
from dcs.unit import Unit
from dcs.unitgroup import ShipGroup, VehicleGroup
from .. import db
from ..data.radar_db import (
TRACK_RADARS,
TELARS,
LAUNCHER_TRACKER_PAIRS,
)
from ..utils import Distance, meters
if TYPE_CHECKING:
from .controlpoint import ControlPoint
from gen.flights.flight import FlightType
from .missiontarget import MissionTarget
NAME_BY_CATEGORY = {
"ewr": "Early Warning Radar",
"aa": "AA Defense Site",
"allycamp": "Camp",
"ammo": "Ammo depot",
"armor": "Armor group",
"coastal": "Coastal defense",
"comms": "Communications tower",
"derrick": "Derrick",
"factory": "Factory",
"farp": "FARP",
"fob": "FOB",
"fuel": "Fuel depot",
"missile": "Missile site",
"oil": "Oil platform",
"power": "Power plant",
"ship": "Ship",
"village": "Village",
"ware": "Warehouse",
"ww2bunker": "Bunker",
}
GroupT = TypeVar("GroupT", ShipGroup, VehicleGroup)
class TheaterGroundObject(MissionTarget, Generic[GroupT]):
def __init__(
self,
name: str,
category: str,
group_id: int,
position: Point,
heading: int,
control_point: ControlPoint,
dcs_identifier: str,
sea_object: bool,
) -> None:
super().__init__(name, position)
self.category = category
self.group_id = group_id
self.heading = heading
self.control_point = control_point
self.dcs_identifier = dcs_identifier
self.sea_object = sea_object
self.groups: List[GroupT] = []
@property
def is_dead(self) -> bool:
return self.alive_unit_count == 0
@property
def units(self) -> List[Unit]:
"""
:return: all the units at this location
"""
return list(itertools.chain.from_iterable([g.units for g in self.groups]))
@property
def dead_units(self) -> List[Unit]:
"""
:return: all the dead units at this location
"""
return list(
itertools.chain.from_iterable(
[getattr(g, "units_losts", []) for g in self.groups]
)
)
@property
def group_name(self) -> str:
"""The name of the unit group."""
return f"{self.category}|{self.group_id}"
@property
def waypoint_name(self) -> str:
return f"[{self.name}] {self.category}"
def __str__(self) -> str:
return NAME_BY_CATEGORY[self.category]
def is_same_group(self, identifier: str) -> bool:
return self.group_id == identifier
@property
def obj_name(self) -> str:
return self.name
@property
def faction_color(self) -> str:
return "BLUE" if self.control_point.captured else "RED"
def is_friendly(self, to_player: bool) -> bool:
return self.control_point.is_friendly(to_player)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if self.is_friendly(for_player):
yield from [
# TODO: FlightType.LOGISTICS
# TODO: FlightType.TROOP_TRANSPORT
]
else:
yield from [
FlightType.STRIKE,
FlightType.BAI,
]
yield from super().mission_types(for_player)
@property
def alive_unit_count(self) -> int:
return sum(len(g.units) for g in self.groups)
@property
def might_have_aa(self) -> bool:
return False
@property
def has_live_radar_sam(self) -> bool:
"""Returns True if the ground object contains a unit with working radar SAM."""
for group in self.groups:
if self.threat_range(group, radar_only=True):
return True
return False
def _max_range_of_type(self, group: GroupT, 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 max_detection_range(self) -> Distance:
return max(self.detection_range(g) for g in self.groups)
def detection_range(self, group: GroupT) -> Distance:
return self._max_range_of_type(group, "detection_range")
def max_threat_range(self) -> Distance:
return max(self.threat_range(g) for g in self.groups)
def threat_range(self, group: GroupT, radar_only: bool = False) -> Distance:
return self._max_range_of_type(group, "threat_range")
@property
def is_factory(self) -> bool:
return self.category == "factory"
@property
def is_control_point(self) -> bool:
"""True if this TGO is the group for the control point itself (CVs and FOBs)."""
return False
@property
def strike_targets(self) -> Sequence[Union[MissionTarget, Unit]]:
return self.units
@property
def mark_locations(self) -> Iterator[Point]:
yield self.position
def clear(self) -> None:
self.groups = []
@property
def capturable(self) -> bool:
raise NotImplementedError
@property
def purchasable(self) -> bool:
raise NotImplementedError
class BuildingGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
category: str,
group_id: int,
object_id: int,
position: Point,
heading: int,
control_point: ControlPoint,
dcs_identifier: str,
is_fob_structure: bool = False,
) -> None:
super().__init__(
name=name,
category=category,
group_id=group_id,
position=position,
heading=heading,
control_point=control_point,
dcs_identifier=dcs_identifier,
sea_object=False,
)
self.is_fob_structure = is_fob_structure
self.object_id = object_id
# Other TGOs track deadness based on the number of alive units, but
# buildings don't have groups assigned to the TGO.
self._dead = False
@property
def group_name(self) -> str:
"""The name of the unit group."""
return f"{self.category}|{self.group_id}|{self.object_id}"
@property
def waypoint_name(self) -> str:
return f"{super().waypoint_name} #{self.object_id}"
@property
def is_dead(self) -> bool:
if not hasattr(self, "_dead"):
self._dead = False
return self._dead
def kill(self) -> None:
self._dead = True
def iter_building_group(self) -> Iterator[TheaterGroundObject[Any]]:
for tgo in self.control_point.ground_objects:
if tgo.obj_name == self.obj_name and not tgo.is_dead:
yield tgo
@property
def strike_targets(self) -> List[Union[MissionTarget, Unit]]:
return list(self.iter_building_group())
@property
def mark_locations(self) -> Iterator[Point]:
for building in self.iter_building_group():
yield building.position
@property
def is_control_point(self) -> bool:
return self.is_fob_structure
@property
def capturable(self) -> bool:
return True
@property
def purchasable(self) -> bool:
return False
class SceneryGroundObject(BuildingGroundObject):
def __init__(
self,
name: str,
category: str,
group_id: int,
object_id: int,
position: Point,
control_point: ControlPoint,
dcs_identifier: str,
zone: TriggerZone,
) -> None:
super().__init__(
name=name,
category=category,
group_id=group_id,
object_id=object_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier=dcs_identifier,
is_fob_structure=False,
)
self.zone = zone
try:
# In the default TriggerZone using "assign as..." in the DCS Mission Editor,
# property three has the scenery's object ID as its value.
self.map_object_id = self.zone.properties[3]["value"]
except (IndexError, KeyError):
logging.exception(
"Invalid TriggerZone for Scenery definition. The third property must "
"be the map object ID."
)
raise
class FactoryGroundObject(BuildingGroundObject):
def __init__(
self,
name: str,
group_id: int,
position: Point,
heading: int,
control_point: ControlPoint,
) -> None:
super().__init__(
name=name,
category="factory",
group_id=group_id,
object_id=0,
position=position,
heading=heading,
control_point=control_point,
dcs_identifier="Workshop A",
is_fob_structure=False,
)
class NavalGroundObject(TheaterGroundObject[ShipGroup]):
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if not self.is_friendly(for_player):
yield FlightType.ANTISHIP
yield from super().mission_types(for_player)
@property
def might_have_aa(self) -> bool:
return True
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return False
class GenericCarrierGroundObject(NavalGroundObject):
@property
def is_control_point(self) -> bool:
return True
# TODO: Why is this both a CP and a TGO?
class CarrierGroundObject(GenericCarrierGroundObject):
def __init__(self, name: str, group_id: int, control_point: ControlPoint) -> None:
super().__init__(
name=name,
category="CARRIER",
group_id=group_id,
position=control_point.position,
heading=0,
control_point=control_point,
dcs_identifier="CARRIER",
sea_object=True,
)
@property
def group_name(self) -> str:
# Prefix the group names with the side color so Skynet can find them,
# add to EWR.
return f"{self.faction_color}|EWR|{super().group_name}"
# TODO: Why is this both a CP and a TGO?
class LhaGroundObject(GenericCarrierGroundObject):
def __init__(self, name: str, group_id: int, control_point: ControlPoint) -> None:
super().__init__(
name=name,
category="LHA",
group_id=group_id,
position=control_point.position,
heading=0,
control_point=control_point,
dcs_identifier="LHA",
sea_object=True,
)
@property
def group_name(self) -> str:
# Prefix the group names with the side color so Skynet can find them,
# add to EWR.
return f"{self.faction_color}|EWR|{super().group_name}"
class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self, name: str, group_id: int, position: Point, control_point: ControlPoint
) -> None:
super().__init__(
name=name,
category="missile",
group_id=group_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return False
class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
heading: int,
) -> None:
super().__init__(
name=name,
category="coastal",
group_id=group_id,
position=position,
heading=heading,
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return False
# The SamGroundObject represents all type of AA
# The TGO can have multiple types of units (AAA,SAM,Support...)
# Differentiation can be made during generation with the airdefensegroupgenerator
class SamGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
) -> None:
super().__init__(
name=name,
category="aa",
group_id=group_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if not self.is_friendly(for_player):
yield FlightType.DEAD
yield FlightType.SEAD
yield from super().mission_types(for_player)
@property
def might_have_aa(self) -> bool:
return True
def threat_range(self, group: VehicleGroup, radar_only: bool = False) -> Distance:
max_non_radar = meters(0)
live_trs = set()
max_telar_range = meters(0)
launchers = set()
for unit in group.units:
unit_type = db.vehicle_type_from_name(unit.type)
if unit_type in TRACK_RADARS:
live_trs.add(unit_type)
elif unit_type in TELARS:
max_telar_range = max(max_telar_range, meters(unit_type.threat_range))
elif unit_type in LAUNCHER_TRACKER_PAIRS:
launchers.add(unit_type)
else:
max_non_radar = max(max_non_radar, meters(unit_type.threat_range))
max_tel_range = meters(0)
for launcher in launchers:
if LAUNCHER_TRACKER_PAIRS[launcher] in live_trs:
max_tel_range = max(max_tel_range, meters(unit_type.threat_range))
if radar_only:
return max(max_tel_range, max_telar_range)
else:
return max(max_tel_range, max_telar_range, max_non_radar)
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return True
class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
) -> None:
super().__init__(
name=name,
category="armor",
group_id=group_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
)
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return True
class EwrGroundObject(TheaterGroundObject[VehicleGroup]):
def __init__(
self,
name: str,
group_id: int,
position: Point,
control_point: ControlPoint,
) -> None:
super().__init__(
name=name,
category="ewr",
group_id=group_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier="EWR",
sea_object=False,
)
@property
def group_name(self) -> str:
# Prefix the group names with the side color so Skynet can find them.
# Use Group Id and uppercase EWR
return f"{self.faction_color}|EWR|{self.group_id}"
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
from gen.flights.flight import FlightType
if not self.is_friendly(for_player):
yield FlightType.DEAD
yield from super().mission_types(for_player)
@property
def might_have_aa(self) -> bool:
return True
@property
def capturable(self) -> bool:
return False
@property
def purchasable(self) -> bool:
return True
class ShipGroundObject(NavalGroundObject):
def __init__(
self, name: str, group_id: int, position: Point, control_point: ControlPoint
) -> None:
super().__init__(
name=name,
category="ship",
group_id=group_id,
position=position,
heading=0,
control_point=control_point,
dcs_identifier="AA",
sea_object=True,
)
@property
def group_name(self) -> str:
# Prefix the group names with the side color so Skynet can find them,
# add to EWR.
return f"{self.faction_color}|EWR|{super().group_name}"

View File

@@ -1,8 +0,0 @@
from game.theater.projections import TransverseMercator
PARAMETERS = TransverseMercator(
central_meridian=3,
false_easting=99376.00000000288,
false_northing=-5636889.00000001,
scale_factor=0.9996,
)

View File

@@ -1,196 +0,0 @@
from __future__ import annotations
import heapq
import math
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Dict, Iterator, List, Optional, Set, Tuple
from game.theater import ConflictTheater
from game.theater.controlpoint import ControlPoint
class NoPathError(RuntimeError):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(f"Could not reconstruct path to {destination} from {origin}")
@dataclass(frozen=True, order=True)
class FrontierNode:
cost: float
point: ControlPoint = field(compare=False)
class Frontier:
def __init__(self) -> None:
self.nodes: List[FrontierNode] = []
def push(self, poly: ControlPoint, cost: float) -> None:
heapq.heappush(self.nodes, FrontierNode(cost, poly))
def pop(self) -> Optional[FrontierNode]:
try:
return heapq.heappop(self.nodes)
except IndexError:
return None
def __bool__(self) -> bool:
return bool(self.nodes)
class TransitConnection(Enum):
Road = auto()
Shipping = auto()
Airlift = auto()
class TransitNetwork:
def __init__(self) -> None:
self.nodes: Dict[
ControlPoint, Dict[ControlPoint, TransitConnection]
] = defaultdict(dict)
def has_destinations(self, control_point: ControlPoint) -> bool:
return bool(self.nodes[control_point])
def has_link(self, a: ControlPoint, b: ControlPoint) -> bool:
return b in self.nodes[a]
def link_type(self, a: ControlPoint, b: ControlPoint) -> TransitConnection:
return self.nodes[a][b]
def link_with(
self, a: ControlPoint, b: ControlPoint, link_type: TransitConnection
) -> None:
self.nodes[a][b] = link_type
self.nodes[b][a] = link_type
def link_road(self, a: ControlPoint, b: ControlPoint) -> None:
self.link_with(a, b, TransitConnection.Road)
def link_shipping(self, a: ControlPoint, b: ControlPoint) -> None:
self.link_with(a, b, TransitConnection.Shipping)
def link_airport(self, a: ControlPoint, b: ControlPoint) -> None:
self.link_with(a, b, TransitConnection.Airlift)
def connections_from(self, control_point: ControlPoint) -> Iterator[ControlPoint]:
yield from self.nodes[control_point]
def cost(self, a: ControlPoint, b: ControlPoint) -> float:
return {
TransitConnection.Road: 1,
TransitConnection.Shipping: 3,
# Set arbitrarily high so that other methods are preferred, but still scaled
# by distance so that when we do need it we still pick the closest airfield.
# The units of distance are meters so there's no risk of these
TransitConnection.Airlift: a.position.distance_to_point(b.position),
}[self.link_type(a, b)]
def has_path_between(
self,
origin: ControlPoint,
destination: ControlPoint,
seen: Optional[set[ControlPoint]] = None,
) -> bool:
if seen is None:
seen = set()
seen.add(origin)
for connection in self.connections_from(origin):
if connection in seen:
continue
if connection == destination:
return True
if self.has_path_between(connection, destination, seen):
return True
return False
def shortest_path_between(
self, origin: ControlPoint, destination: ControlPoint
) -> list[ControlPoint]:
return self.shortest_path_with_cost(origin, destination)[0]
def shortest_path_with_cost(
self, origin: ControlPoint, destination: ControlPoint
) -> Tuple[List[ControlPoint], float]:
if origin not in self.nodes:
raise ValueError(f"{origin} is not in the transit network.")
if destination not in self.nodes:
raise ValueError(f"{destination} is not in the transit network.")
frontier = Frontier()
frontier.push(origin, 0)
came_from: Dict[ControlPoint, Optional[ControlPoint]] = {origin: None}
best_known: Dict[ControlPoint, float] = defaultdict(lambda: math.inf)
best_known[origin] = 0.0
while (node := frontier.pop()) is not None:
cost = node.cost
current = node.point
if cost > best_known[current]:
continue
for neighbor in self.connections_from(current):
new_cost = cost + self.cost(node.point, neighbor)
if new_cost < best_known[neighbor]:
best_known[neighbor] = new_cost
frontier.push(neighbor, new_cost)
came_from[neighbor] = current
# Reconstruct and reverse the path.
current = destination
path: List[ControlPoint] = []
while current != origin:
path.append(current)
previous = came_from.get(current)
if previous is None:
raise NoPathError(origin, destination)
current = previous
path.reverse()
return path, best_known[destination]
class TransitNetworkBuilder:
def __init__(self, theater: ConflictTheater, for_player: bool) -> None:
self.control_points = list(theater.control_points_for(for_player))
self.network = TransitNetwork()
self.airports: Set[ControlPoint] = {
cp
for cp in self.control_points
if cp.is_friendly(for_player) and cp.runway_is_operational()
}
def build(self) -> TransitNetwork:
seen = set()
for control_point in self.control_points:
if control_point not in seen:
seen.add(control_point)
self.add_transit_links(control_point)
return self.network
def add_transit_links(self, control_point: ControlPoint) -> None:
# Prefer road connections.
for road_connection in control_point.connected_points:
if road_connection.is_friendly_to(control_point):
self.network.link_road(control_point, road_connection)
# Use sea connections if there's no road or rail connection.
for sea_connection in control_point.shipping_lanes:
if self.network.has_link(control_point, sea_connection):
continue
if sea_connection.is_friendly_to(control_point):
self.network.link_shipping(control_point, sea_connection)
# And use airports as a last resort.
if control_point in self.airports:
for airport in self.airports:
if control_point == airport:
continue
if self.network.has_link(control_point, airport):
continue
if not airport.is_friendly_to(control_point):
continue
self.network.link_airport(control_point, airport)

View File

@@ -1,220 +0,0 @@
from __future__ import annotations
from functools import singledispatchmethod
from typing import Optional, TYPE_CHECKING, Union, Iterable
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, MissionTarget
from game.utils import Distance, meters, nautical_miles
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight, FlightWaypoint
if TYPE_CHECKING:
from game import Game
ThreatPoly = Union[MultiPolygon, Polygon]
class ThreatZones:
def __init__(
self,
airbases: ThreatPoly,
air_defenses: ThreatPoly,
radar_sam_threats: ThreatPoly,
) -> None:
self.airbases = airbases
self.air_defenses = air_defenses
self.radar_sam_threats = radar_sam_threats
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)
def distance_to_threat(self, point: DcsPoint) -> Distance:
boundary = self.closest_boundary(point)
return meters(boundary.distance_to_point(point))
# Type checking ignored because singledispatchmethod doesn't work with required type
# definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
def threatened(self, position) -> bool: # type: ignore
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)])
)
# Type checking ignored because singledispatchmethod doesn't work with required type
# definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
def threatened_by_aircraft(self, target) -> bool: # type: ignore
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))
)
def waypoints_threatened_by_aircraft(
self, waypoints: Iterable[FlightWaypoint]
) -> bool:
return self.threatened_by_aircraft(
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
)
# Type checking ignored because singledispatchmethod doesn't work with required type
# definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
def threatened_by_air_defense(self, target) -> bool: # type: ignore
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))
)
@threatened_by_air_defense.register
def _threatened_by_air_defense_mission_target(self, target: MissionTarget) -> bool:
return self.threatened_by_air_defense(
self.dcs_to_shapely_point(target.position)
)
# Type checking ignored because singledispatchmethod doesn't work with required type
# definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
def threatened_by_radar_sam(self, target) -> bool: # type: ignore
raise NotImplementedError
@threatened_by_radar_sam.register
def _threatened_by_radar_sam_geom(self, position: BaseGeometry) -> bool:
return self.radar_sam_threats.intersects(position)
@threatened_by_radar_sam.register
def _threatened_by_radar_sam_flight(self, flight: Flight) -> bool:
return self.threatened_by_radar_sam(
LineString((self.dcs_to_shapely_point(p.position) for p in flight.points))
)
def waypoints_threatened_by_radar_sam(
self, waypoints: Iterable[FlightWaypoint]
) -> bool:
return self.threatened_by_radar_sam(
LineString((self.dcs_to_shapely_point(p.position) for p in waypoints))
)
@classmethod
def closest_enemy_airbase(
cls, location: ControlPoint, max_distance: Distance
) -> Optional[ControlPoint]:
airfields = ObjectiveDistanceCache.get_closest_airfields(location)
for airfield in airfields.all_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.
"""
air_threats = []
air_defenses = []
radar_sam_threats = []
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)
air_threats.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)
radar_threat_range = tgo.threat_range(group, radar_only=True)
if radar_threat_range > nautical_miles(3):
point = ShapelyPoint(tgo.position.x, tgo.position.y)
threat_zone = point.buffer(threat_range.meters)
radar_sam_threats.append(threat_zone)
return cls(
airbases=unary_union(air_threats),
air_defenses=unary_union(air_defenses),
radar_sam_threats=unary_union(radar_sam_threats),
)
@staticmethod
def dcs_to_shapely_point(point: DcsPoint) -> ShapelyPoint:
return ShapelyPoint(point.x, point.y)

View File

@@ -1,763 +0,0 @@
from __future__ import annotations
import logging
import math
from collections import defaultdict
from dataclasses import dataclass, field
from functools import singledispatchmethod
from typing import (
Generic,
Iterator,
List,
Optional,
TYPE_CHECKING,
TypeVar,
Sequence,
)
from dcs.mapping import Point
from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.procurement import AircraftProcurementRequest
from game.squadrons import Squadron
from game.theater import ControlPoint, MissionTarget
from game.theater.transitnetwork import (
TransitConnection,
TransitNetwork,
)
from game.utils import meters, nautical_miles
from gen.ato import Package
from gen.flights.ai_flight_planner_db import TRANSPORT_CAPABLE, aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import Flight, FlightType
from gen.flights.flightplan import FlightPlanBuilder
from gen.naming import namegen
if TYPE_CHECKING:
from game import Game
from game.inventory import ControlPointAircraftInventory
class Transport:
def __init__(self, destination: ControlPoint):
self.destination = destination
def find_escape_route(self) -> Optional[ControlPoint]:
raise NotImplementedError
def description(self) -> str:
raise NotImplementedError
@dataclass
class TransferOrder:
"""The base type of all transfer orders.
A transfer order can transfer multiple units of multiple types.
"""
#: The location the units are transferring from.
origin: ControlPoint
#: The location the units are transferring to.
destination: ControlPoint
#: The current position of the group being transferred. Groups may make multiple
#: stops and can switch transport modes before reaching their destination.
position: ControlPoint = field(init=False)
#: True if the transfer order belongs to the player.
player: bool = field(init=False)
#: The units being transferred.
units: dict[GroundUnitType, int]
transport: Optional[Transport] = field(default=None)
def __str__(self) -> str:
"""Returns the text that should be displayed for the transfer."""
count = self.size
origin = self.origin.name
destination = self.destination.name
description = "Transfer" if self.player else "Enemy transfer"
return f"{description} of {count} units from {origin} to {destination}"
def __post_init__(self) -> None:
self.position = self.origin
self.player = self.origin.is_friendly(to_player=True)
@property
def description(self) -> str:
if self.transport is None:
return "No transports available"
return self.transport.description()
def kill_all(self) -> None:
self.units.clear()
def kill_unit(self, unit_type: GroundUnitType) -> None:
if unit_type not in self.units or not self.units[unit_type]:
raise KeyError(f"{self} has no {unit_type} remaining")
if self.units[unit_type] == 1:
del self.units[unit_type]
else:
self.units[unit_type] -= 1
@property
def size(self) -> int:
return sum(self.units.values())
def iter_units(self) -> Iterator[GroundUnitType]:
for unit_type, count in self.units.items():
for _ in range(count):
yield unit_type
@property
def completed(self) -> bool:
return self.destination == self.position or not self.size
def disband_at(self, location: ControlPoint) -> None:
logging.info(f"Units halting at {location}.")
location.base.commission_units(self.units)
self.units.clear()
@property
def next_stop(self) -> ControlPoint:
if self.transport is None:
raise RuntimeError(
"TransferOrder.next_stop called with no transport assigned"
)
return self.transport.destination
def find_escape_route(self) -> Optional[ControlPoint]:
if self.transport is not None:
return self.transport.find_escape_route()
return None
def disband(self) -> None:
"""
Disbands the specific transfer at the current position if friendly, at a
possible escape route or kills all units if none is possible
"""
if self.position.is_friendly(self.player):
self.disband_at(self.position)
elif (escape_route := self.find_escape_route()) is not None:
self.disband_at(escape_route)
else:
logging.info(
f"No escape route available. Units were surrounded and destroyed "
"during transfer."
)
self.kill_all()
def is_completable(self, network: TransitNetwork) -> bool:
"""
Checks if the transfer can be completed with the current theater state / transit
network to ensure that there is possible route between the current position and
the planned destination. This also ensures that the points are friendly.
"""
if self.transport is None:
# Check if unplanned transfers could be completed
if not self.position.is_friendly(self.player):
logging.info(
f"Current position ({self.position}) "
f"of the halting transfer was captured."
)
return False
if not network.has_path_between(self.position, self.destination):
logging.info(
f"Destination of transfer ({self.destination}) "
f"can not be reached anymore."
)
return False
if self.transport is not None and not self.next_stop.is_friendly(self.player):
# check if already proceeding transfers can reach the next stop
logging.info(
f"The next stop of the transfer ({self.next_stop}) "
f"was captured while transfer was on route."
)
return False
return True
def proceed(self) -> None:
"""
Let the transfer proceed to the next stop and disbands it if the next stop
is the destination
"""
if self.transport is None:
return
self.position = self.next_stop
self.transport = None
if self.completed:
self.disband_at(self.position)
class Airlift(Transport):
"""A transfer order that moves units by cargo planes and helicopters."""
def __init__(
self, transfer: TransferOrder, flight: Flight, next_stop: ControlPoint
) -> None:
super().__init__(next_stop)
self.transfer = transfer
self.flight = flight
@property
def units(self) -> dict[GroundUnitType, int]:
return self.transfer.units
@property
def player_owned(self) -> bool:
return self.transfer.player
def find_escape_route(self) -> Optional[ControlPoint]:
# TODO: Move units to closest base.
return None
def description(self) -> str:
return (
f"Being airlifted from {self.transfer.position} to {self.destination} by "
f"{self.flight}"
)
class AirliftPlanner:
#: Maximum range from for any link in the route of takeoff, pickup, dropoff, and RTB
#: for a helicopter to be considered for airlift. Total route length is not
#: considered because the helicopter can refuel at each stop. Cargo planes have no
#: maximum range.
HELO_MAX_RANGE = nautical_miles(100)
def __init__(
self, game: Game, transfer: TransferOrder, next_stop: ControlPoint
) -> None:
self.game = game
self.transfer = transfer
self.next_stop = next_stop
self.for_player = transfer.destination.captured
self.package = Package(target=next_stop, auto_asap=True)
def compatible_with_mission(
self, unit_type: AircraftType, airfield: ControlPoint
) -> bool:
if unit_type not in aircraft_for_task(FlightType.TRANSPORT):
return False
if not self.transfer.origin.can_operate(unit_type):
return False
if not self.next_stop.can_operate(unit_type):
return False
# Cargo planes have no maximum range.
if not unit_type.dcs_unit_type.helicopter:
return True
# A helicopter that is transport capable and able to operate at both bases. Need
# to check that no leg of the journey exceeds the maximum range. This doesn't
# account for any routing around threats that might take place, but it's close
# enough.
home = airfield.position
pickup = self.transfer.position.position
drop_off = self.transfer.position.position
if meters(home.distance_to_point(pickup)) > self.HELO_MAX_RANGE:
return False
if meters(pickup.distance_to_point(drop_off)) > self.HELO_MAX_RANGE:
return False
if meters(drop_off.distance_to_point(home)) > self.HELO_MAX_RANGE:
return False
return True
def create_package_for_airlift(self) -> None:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(
self.transfer.position
)
air_wing = self.game.air_wing_for(self.for_player)
for cp in distance_cache.closest_airfields:
if cp.captured != self.for_player:
continue
inventory = self.game.aircraft_inventory.for_control_point(cp)
for unit_type, available in inventory.all_aircraft:
squadrons = air_wing.auto_assignable_for_task_with_type(
unit_type, FlightType.TRANSPORT
)
for squadron in squadrons:
if self.compatible_with_mission(unit_type, cp):
while (
available
and squadron.has_available_pilots
and self.transfer.transport is None
):
flight_size = self.create_airlift_flight(
squadron, inventory
)
available -= flight_size
if self.package.flights:
self.game.ato_for(self.for_player).add_package(self.package)
def create_airlift_flight(
self, squadron: Squadron, inventory: ControlPointAircraftInventory
) -> int:
available_aircraft = inventory.available(squadron.aircraft)
capacity_each = 1 if squadron.aircraft.dcs_unit_type.helicopter else 2
required = math.ceil(self.transfer.size / capacity_each)
flight_size = min(
required,
available_aircraft,
squadron.aircraft.dcs_unit_type.group_size_max,
)
# TODO: Use number_of_available_pilots directly once feature flag is gone.
# The number of currently available pilots is not relevant when pilot limits
# are disabled.
if not squadron.can_provide_pilots(flight_size):
flight_size = squadron.number_of_available_pilots
capacity = flight_size * capacity_each
if capacity < self.transfer.size:
transfer = self.game.transfers.split_transfer(self.transfer, capacity)
else:
transfer = self.transfer
player = inventory.control_point.captured
flight = Flight(
self.package,
self.game.country_for(player),
squadron,
flight_size,
FlightType.TRANSPORT,
self.game.settings.default_start_type,
departure=inventory.control_point,
arrival=inventory.control_point,
divert=None,
cargo=transfer,
)
transport = Airlift(transfer, flight, self.next_stop)
transfer.transport = transport
self.package.add_flight(flight)
planner = FlightPlanBuilder(self.game, self.package, self.for_player)
planner.populate_flight_plan(flight)
self.game.aircraft_inventory.claim_for_flight(flight)
return flight_size
class MultiGroupTransport(MissionTarget, Transport):
def __init__(
self, name: str, origin: ControlPoint, destination: ControlPoint
) -> None:
MissionTarget.__init__(self, name, origin.position)
Transport.__init__(self, destination)
self.origin = origin
self.transfers: List[TransferOrder] = []
def is_friendly(self, to_player: bool) -> bool:
return self.origin.captured
def add_units(self, transfer: TransferOrder) -> None:
self.transfers.append(transfer)
transfer.transport = self
def remove_units(self, transfer: TransferOrder) -> None:
transfer.transport = None
self.transfers.remove(transfer)
def kill_unit(self, unit_type: GroundUnitType) -> None:
for transfer in self.transfers:
try:
transfer.kill_unit(unit_type)
return
except KeyError:
pass
raise KeyError
def kill_all(self) -> None:
for transfer in self.transfers:
transfer.kill_all()
def disband(self) -> None:
for transfer in list(self.transfers):
self.remove_units(transfer)
self.transfers.clear()
@property
def size(self) -> int:
return sum(t.size for t in self.transfers)
@property
def units(self) -> dict[GroundUnitType, int]:
units: dict[GroundUnitType, int] = defaultdict(int)
for transfer in self.transfers:
for unit_type, count in transfer.units.items():
units[unit_type] += count
return units
def iter_units(self) -> Iterator[GroundUnitType]:
for unit_type, count in self.units.items():
for _ in range(count):
yield unit_type
@property
def player_owned(self) -> bool:
return self.origin.captured
def find_escape_route(self) -> Optional[ControlPoint]:
raise NotImplementedError
def description(self) -> str:
raise NotImplementedError
class Convoy(MultiGroupTransport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_convoy_name(), origin, destination)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
return
yield FlightType.BAI
yield from super().mission_types(for_player)
@property
def route_start(self) -> Point:
return self.origin.convoy_origin_for(self.destination)
@property
def route_end(self) -> Point:
return self.destination.convoy_origin_for(self.origin)
def description(self) -> str:
return f"In a convoy from {self.origin} to {self.destination}"
def find_escape_route(self) -> Optional[ControlPoint]:
return None
class CargoShip(MultiGroupTransport):
def __init__(self, origin: ControlPoint, destination: ControlPoint) -> None:
super().__init__(namegen.next_cargo_ship_name(), origin, destination)
def mission_types(self, for_player: bool) -> Iterator[FlightType]:
if self.is_friendly(for_player):
return
yield FlightType.ANTISHIP
yield from super().mission_types(for_player)
@property
def route(self) -> Sequence[Point]:
return self.origin.shipping_lanes[self.destination]
def description(self) -> str:
return f"On a ship from {self.origin} to {self.destination}"
def find_escape_route(self) -> Optional[ControlPoint]:
return None
TransportType = TypeVar("TransportType", bound=MultiGroupTransport)
class TransportMap(Generic[TransportType]):
def __init__(self) -> None:
# Dict of origin -> destination -> transport.
self.transports: dict[
ControlPoint, dict[ControlPoint, TransportType]
] = defaultdict(dict)
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> TransportType:
raise NotImplementedError
def transport_exists(self, origin: ControlPoint, destination: ControlPoint) -> bool:
return destination in self.transports[origin]
def find_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Optional[TransportType]:
return self.transports[origin].get(destination)
def find_or_create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> TransportType:
transport = self.find_transport(origin, destination)
if transport is None:
transport = self.create_transport(origin, destination)
self.transports[origin][destination] = transport
return transport
def departing_from(self, origin: ControlPoint) -> Iterator[TransportType]:
yield from self.transports[origin].values()
def travelling_to(self, destination: ControlPoint) -> Iterator[TransportType]:
for destination_dict in self.transports.values():
if destination in destination_dict:
yield destination_dict[destination]
def disband_transport(self, transport: TransportType) -> None:
transport.disband()
del self.transports[transport.origin][transport.destination]
def add(self, transfer: TransferOrder, next_stop: ControlPoint) -> None:
self.find_or_create_transport(transfer.position, next_stop).add_units(transfer)
def remove(self, transport: TransportType, transfer: TransferOrder) -> None:
transport.remove_units(transfer)
if not transport.transfers:
self.disband_transport(transport)
def disband_all(self) -> None:
for transport in list(self):
self.disband_transport(transport)
def __iter__(self) -> Iterator[TransportType]:
for destination_dict in self.transports.values():
yield from destination_dict.values()
class ConvoyMap(TransportMap[Convoy]):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> Convoy:
return Convoy(origin, destination)
class CargoShipMap(TransportMap[CargoShip]):
def create_transport(
self, origin: ControlPoint, destination: ControlPoint
) -> CargoShip:
return CargoShip(origin, destination)
class PendingTransfers:
def __init__(self, game: Game) -> None:
self.game = game
self.convoys = ConvoyMap()
self.cargo_ships = CargoShipMap()
self.pending_transfers: List[TransferOrder] = []
def __iter__(self) -> Iterator[TransferOrder]:
yield from self.pending_transfers
@property
def pending_transfer_count(self) -> int:
return len(self.pending_transfers)
def transfer_at_index(self, index: int) -> TransferOrder:
return self.pending_transfers[index]
def index_of_transfer(self, transfer: TransferOrder) -> int:
return self.pending_transfers.index(transfer)
def network_for(self, control_point: ControlPoint) -> TransitNetwork:
return self.game.transit_network_for(control_point.captured)
def arrange_transport(self, transfer: TransferOrder) -> None:
network = self.network_for(transfer.position)
path = network.shortest_path_between(transfer.position, transfer.destination)
next_stop = path[0]
if network.link_type(transfer.position, next_stop) == TransitConnection.Road:
self.convoys.add(transfer, next_stop)
elif (
network.link_type(transfer.position, next_stop)
== TransitConnection.Shipping
):
self.cargo_ships.add(transfer, next_stop)
else:
AirliftPlanner(self.game, transfer, next_stop).create_package_for_airlift()
def new_transfer(self, transfer: TransferOrder) -> None:
transfer.origin.base.commit_losses(transfer.units)
self.pending_transfers.append(transfer)
self.arrange_transport(transfer)
def split_transfer(self, transfer: TransferOrder, size: int) -> TransferOrder:
"""Creates a smaller transfer that is a subset of the original."""
if transfer.size <= size:
raise ValueError
units = {}
for unit_type, remaining in transfer.units.items():
take = min(remaining, size)
size -= take
transfer.units[unit_type] -= take
units[unit_type] = take
if not size:
break
new_transfer = TransferOrder(transfer.origin, transfer.destination, units)
self.pending_transfers.append(new_transfer)
return new_transfer
# Type checking ignored because singledispatchmethod doesn't work with required type
# definitions. The implementation methods are all typed, so should be fine.
@singledispatchmethod
def cancel_transport( # type: ignore
self,
transport,
transfer: TransferOrder,
) -> None:
pass
@cancel_transport.register
def _cancel_transport_air(
self, transport: Airlift, _transfer: TransferOrder
) -> None:
flight = transport.flight
flight.package.remove_flight(flight)
if not flight.package.flights:
self.game.ato_for(transport.player_owned).remove_package(flight.package)
self.game.aircraft_inventory.return_from_flight(flight)
flight.clear_roster()
@cancel_transport.register
def _cancel_transport_convoy(
self, transport: Convoy, transfer: TransferOrder
) -> None:
self.convoys.remove(transport, transfer)
@cancel_transport.register
def _cancel_transport_cargo_ship(
self, transport: CargoShip, transfer: TransferOrder
) -> None:
self.cargo_ships.remove(transport, transfer)
def cancel_transfer(self, transfer: TransferOrder) -> None:
if transfer.transport is not None:
self.cancel_transport(transfer.transport, transfer)
self.pending_transfers.remove(transfer)
transfer.origin.base.commission_units(transfer.units)
def perform_transfers(self) -> None:
"""
Performs completable transfers from the list of pending transfers and adds
uncompleted transfers which are en route back to the list of pending transfers.
Disbands all convoys and cargo ships
"""
self.disband_uncompletable_transfers()
incomplete = []
for transfer in self.pending_transfers:
transfer.proceed()
if not transfer.completed:
incomplete.append(transfer)
self.pending_transfers = incomplete
self.convoys.disband_all()
self.cargo_ships.disband_all()
def plan_transports(self) -> None:
"""
Plan transports for all pending and completable transfers which don't have a
transport assigned already. This calculates the shortest path between current
position and destination on every execution to ensure the route is adopted to
recent changes in the theater state / transit network.
"""
self.disband_uncompletable_transfers()
for transfer in self.pending_transfers:
if transfer.transport is None:
self.arrange_transport(transfer)
def disband_uncompletable_transfers(self) -> None:
"""
Disbands all transfers from the list of pending_transfers which can not be
completed anymore because the theater state changed or the transit network does
not allow a route to the destination anymore
"""
completable_transfers = []
for transfer in self.pending_transfers:
if not transfer.is_completable(self.network_for(transfer.position)):
transfer.disband()
else:
completable_transfers.append(transfer)
self.pending_transfers = completable_transfers
def order_airlift_assets(self) -> None:
for control_point in self.game.theater.controlpoints:
if self.game.air_wing_for(control_point.captured).can_auto_plan(
FlightType.TRANSPORT
):
self.order_airlift_assets_at(control_point)
def desired_airlift_capacity(self, control_point: ControlPoint) -> int:
if control_point.has_factory:
is_major_hub = control_point.total_aircraft_parking > 0
# Check if there is a CP which is only reachable via Airlift
transit_network = self.network_for(control_point)
for cp in self.game.theater.control_points_for(control_point.captured):
# check if the CP has no factory, is reachable from the current
# position and can only be reached with airlift connections
if (
cp.can_deploy_ground_units
and not cp.has_factory
and transit_network.has_link(control_point, cp)
and not any(
link_type
for link, link_type in transit_network.nodes[cp].items()
if not link_type == TransitConnection.Airlift
)
):
return 4
if (
is_major_hub
and cp.has_factory
and cp.total_aircraft_parking > control_point.total_aircraft_parking
):
is_major_hub = False
if is_major_hub:
# If the current CP is a major hub keep always 2 planes on reserve
return 2
return 0
def current_airlift_capacity(self, control_point: ControlPoint) -> int:
inventory = self.game.aircraft_inventory.for_control_point(control_point)
squadrons = self.game.air_wing_for(
control_point.captured
).auto_assignable_for_task(FlightType.TRANSPORT)
unit_types = {s.aircraft for s in squadrons}
return sum(
count
for unit_type, count in inventory.all_aircraft
if unit_type in unit_types
)
def order_airlift_assets_at(self, control_point: ControlPoint) -> None:
unclaimed_parking = control_point.unclaimed_parking(self.game)
# Buy a maximum of unclaimed_parking only to prevent that aircraft procurement
# take place at another base
gap = min(
[
self.desired_airlift_capacity(control_point)
- self.current_airlift_capacity(control_point),
unclaimed_parking,
]
)
if gap <= 0:
return
if gap % 2:
# Always buy in pairs since we're not trying to fill odd squadrons. Purely
# aesthetic.
gap += 1
if gap > unclaimed_parking:
# Prevent to buy more aircraft than possible
return
self.game.procurement_requests_for(player=control_point.captured).append(
AircraftProcurementRequest(
control_point, nautical_miles(200), FlightType.TRANSPORT, gap
)
)

View File

@@ -1,170 +0,0 @@
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional, TYPE_CHECKING, Any
from game.theater import ControlPoint
from .dcs.groundunittype import GroundUnitType
from .dcs.unittype import UnitType
from .theater.transitnetwork import (
NoPathError,
TransitNetwork,
)
from .transfers import TransferOrder
if TYPE_CHECKING:
from .game import Game
@dataclass(frozen=True)
class GroundUnitSource:
control_point: ControlPoint
class PendingUnitDeliveries:
def __init__(self, destination: ControlPoint) -> None:
self.destination = destination
# Maps unit type to order quantity.
self.units: dict[UnitType[Any], int] = defaultdict(int)
def __str__(self) -> str:
return f"Pending delivery to {self.destination}"
def order(self, units: dict[UnitType[Any], int]) -> None:
for k, v in units.items():
self.units[k] += v
def sell(self, units: dict[UnitType[Any], int]) -> None:
for k, v in units.items():
if self.units[k] > v:
self.units[k] -= v
else:
del self.units[k]
def refund_all(self, game: Game) -> None:
self.refund(game, self.units)
self.units = defaultdict(int)
def refund_ground_units(self, game: Game) -> None:
ground_units: dict[UnitType[Any], int] = {
u: self.units[u] for u in self.units.keys() if isinstance(u, GroundUnitType)
}
self.refund(game, ground_units)
for gu in ground_units.keys():
del self.units[gu]
def refund(self, game: Game, units: dict[UnitType[Any], int]) -> None:
for unit_type, count in units.items():
logging.info(f"Refunding {count} {unit_type} at {self.destination.name}")
game.adjust_budget(
unit_type.price * count, player=self.destination.captured
)
def pending_orders(self, unit_type: UnitType[Any]) -> int:
pending_units = self.units.get(unit_type)
if pending_units is None:
pending_units = 0
return pending_units
def available_next_turn(self, unit_type: UnitType[Any]) -> int:
current_units = self.destination.base.total_units_of_type(unit_type)
return self.pending_orders(unit_type) + current_units
def process(self, game: Game) -> None:
ground_unit_source = self.find_ground_unit_source(game)
if ground_unit_source is None:
game.message(
f"{self.destination.name} lost its source for ground unit "
"reinforcements. Refunding purchase price."
)
self.refund_ground_units(game)
bought_units: dict[UnitType[Any], int] = {}
units_needing_transfer: dict[GroundUnitType, int] = {}
sold_units: dict[UnitType[Any], int] = {}
for unit_type, count in self.units.items():
coalition = "Ally" if self.destination.captured else "Enemy"
d: dict[Any, int]
if (
isinstance(unit_type, GroundUnitType)
and self.destination != ground_unit_source
):
source = ground_unit_source
d = units_needing_transfer
else:
source = self.destination
d = bought_units
if count >= 0:
d[unit_type] = count
game.message(
f"{coalition} reinforcements: {unit_type} x {count} at {source}"
)
else:
sold_units[unit_type] = -count
game.message(f"{coalition} sold: {unit_type} x {-count} at {source}")
self.units = defaultdict(int)
self.destination.base.commission_units(bought_units)
self.destination.base.commit_losses(sold_units)
if units_needing_transfer:
if ground_unit_source is None:
raise RuntimeError(
f"ground unit source could not be found for {self.destination} but still tried to "
f"transfer units to there"
)
ground_unit_source.base.commission_units(units_needing_transfer)
self.create_transfer(game, ground_unit_source, units_needing_transfer)
def create_transfer(
self, game: Game, source: ControlPoint, units: dict[GroundUnitType, int]
) -> None:
game.transfers.new_transfer(TransferOrder(source, self.destination, units))
def find_ground_unit_source(self, game: Game) -> Optional[ControlPoint]:
# This is running *after* the turn counter has been incremented, so this is the
# reaction to turn 0. On turn zero we allow units to be recruited anywhere for
# delivery on turn 1 so that turn 1 always starts with units on the front line.
if game.turn == 1:
return self.destination
# Fast path if the destination is a valid source.
if self.destination.can_recruit_ground_units(game):
return self.destination
try:
return self.find_ground_unit_source_in_network(
game.transit_network_for(self.destination.captured), game
)
except NoPathError:
return None
def find_ground_unit_source_in_network(
self, network: TransitNetwork, game: Game
) -> Optional[ControlPoint]:
sources = []
for control_point in game.theater.control_points_for(self.destination.captured):
if control_point.can_recruit_ground_units(
game
) and network.has_path_between(self.destination, control_point):
sources.append(control_point)
if not sources:
return None
# Fast path to skip the distance calculation if we have only one option.
if len(sources) == 1:
return sources[0]
closest = sources[0]
_, cost = network.shortest_path_with_cost(self.destination, closest)
for source in sources:
_, new_cost = network.shortest_path_with_cost(self.destination, source)
if new_cost < cost:
closest = source
cost = new_cost
return closest

View File

@@ -1,230 +0,0 @@
"""Maps generated units back to their Liberation types."""
import itertools
import math
from dataclasses import dataclass
from typing import Dict, Optional, Any, Union, TypeVar, Generic
from dcs.unit import Vehicle, Ship
from dcs.unitgroup import FlyingGroup, VehicleGroup, StaticGroup, ShipGroup, MovingGroup
from game.dcs.groundunittype import GroundUnitType
from game.squadrons import Pilot
from game.theater import Airfield, ControlPoint, TheaterGroundObject
from game.theater.theatergroundobject import BuildingGroundObject, SceneryGroundObject
from game.transfers import CargoShip, Convoy, TransferOrder
from gen.flights.flight import Flight
@dataclass(frozen=True)
class FlyingUnit:
flight: Flight
pilot: Optional[Pilot]
@dataclass(frozen=True)
class FrontLineUnit:
unit_type: GroundUnitType
origin: ControlPoint
UnitT = TypeVar("UnitT", Ship, Vehicle)
@dataclass(frozen=True)
class GroundObjectUnit(Generic[UnitT]):
ground_object: TheaterGroundObject[Any]
group: MovingGroup[UnitT]
unit: UnitT
@dataclass(frozen=True)
class ConvoyUnit:
unit_type: GroundUnitType
convoy: Convoy
@dataclass(frozen=True)
class AirliftUnits:
cargo: tuple[GroundUnitType, ...]
transfer: TransferOrder
@dataclass(frozen=True)
class Building:
ground_object: BuildingGroundObject
class UnitMap:
def __init__(self) -> None:
self.aircraft: Dict[str, FlyingUnit] = {}
self.airfields: Dict[str, Airfield] = {}
self.front_line_units: Dict[str, FrontLineUnit] = {}
self.ground_object_units: Dict[str, GroundObjectUnit[Any]] = {}
self.buildings: Dict[str, Building] = {}
self.convoys: Dict[str, ConvoyUnit] = {}
self.cargo_ships: Dict[str, CargoShip] = {}
self.airlifts: Dict[str, AirliftUnits] = {}
def add_aircraft(self, group: FlyingGroup[Any], flight: Flight) -> None:
for pilot, unit in zip(flight.roster.pilots, group.units):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.aircraft:
raise RuntimeError(f"Duplicate unit name: {name}")
self.aircraft[name] = FlyingUnit(flight, pilot)
if flight.cargo is not None:
self.add_airlift_units(group, flight.cargo)
def flight(self, unit_name: str) -> Optional[FlyingUnit]:
return self.aircraft.get(unit_name, None)
def add_airfield(self, airfield: Airfield) -> None:
if airfield.name in self.airfields:
raise RuntimeError(f"Duplicate airfield: {airfield.name}")
self.airfields[airfield.name] = airfield
def airfield(self, name: str) -> Optional[Airfield]:
return self.airfields.get(name, None)
def add_front_line_units(
self, group: VehicleGroup, origin: ControlPoint, unit_type: GroundUnitType
) -> None:
for unit in group.units:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.front_line_units:
raise RuntimeError(f"Duplicate front line unit: {name}")
self.front_line_units[name] = FrontLineUnit(unit_type, origin)
def front_line_unit(self, name: str) -> Optional[FrontLineUnit]:
return self.front_line_units.get(name, None)
def add_ground_object_units(
self,
ground_object: TheaterGroundObject[Any],
persistence_group: Union[ShipGroup, VehicleGroup],
miz_group: Union[ShipGroup, VehicleGroup],
) -> None:
"""Adds a group associated with a TGO to the unit map.
Args:
ground_object: The TGO the group is associated with.
persistence_group: The Group tracked by the TGO itself.
miz_group: The Group spawned for the miz to match persistence_group.
"""
# Deaths for units at TGOs are recorded in the Group that is contained
# by the TGO, but when groundobjectsgen populates the miz it creates new
# groups based on that template, so the units and groups in the miz are
# not a direct match for the units and groups that persist in the TGO.
#
# This means that we need to map the spawned unit names back to the
# original TGO units, not the ones in the miz.
if len(persistence_group.units) != len(miz_group.units):
raise ValueError("Persistent group does not match generated group")
unit_pairs = zip(persistence_group.units, miz_group.units)
for persistent_unit, miz_unit in unit_pairs:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(miz_unit.name)
if name in self.ground_object_units:
raise RuntimeError(f"Duplicate TGO unit: {name}")
self.ground_object_units[name] = GroundObjectUnit(
ground_object, persistence_group, persistent_unit
)
def ground_object_unit(self, name: str) -> Optional[GroundObjectUnit[Any]]:
return self.ground_object_units.get(name, None)
def add_convoy_units(self, group: VehicleGroup, convoy: Convoy) -> None:
for unit, unit_type in zip(group.units, convoy.iter_units()):
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.convoys:
raise RuntimeError(f"Duplicate convoy unit: {name}")
self.convoys[name] = ConvoyUnit(unit_type, convoy)
def convoy_unit(self, name: str) -> Optional[ConvoyUnit]:
return self.convoys.get(name, None)
def add_cargo_ship(self, group: ShipGroup, ship: CargoShip) -> None:
if len(group.units) > 1:
# Cargo ship "groups" are single units. Killing the one ship kills the whole
# transfer. If we ever want to add escorts or create multiple cargo ships in
# a convoy of ships that logic needs to change.
raise ValueError("Expected cargo ship to be a single unit group.")
unit = group.units[0]
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.cargo_ships:
raise RuntimeError(f"Duplicate cargo ship: {name}")
self.cargo_ships[name] = ship
def cargo_ship(self, name: str) -> Optional[CargoShip]:
return self.cargo_ships.get(name, None)
def add_airlift_units(
self, group: FlyingGroup[Any], transfer: TransferOrder
) -> None:
capacity_each = math.ceil(transfer.size / len(group.units))
for idx, transport in enumerate(group.units):
# Slice the units in groups based on the capacity of each unit. Cargo is
# assigned arbitrarily to units in the order of the group. The last unit in
# the group will receive a partial load if there is not enough cargo to fill
# every transport.
base_idx = idx * capacity_each
cargo = tuple(
itertools.islice(
transfer.iter_units(), base_idx, base_idx + capacity_each
)
)
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(transport.name)
if name in self.airlifts:
raise RuntimeError(f"Duplicate airlift unit: {name}")
self.airlifts[name] = AirliftUnits(cargo, transfer)
def airlift_unit(self, name: str) -> Optional[AirliftUnits]:
return self.airlifts.get(name, None)
def add_building(
self, ground_object: BuildingGroundObject, group: StaticGroup
) -> None:
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
# 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:
raise RuntimeError(f"Duplicate TGO unit: {name}")
self.buildings[name] = Building(ground_object)
def add_fortification(
self, ground_object: BuildingGroundObject, group: VehicleGroup
) -> None:
if len(group.units) != 1:
raise ValueError("Fortification groups must have exactly one unit.")
unit = group.units[0]
# The actual name is a String (the pydcs translatable string), which
# doesn't define __eq__.
name = str(unit.name)
if name in self.buildings:
raise RuntimeError(f"Duplicate TGO unit: {name}")
self.buildings[name] = Building(ground_object)
def add_scenery(self, ground_object: SceneryGroundObject) -> None:
name = str(ground_object.map_object_id)
if name in self.buildings:
raise RuntimeError(
f"Duplicate TGO unit: {name}. TriggerZone name: "
f"{ground_object.dcs_identifier}"
)
self.buildings[name] = Building(ground_object)
def building_or_fortification(self, name: str) -> Optional[Building]:
return self.buildings.get(name, None)

View File

@@ -1,191 +1,14 @@
from __future__ import annotations
import itertools
import math
from collections import Iterable
from dataclasses import dataclass
from typing import Union, Any
METERS_TO_FEET = 3.28084
FEET_TO_METERS = 1 / METERS_TO_FEET
NM_TO_METERS = 1852
METERS_TO_NM = 1 / NM_TO_METERS
KNOTS_TO_KPH = 1.852
KPH_TO_KNOTS = 1 / KNOTS_TO_KPH
MS_TO_KPH = 3.6
KPH_TO_MS = 1 / MS_TO_KPH
def meter_to_feet(value_in_meter: float) -> int:
return int(3.28084 * value_in_meter)
def heading_sum(h: int, a: int) -> int:
h += a
return h % 360
def feet_to_meter(value_in_feet: float) -> int:
return int(value_in_feet / 3.28084)
def opposite_heading(h: int) -> int:
return heading_sum(h, 180)
def meter_to_nm(value_in_meter: float) -> int:
return int(value_in_meter / 1852)
@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)
@classmethod
def inf(cls) -> Distance:
return cls.from_meters(math.inf)
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)
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
"""
itertools recipe
s -> (s0,s1), (s1,s2), (s2, s3), ...
"""
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
def nm_to_meter(value_in_nm: float) -> int:
return int(value_in_nm * 1852)

View File

@@ -1,113 +0,0 @@
from pathlib import Path
MAJOR_VERSION = 4
MINOR_VERSION = 1
MICRO_VERSION = 0
def _build_version_string() -> str:
components = [
".".join(str(v) for v in (MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION))
]
build_number_path = Path("resources/buildnumber")
if build_number_path.exists():
with build_number_path.open("r") as build_number_file:
components.append(build_number_file.readline())
if not Path("resources/final").exists():
components.append("preview")
return "-".join(components)
#: Current version of Liberation.
VERSION = _build_version_string()
#: The latest version of the campaign format. Increment this version whenever all
#: existing campaigns should be flagged as incompatible in the UI. We will still attempt
#: to load old campaigns, but this provides a warning to the user that the campaign may
#: not work correctly.
#:
#: There is no verification that the campaign author updated their campaign correctly
#: this is just a UI hint.
#:
#: Version history:
#:
#: Version 0
#: * Unknown compatibility.
#:
#: Version 1
#: * Compatible with Liberation 2.5.
#:
#: Version 2
#: * Front line endpoints now define convoy origin/destination waypoints. They should be
#: placed on or near roads.
#: * Factories (Workshop_A) define factory objectives. Only control points with
#: factories will be able to recruit ground units, so they should exist in sufficient
#: number and be protected by IADS.
#:
#: Version 3
#: * Bulker Handy Winds define shipping lanes. They should be placed in port areas that
#: are navigable by ships and have a route to another port area. DCS ships *will not*
#: avoid driving into islands, so ensure that their waypoints plot a navigable route.
#:
#: Version 4
#: * TriggerZones define map based building targets. White TriggerZones created by right
#: clicking an object and using "assign as..." define the buildings within an objective.
#: Blue circular TriggerZones created normally must surround groups of one or more
#: white TriggerZones to define an objective. If a white TriggerZone is not surrounded
#: by a blue circular TriggerZone, campaign creation will fail. Blue circular
#: TriggerZones must also have their first property's value field define the type of
#: objective (a valid value for a building TGO category, from `game.db.PRICES`).
#:
#: Version 4.1
#: * All objective types may now be set as required generation (similar to the required
#: IADS generation). This includes:
#: * SHORADS
#: * Armor groups
#: * Strike targets
#: * Offshore strike targets
#: * Ships
#: * Missile sites
#: * Coastal defenses
#:
#: See the unit lists in MizCampaignLoader in conflicttheater.py for unit types.
#:
#: Version 4.2
#: * Adds support for AAA objectives. Place with any of the following units (either red
#: or blue):
#: * Flak18,
#: * Vulcan,
#: * ZSU_23_4_Shilka,
#:
#: Version 5.0
#: * Ammunition Depots objective locations are now predetermined using the "Ammunition
# Depot" Warehouse object, and through trigger zone based scenery objects.
#: * The number of alive Ammunition Depot objective buildings connected to a control
#: point directly influences how many ground units can be supported on the front
#: line.
#: * The number of supported ground units at any control point is artificially
#: capped at 50, even if the number of alive Ammunition Depot objectives can
#: support more.
#:
#: Version 6.0
#: * Random objective generation no is longer supported. Fixed objective locations were
#: added in 4.1.
#:
#: Version 6.1
#: * Support for new Syrian airfields in DCS 2.7.2.7910.1 (Cyprus update).
#:
#: Version 7.0
#: * DCS 2.7.2.7910.1 (Cyprus update) changed the IDs of scenery strike targets. Any
#: mission using map buildings as strike targets must check and potentially recreate
#: all those objectives. This definitely affects all Syria campaigns, other maps are
#: not yet verified.
#:
#: Version 7.1
#: * Support for Mariana Islands terrain
#:
#: Version 8.0
#: * DCS 2.7.4.9632 changed scenery target IDs. Any mission using map buildings as
#: strike targets must check and potentially recreate all those objectives.
CAMPAIGN_FORMAT_VERSION = (8, 0)

View File

@@ -3,18 +3,14 @@ from __future__ import annotations
import datetime
import logging
import random
from dataclasses import dataclass, field
from dataclasses import dataclass
from enum import Enum
from typing import Optional, TYPE_CHECKING
from typing import Optional
from dcs.cloud_presets import Clouds as PydcsClouds
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
from dcs.weather import Weather as PydcsWeather, Wind
from game.settings import Settings
from game.utils import Distance, meters
if TYPE_CHECKING:
from game.theater import ConflictTheater
from theater import ConflictTheater
class TimeOfDay(Enum):
@@ -37,28 +33,11 @@ class Clouds:
density: int
thickness: int
precipitation: PydcsWeather.Preceptions
preset: Optional[CloudPreset] = field(default=None)
@classmethod
def random_preset(cls, rain: bool) -> Clouds:
clouds = (p.value for p in PydcsClouds)
if rain:
presets = [p for p in clouds if "Rain" in p.name]
else:
presets = [p for p in clouds if "Rain" not in p.name]
preset = random.choice(presets)
return Clouds(
base=random.randint(preset.min_base, preset.max_base),
density=0,
thickness=0,
precipitation=PydcsWeather.Preceptions.None_,
preset=preset,
)
@dataclass(frozen=True)
class Fog:
visibility: Distance
visibility: int
thickness: int
@@ -75,15 +54,15 @@ class Weather:
if random.randrange(5) != 0:
return None
return Fog(
visibility=meters(random.randint(2500, 5000)),
thickness=random.randint(100, 500),
visibility=random.randint(2500, 5000),
thickness=random.randint(100, 500)
)
def generate_wind(self) -> WindConditions:
raise NotImplementedError
@staticmethod
def random_wind(minimum: int, maximum: int) -> WindConditions:
def random_wind(minimum: int, maximum) -> WindConditions:
wind_direction = random.randint(0, 360)
at_0m_factor = 1
at_2000m_factor = 2
@@ -94,7 +73,7 @@ class Weather:
# Always some wind to make the smoke move a bit.
at_0m=Wind(wind_direction, max(1, base_wind * at_0m_factor)),
at_2000m=Wind(wind_direction, base_wind * at_2000m_factor),
at_8000m=Wind(wind_direction, base_wind * at_8000m_factor),
at_8000m=Wind(wind_direction, base_wind * at_8000m_factor)
)
@staticmethod
@@ -119,11 +98,12 @@ class ClearSkies(Weather):
class Cloudy(Weather):
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=False)
def generate_fog(self) -> Optional[Fog]:
# DCS 2.7 says to not use fog with the cloud presets.
return None
return Clouds(
base=self.random_cloud_base(),
density=random.randint(1, 8),
thickness=self.random_cloud_thickness(),
precipitation=PydcsWeather.Preceptions.None_
)
def generate_wind(self) -> WindConditions:
return self.random_wind(0, 4)
@@ -131,11 +111,12 @@ class Cloudy(Weather):
class Raining(Weather):
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=True)
def generate_fog(self) -> Optional[Fog]:
# DCS 2.7 says to not use fog with the cloud presets.
return None
return Clouds(
base=self.random_cloud_base(),
density=random.randint(5, 8),
thickness=self.random_cloud_thickness(),
precipitation=PydcsWeather.Preceptions.Rain
)
def generate_wind(self) -> WindConditions:
return self.random_wind(0, 6)
@@ -147,7 +128,7 @@ class Thunderstorm(Weather):
base=self.random_cloud_base(),
density=random.randint(9, 10),
thickness=self.random_cloud_thickness(),
precipitation=PydcsWeather.Preceptions.Thunderstorm,
precipitation=PydcsWeather.Preceptions.Thunderstorm
)
def generate_wind(self) -> WindConditions:
@@ -161,29 +142,20 @@ class Conditions:
weather: Weather
@classmethod
def generate(
cls,
theater: ConflictTheater,
day: datetime.date,
time_of_day: TimeOfDay,
settings: Settings,
) -> Conditions:
def generate(cls, theater: ConflictTheater, day: datetime.date,
time_of_day: TimeOfDay, settings: Settings) -> Conditions:
return cls(
time_of_day=time_of_day,
start_time=cls.generate_start_time(
theater, day, time_of_day, settings.night_disabled
),
weather=cls.generate_weather(),
weather=cls.generate_weather()
)
@classmethod
def generate_start_time(
cls,
theater: ConflictTheater,
day: datetime.date,
time_of_day: TimeOfDay,
night_disabled: bool,
) -> datetime.datetime:
def generate_start_time(cls, theater: ConflictTheater, day: datetime.date,
time_of_day: TimeOfDay,
night_disabled: bool) -> datetime.datetime:
if night_disabled:
logging.info("Skip Night mission due to user settings")
time_range = {
@@ -206,7 +178,6 @@ class Conditions:
Cloudy: 60,
ClearSkies: 20,
}
weather_type = random.choices(
list(chances.keys()), weights=list(chances.values())
)[0]
weather_type = random.choices(list(chances.keys()),
weights=list(chances.values()))[0]
return weather_type()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from typing import List, Type, Tuple, Optional, TYPE_CHECKING
from typing import List, Type
from dcs.mission import Mission, StartType
from dcs.planes import IL_78M, KC130, KC135MPRS, KC_135, PlaneType
from dcs.planes import IL_78M
from dcs.task import (
AWACS,
ActivateBeaconCommand,
@@ -15,18 +11,14 @@ from dcs.task import (
SetImmortalCommand,
SetInvisibleCommand,
)
from dcs.unittype import UnitType
from game import db
from .naming import namegen
from .callsigns import callsign_for_support_unit
from .conflictgen import Conflict
from .flights.ai_flight_planner_db import AEWC_CAPABLE
from .naming import namegen
from .radios import RadioFrequency, RadioRegistry
from .tacan import TacanBand, TacanChannel, TacanRegistry
if TYPE_CHECKING:
from game import Game
TANKER_DISTANCE = 15000
TANKER_ALT = 4572
TANKER_HEADING_OFFSET = 45
@@ -38,28 +30,19 @@ AWACS_ALT = 13000
@dataclass
class AwacsInfo:
"""AWACS information for the kneeboard."""
group_name: str
dcsGroupName: str
callsign: str
freq: RadioFrequency
depature_location: Optional[str]
start_time: Optional[timedelta]
end_time: Optional[timedelta]
blue: bool
@dataclass
class TankerInfo:
"""Tanker information for the kneeboard."""
group_name: str
dcsGroupName: str
callsign: str
variant: str
freq: RadioFrequency
tacan: TacanChannel
start_time: Optional[timedelta]
end_time: Optional[timedelta]
blue: bool
@dataclass
@@ -69,14 +52,10 @@ class AirSupport:
class AirSupportConflictGenerator:
def __init__(
self,
mission: Mission,
conflict: Conflict,
game: Game,
radio_registry: RadioRegistry,
tacan_registry: TacanRegistry,
) -> None:
def __init__(self, mission: Mission, conflict: Conflict, game,
radio_registry: RadioRegistry,
tacan_registry: TacanRegistry) -> None:
self.mission = mission
self.conflict = conflict
self.game = game
@@ -88,155 +67,79 @@ class AirSupportConflictGenerator:
def support_tasks(cls) -> List[Type[MainTask]]:
return [Refueling, AWACS]
@staticmethod
def _get_tanker_params(unit_type: Type[UnitType]) -> Tuple[int, int]:
if unit_type is KC130:
return (TANKER_ALT - 500, 596)
elif unit_type is KC_135:
return (TANKER_ALT, 770)
elif unit_type is KC135MPRS:
return (TANKER_ALT + 500, 596)
return (TANKER_ALT, 574)
def generate(self, is_awacs_enabled):
player_cp = self.conflict.from_cp if self.conflict.from_cp.captured else self.conflict.to_cp
def generate(self) -> None:
player_cp = (
self.conflict.blue_cp
if self.conflict.blue_cp.captured
else self.conflict.red_cp
)
fallback_tanker_number = 0
if not self.game.settings.disable_legacy_tanker:
fallback_tanker_number = 0
for i, tanker_unit_type in enumerate(
self.game.faction_for(player=True).tankers
):
unit_type = tanker_unit_type.dcs_unit_type
if not issubclass(unit_type, PlaneType):
logging.warning(f"Refueling aircraft {unit_type} must be a plane")
continue
# TODO: Make loiter altitude a property of the unit type.
alt, airspeed = self._get_tanker_params(tanker_unit_type.dcs_unit_type)
freq = self.radio_registry.alloc_uhf()
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
tanker_heading = (
self.conflict.red_cp.position.heading_between_point(
self.conflict.blue_cp.position
)
+ TANKER_HEADING_OFFSET * i
)
tanker_position = player_cp.position.point_from_heading(
tanker_heading, TANKER_DISTANCE
)
tanker_group = self.mission.refuel_flight(
country=self.mission.country(self.game.player_country),
name=namegen.next_tanker_name(
self.mission.country(self.game.player_country), tanker_unit_type
),
airport=None,
plane_type=unit_type,
position=tanker_position,
altitude=alt,
race_distance=58000,
frequency=freq.mhz,
start_type=StartType.Warm,
speed=airspeed,
tacanchannel=str(tacan),
)
tanker_group.set_frequency(freq.mhz)
callsign = callsign_for_support_unit(tanker_group)
tacan_callsign = {
"Texaco": "TEX",
"Arco": "ARC",
"Shell": "SHL",
}.get(callsign)
if tacan_callsign is None:
# The dict above is all the callsigns currently in the game, but
# non-Western countries don't use the callsigns and instead just
# use numbers. It's possible that none of those nations have
# TACAN compatible refueling aircraft, but fallback just in
# case.
tacan_callsign = f"TK{fallback_tanker_number}"
fallback_tanker_number += 1
if tanker_unit_type != IL_78M:
# Override PyDCS tacan channel.
tanker_group.points[0].tasks.pop()
tanker_group.points[0].tasks.append(
ActivateBeaconCommand(
tacan.number,
tacan.band.value,
tacan_callsign,
True,
tanker_group.units[0].id,
True,
)
)
tanker_group.points[0].tasks.append(SetInvisibleCommand(True))
tanker_group.points[0].tasks.append(SetImmortalCommand(True))
self.air_support.tankers.append(
TankerInfo(
str(tanker_group.name),
callsign,
tanker_unit_type.name,
freq,
tacan,
start_time=None,
end_time=None,
blue=True,
)
)
if not self.game.settings.disable_legacy_aewc:
possible_awacs = [
a
for a in self.game.faction_for(player=True).aircrafts
if a in AEWC_CAPABLE
]
if not possible_awacs:
logging.warning("No AWACS for faction")
return
awacs_unit = possible_awacs[0]
for i, tanker_unit_type in enumerate(db.find_unittype(Refueling, self.conflict.attackers_side)):
variant = db.unit_type_name(tanker_unit_type)
freq = self.radio_registry.alloc_uhf()
unit_type = awacs_unit.dcs_unit_type
if not issubclass(unit_type, PlaneType):
logging.warning(f"AWACS aircraft {unit_type} must be a plane")
return
awacs_flight = self.mission.awacs_flight(
tacan = self.tacan_registry.alloc_for_band(TacanBand.Y)
tanker_heading = self.conflict.to_cp.position.heading_between_point(self.conflict.from_cp.position) + TANKER_HEADING_OFFSET * i
tanker_position = player_cp.position.point_from_heading(tanker_heading, TANKER_DISTANCE)
tanker_group = self.mission.refuel_flight(
country=self.mission.country(self.game.player_country),
name=namegen.next_awacs_name(
self.mission.country(self.game.player_country)
),
plane_type=unit_type,
altitude=AWACS_ALT,
name=namegen.next_tanker_name(self.mission.country(self.game.player_country), tanker_unit_type),
airport=None,
position=self.conflict.position.random_point_within(
AWACS_DISTANCE, AWACS_DISTANCE
),
plane_type=tanker_unit_type,
position=tanker_position,
altitude=TANKER_ALT,
race_distance=58000,
frequency=freq.mhz,
start_type=StartType.Warm,
speed=574,
tacanchannel=str(tacan),
)
awacs_flight.set_frequency(freq.mhz)
tanker_group.set_frequency(freq.mhz)
awacs_flight.points[0].tasks.append(SetInvisibleCommand(True))
awacs_flight.points[0].tasks.append(SetImmortalCommand(True))
callsign = callsign_for_support_unit(tanker_group)
tacan_callsign = {
"Texaco": "TEX",
"Arco": "ARC",
"Shell": "SHL",
}.get(callsign)
if tacan_callsign is None:
# The dict above is all the callsigns currently in the game, but
# non-Western countries don't use the callsigns and instead just
# use numbers. It's possible that none of those nations have
# TACAN compatible refueling aircraft, but fallback just in
# case.
tacan_callsign = f"TK{fallback_tanker_number}"
fallback_tanker_number += 1
self.air_support.awacs.append(
AwacsInfo(
group_name=str(awacs_flight.name),
callsign=callsign_for_support_unit(awacs_flight),
freq=freq,
depature_location=None,
start_time=None,
end_time=None,
blue=True,
if tanker_unit_type != IL_78M:
# Override PyDCS tacan channel.
tanker_group.points[0].tasks.pop()
tanker_group.points[0].tasks.append(ActivateBeaconCommand(
tacan.number, tacan.band.value, tacan_callsign, True,
tanker_group.units[0].id, True))
tanker_group.points[0].tasks.append(SetInvisibleCommand(True))
tanker_group.points[0].tasks.append(SetImmortalCommand(True))
self.air_support.tankers.append(TankerInfo(str(tanker_group.name), callsign, variant, freq, tacan))
if is_awacs_enabled:
try:
freq = self.radio_registry.alloc_uhf()
awacs_unit = db.find_unittype(AWACS, self.conflict.attackers_side)[0]
awacs_flight = self.mission.awacs_flight(
country=self.mission.country(self.game.player_country),
name=namegen.next_awacs_name(self.mission.country(self.game.player_country)),
plane_type=awacs_unit,
altitude=AWACS_ALT,
airport=None,
position=self.conflict.position.random_point_within(AWACS_DISTANCE, AWACS_DISTANCE),
frequency=freq.mhz,
start_type=StartType.Warm,
)
)
awacs_flight.set_frequency(freq.mhz)
awacs_flight.points[0].tasks.append(SetInvisibleCommand(True))
awacs_flight.points[0].tasks.append(SetImmortalCommand(True))
self.air_support.awacs.append(AwacsInfo(
str(awacs_flight.name), callsign_for_support_unit(awacs_flight), freq))
except:
print("No AWACS for faction")

File diff suppressed because it is too large Load Diff

View File

@@ -8,21 +8,15 @@ example, the package to strike an enemy airfield may contain an escort flight,
a SEAD flight, and the strike aircraft themselves. CAP packages may contain only
the single CAP flight.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Dict, List, Optional
from dcs.mapping import Point
from game.theater.missiontarget import MissionTarget
from game.utils import Speed
from theater.missiontarget import MissionTarget
from .flights.flight import Flight, FlightType
from .flights.flightplan import FormationFlightPlan
from .flights.traveltime import TotEstimator
@dataclass(frozen=True)
@@ -57,93 +51,11 @@ class Package:
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.
time_over_target: timedelta = field(default=timedelta())
#: Desired TOT measured in seconds from mission start.
time_over_target: int = field(default=0)
waypoints: Optional[PackageWaypoints] = field(default=None)
@property
def has_players(self) -> bool:
return any(flight.client_count for flight in self.flights)
@property
def formation_speed(self) -> Optional[Speed]:
"""The speed of the package when in formation.
If none of the flights in the package will join a formation, this
returns None. This is nto uncommon, since only strike-like (strike,
DEAD, anti-ship, BAI, etc.) flights and their escorts fly in formation.
Others (CAP and CAS, currently) will coordinate in target timing but
fly their own path to the target.
"""
speeds = []
for flight in self.flights:
if isinstance(flight.flight_plan, FormationFlightPlan):
speeds.append(flight.flight_plan.best_flight_formation_speed)
if not speeds:
return None
return min(speeds)
# TODO: Should depend on the type of escort.
# SEAD might be able to leave before CAP.
@property
def escort_start_time(self) -> Optional[timedelta]:
times = []
for flight in self.flights:
waypoint = flight.flight_plan.request_escort_at()
if waypoint is None:
continue
tot = flight.flight_plan.tot_for_waypoint(waypoint)
if tot is None:
logging.error(
f"{flight} requested escort at {waypoint} but that "
"waypoint has no TOT. It may not be escorted."
)
continue
times.append(tot)
if times:
return min(times)
return None
@property
def escort_end_time(self) -> Optional[timedelta]:
times = []
for flight in self.flights:
waypoint = flight.flight_plan.dismiss_escort_at()
if waypoint is None:
continue
tot = flight.flight_plan.tot_for_waypoint(waypoint)
if tot is None:
tot = flight.flight_plan.depart_time_for_waypoint(waypoint)
if tot is None:
logging.error(
f"{flight} dismissed escort at {waypoint} but that "
"waypoint has no TOT or departure time. It may not be "
"escorted."
)
continue
times.append(tot)
if times:
return max(times)
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:
"""Adds a flight to the package."""
self.flights.append(flight)
@@ -168,27 +80,28 @@ class Package:
# likely to be the main task than others. For example, a package with
# only CAP flights is a CAP package, a flight with CAP and strike is a
# strike package, a flight with CAP and DEAD is a DEAD package, and a
# flight with strike and SEAD is an OCA/Strike package. This list defines the
# priority order for package task names. The package's primary task will be the
# first task in this list that matches a flight in the package.
tasks_by_priority = [
# flight with strike and SEAD is an OCA/Strike package. The type of
# package is determined by the highest priority flight in the package.
task_priorities = [
FlightType.CAS,
FlightType.STRIKE,
FlightType.ANTISHIP,
FlightType.OCA_AIRCRAFT,
FlightType.OCA_RUNWAY,
FlightType.BAI,
FlightType.EVAC,
FlightType.TROOP_TRANSPORT,
FlightType.RECON,
FlightType.ELINT,
FlightType.DEAD,
FlightType.TRANSPORT,
FlightType.SEAD,
FlightType.LOGISTICS,
FlightType.INTERCEPTION,
FlightType.TARCAP,
FlightType.CAP,
FlightType.BARCAP,
FlightType.AEWC,
FlightType.REFUELING,
FlightType.SWEEP,
FlightType.EWAR,
FlightType.ESCORT,
]
for task in tasks_by_priority:
for task in task_priorities:
if flight_counts[task]:
return task
@@ -204,10 +117,7 @@ class Package:
task = self.primary_task
if task is None:
return "No mission"
oca_strike_types = {FlightType.OCA_AIRCRAFT, FlightType.OCA_RUNWAY}
if task in oca_strike_types:
return "OCA Strike"
return str(task)
return task.name
def __hash__(self) -> int:
# TODO: Far from perfect. Number packages?

View File

@@ -1,68 +1,42 @@
"""
Briefing generation logic
"""
from __future__ import annotations
import datetime
import os
import random
from collections import defaultdict
from dataclasses import dataclass
from datetime import timedelta
from typing import Dict, List, TYPE_CHECKING
from typing import List
from dcs.mission import Mission
from jinja2 import Environment, FileSystemLoader, select_autoescape
from game.theater import ControlPoint, FrontLine
from game import db
from .aircraft import FlightData
from .airsupportgen import AwacsInfo, TankerInfo
from .armor import JtacInfo
from .flights.flight import FlightWaypoint
from .conflictgen import Conflict
from .ground_forces.combat_stance import CombatStance
from .radios import RadioFrequency
from .runways import RunwayData
if TYPE_CHECKING:
from game import Game
@dataclass
class CommInfo:
"""Communications information for the kneeboard."""
name: str
freq: RadioFrequency
class FrontLineInfo:
def __init__(self, front_line: FrontLine):
self.front_line: FrontLine = front_line
self.player_base: ControlPoint = front_line.blue_cp
self.enemy_base: ControlPoint = front_line.red_cp
self.player_zero: bool = self.player_base.base.total_armor == 0
self.enemy_zero: bool = self.enemy_base.base.total_armor == 0
self.advantage: bool = (
self.player_base.base.total_armor > self.enemy_base.base.total_armor
)
self.stance: CombatStance = self.player_base.stances[self.enemy_base.id]
self.combat_stances = CombatStance
class MissionInfoGenerator:
"""Base type for generators of mission information for the player.
Examples of subtypes include briefing generators, kneeboard generators, etc.
"""
def __init__(self, mission: Mission, game: Game) -> None:
def __init__(self, mission: Mission) -> None:
self.mission = mission
self.game = game
self.awacs: List[AwacsInfo] = []
self.comms: List[CommInfo] = []
self.flights: List[FlightData] = []
self.jtacs: List[JtacInfo] = []
self.tankers: List[TankerInfo] = []
self.frontlines: List[FrontLineInfo] = []
self.dynamic_runways: List[RunwayData] = []
def add_awacs(self, awacs: AwacsInfo) -> None:
"""Adds an AWACS/GCI to the mission.
@@ -105,13 +79,20 @@ class MissionInfoGenerator:
"""
self.tankers.append(tanker)
def add_frontline(self, frontline: FrontLineInfo) -> None:
"""Adds a frontline to the briefing
def generate(self) -> None:
"""Generates the mission information."""
raise NotImplementedError
Arguments:
frontline: Frontline conflict information
"""
self.frontlines.append(frontline)
class BriefingGenerator(MissionInfoGenerator):
def __init__(self, mission: Mission, conflict: Conflict, game):
super().__init__(mission)
self.conflict = conflict
self.game = game
self.title = ""
self.description = ""
self.dynamic_runways: List[RunwayData] = []
def add_dynamic_runway(self, runway: RunwayData) -> None:
"""Adds a dynamically generated runway to the briefing.
@@ -121,61 +102,150 @@ class MissionInfoGenerator:
"""
self.dynamic_runways.append(runway)
def generate(self) -> None:
"""Generates the mission information."""
raise NotImplementedError
def add_flight_description(self, flight: FlightData):
assert flight.client_units
aircraft = flight.aircraft_type
flight_unit_name = db.unit_type_name(aircraft)
self.description += "-" * 50 + "\n"
self.description += f"{flight_unit_name} x {flight.size}\n\n"
def format_waypoint_time(waypoint: FlightWaypoint, depart_prefix: str) -> str:
if waypoint.tot is not None:
time = timedelta(seconds=int(waypoint.tot.total_seconds()))
return f"T+{time} "
elif waypoint.departure_time is not None:
time = timedelta(seconds=int(waypoint.departure_time.total_seconds()))
return f"{depart_prefix} T+{time} "
return ""
for i, wpt in enumerate(flight.waypoints):
self.description += f"#{i + 1} -- {wpt.name} : {wpt.description}\n"
self.description += f"#{len(flight.waypoints) + 1} -- RTB\n\n"
class BriefingGenerator(MissionInfoGenerator):
def __init__(self, mission: Mission, game: Game):
super().__init__(mission, game)
self.allied_flights_by_departure: Dict[str, List[FlightData]] = {}
env = Environment(
loader=FileSystemLoader("resources/briefing/templates"),
autoescape=select_autoescape(
disabled_extensions=("",),
default_for_string=True,
default=True,
),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["waypoint_timing"] = format_waypoint_time
self.template = env.get_template("briefingtemplate_EN.j2")
def generate(self) -> None:
"""Generate the mission briefing"""
self._generate_frontline_info()
self.generate_allied_flights_by_departure()
self.mission.set_description_text(self.template.render(vars(self)))
self.mission.add_picture_blue(
os.path.abspath("./resources/ui/splash_screen.png")
def add_ally_flight_description(self, flight: FlightData):
assert not flight.client_units
aircraft = flight.aircraft_type
flight_unit_name = db.unit_type_name(aircraft)
delay = datetime.timedelta(seconds=flight.departure_delay)
self.description += (
f"{flight.flight_type.name} {flight_unit_name} x {flight.size}, "
f"departing in {delay}\n"
)
def _generate_frontline_info(self) -> None:
"""Build FrontLineInfo objects from FrontLine type and append to briefing."""
for front_line in self.game.theater.conflicts():
self.add_frontline(FrontLineInfo(front_line))
def generate(self):
self.description = ""
# TODO: This should determine if runway is friendly through a method more robust than the existing string match
def generate_allied_flights_by_departure(self) -> None:
"""Create iterable to display allied flights grouped by departure airfield."""
self.description += "DCS Liberation turn #" + str(self.game.turn) + "\n"
self.description += "=" * 15 + "\n\n"
self.description += (
"Most briefing information, including communications and flight "
"plan information, can be found on your kneeboard.\n\n"
)
self.generate_ongoing_war_text()
self.description += "\n"*2
self.description += "Your flights:" + "\n"
self.description += "=" * 15 + "\n\n"
for flight in self.flights:
if flight.client_units:
self.add_flight_description(flight)
self.description += "\n"*2
self.description += "Planned ally flights:" + "\n"
self.description += "=" * 15 + "\n"
allied_flights_by_departure = defaultdict(list)
for flight in self.flights:
if not flight.client_units and flight.friendly:
name = flight.departure.airfield_name
if (
name in self.allied_flights_by_departure
): # where else can we get this?
self.allied_flights_by_departure[name].append(flight)
else:
self.allied_flights_by_departure[name] = [flight]
allied_flights_by_departure[name].append(flight)
for departure, flights in allied_flights_by_departure.items():
self.description += f"\nFrom {departure}\n"
self.description += "-" * 50 + "\n\n"
for flight in flights:
self.add_ally_flight_description(flight)
if self.comms:
self.description += "\n\nComms Frequencies:\n"
self.description += "=" * 15 + "\n"
for comm_info in self.comms:
self.description += f"{comm_info.name}: {comm_info.freq}\n"
self.description += ("-" * 50) + "\n"
for runway in self.dynamic_runways:
self.description += f"{runway.airfield_name}\n"
self.description += f"RADIO : {runway.atc}\n"
if runway.tacan is not None:
self.description += f"TACAN : {runway.tacan} {runway.tacan_callsign}\n"
if runway.icls is not None:
self.description += f"ICLS Channel : {runway.icls}\n"
self.description += "-" * 50 + "\n"
self.description += "JTACS [F-10 Menu] : \n"
self.description += "===================\n\n"
for jtac in self.jtacs:
self.description += f"{jtac.region} -- Code : {jtac.code}\n"
self.mission.set_description_text(self.description)
self.mission.add_picture_blue(os.path.abspath(
"./resources/ui/splash_screen.png"))
def generate_ongoing_war_text(self):
self.description += "Current situation:\n"
self.description += "=" * 15 + "\n\n"
conflict_number = 0
for front_line in self.game.theater.conflicts(from_player=True):
conflict_number = conflict_number + 1
player_base = front_line.control_point_a
enemy_base = front_line.control_point_b
has_numerical_superiority = player_base.base.total_armor > enemy_base.base.total_armor
self.description += self.__random_frontline_sentence(player_base.name, enemy_base.name)
if enemy_base.id in player_base.stances.keys():
stance = player_base.stances[enemy_base.id]
if player_base.base.total_armor == 0:
self.description += "We do not have a single vehicle available to hold our position, the situation is critical, and we will lose ground inevitably.\n"
elif enemy_base.base.total_armor == 0:
self.description += "The enemy forces have been crushed, we will be able to make significant progress toward " + enemy_base.name + ". \n"
if stance == CombatStance.AGGRESSIVE:
if has_numerical_superiority:
self.description += "On this location, our ground forces will try to make progress against the enemy"
self.description += ". As the enemy is outnumbered, our forces should have no issue making progress.\n"
elif has_numerical_superiority:
self.description += "On this location, our ground forces will try an audacious assault against enemies in superior numbers. The operation is risky, and the enemy might counter attack.\n"
elif stance == CombatStance.ELIMINATION:
if has_numerical_superiority:
self.description += "On this location, our ground forces will focus on the destruction of enemy assets, before attempting to make progress toward " + enemy_base.name + ". "
self.description += "The enemy is already outnumbered, and this maneuver might draw a final blow to their forces.\n"
elif has_numerical_superiority:
self.description += "On this location, our ground forces will try an audacious assault against enemies in superior numbers. The operation is risky, and the enemy might counter attack.\n"
elif stance == CombatStance.BREAKTHROUGH:
if has_numerical_superiority:
self.description += "On this location, our ground forces will focus on progression toward " + enemy_base.name + ".\n"
elif has_numerical_superiority:
self.description += "On this location, our ground forces have been ordered to rush toward " + enemy_base.name + ". Wish them luck... We are also expecting a counter attack.\n"
elif stance in [CombatStance.DEFENSIVE, CombatStance.AMBUSH]:
if has_numerical_superiority:
self.description += "On this location, our ground forces will hold position. We are not expecting an enemy assault.\n"
elif has_numerical_superiority:
self.description += "On this location, our ground forces have been ordered to hold still, and defend against enemy attacks. An enemy assault might be iminent.\n"
if conflict_number == 0:
self.description += "There are currently no fights on the ground.\n"
self.description += "\n\n"
def __random_frontline_sentence(self, player_base_name, enemy_base_name):
templates = [
"There are combats between {} and {}. ",
"The war on the ground is still going on between {} and {}. ",
"Our ground forces in {} are opposed to enemy forces based in {}. ",
"Our forces from {} are fighting enemies based in {}. ",
"There is an active frontline between {} and {}. ",
]
return random.choice(templates).format(player_base_name, enemy_base_name)

View File

@@ -1,13 +1,12 @@
"""Support for working with DCS group callsigns."""
import logging
import re
from typing import Any
from dcs.unitgroup import FlyingGroup
from dcs.flyingunit import FlyingUnit
def callsign_for_support_unit(group: FlyingGroup[Any]) -> str:
def callsign_for_support_unit(group: FlyingGroup) -> str:
# Either something like Overlord11 for Western AWACS, or else just a number.
# Convert to either "Overlord" or "Flight 123".
lead = group.units[0]

View File

@@ -1,47 +0,0 @@
from __future__ import annotations
import itertools
from typing import TYPE_CHECKING
from dcs import Mission
from dcs.ships import HandyWind
from dcs.unitgroup import ShipGroup
from game.transfers import CargoShip
from game.unitmap import UnitMap
from game.utils import knots
if TYPE_CHECKING:
from game import Game
class CargoShipGenerator:
def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None:
self.mission = mission
self.game = game
self.unit_map = unit_map
self.count = itertools.count()
def generate(self) -> None:
# Reset the count to make generation deterministic.
for ship in self.game.transfers.cargo_ships:
self.generate_cargo_ship(ship)
def generate_cargo_ship(self, ship: CargoShip) -> ShipGroup:
country = self.mission.country(
self.game.player_country if ship.player_owned else self.game.enemy_country
)
waypoints = ship.route
group = self.mission.ship_group(
country,
ship.name,
HandyWind,
position=waypoints[0],
group_size=1,
)
for waypoint in waypoints[1:]:
# 12 knots is very slow but it's also nearly the max allowed by DCS for this
# type of ship.
group.add_waypoint(waypoint, speed=knots(12).kph)
self.unit_map.add_cargo_ship(group, ship)
return group

View File

@@ -1,39 +0,0 @@
import logging
import random
from typing import Optional
from dcs.unitgroup import VehicleGroup
from game import db, Game
from game.theater.theatergroundobject import CoastalSiteGroundObject
from gen.coastal.silkworm import SilkwormGenerator
COASTAL_MAP = {
"SilkwormGenerator": SilkwormGenerator,
}
def generate_coastal_group(
game: Game, ground_object: CoastalSiteGroundObject, faction_name: str
) -> Optional[VehicleGroup]:
"""
This generate a coastal defenses group
:return: The generated group, or None if this faction does not support coastal
defenses.
"""
faction = db.FACTIONS[faction_name]
if len(faction.coastal_defenses) > 0:
generators = faction.coastal_defenses
if len(generators) > 0:
gen = random.choice(generators)
if gen in COASTAL_MAP.keys():
generator = COASTAL_MAP[gen](game, ground_object, faction)
generator.generate()
return generator.get_generated_group()
else:
logging.info(
"Unable to generate missile group, generator : "
+ str(gen)
+ "does not exists"
)
return None

View File

@@ -1,63 +0,0 @@
from dcs.vehicles import MissilesSS, Unarmed, AirDefence
from game import Game
from game.factions.faction import Faction
from game.theater.theatergroundobject import CoastalSiteGroundObject
from gen.sam.group_generator import VehicleGroupGenerator
class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]):
def __init__(
self, game: Game, ground_object: CoastalSiteGroundObject, faction: Faction
) -> None:
super(SilkwormGenerator, self).__init__(game, ground_object)
self.faction = faction
def generate(self) -> None:
positions = self.get_circular_position(5, launcher_distance=120, coverage=180)
self.add_unit(
MissilesSS.Silkworm_SR,
"SR#0",
self.position.x,
self.position.y,
self.heading,
)
# Launchers
for i, p in enumerate(positions):
self.add_unit(
MissilesSS.Hy_launcher,
"Missile#" + str(i),
p[0],
p[1],
self.heading,
)
# Commander
self.add_unit(
Unarmed.KAMAZ_Truck,
"KAMAZ#0",
self.position.x - 35,
self.position.y - 20,
self.heading,
)
# Shorad
self.add_unit(
AirDefence.ZSU_23_4_Shilka,
"SHILKA#0",
self.position.x - 55,
self.position.y - 38,
self.heading,
)
# Shorad 2
self.add_unit(
AirDefence.Strela_1_9P31,
"STRELA#0",
self.position.x + 200,
self.position.y + 15,
90,
)

View File

@@ -1,166 +1,502 @@
from __future__ import annotations
import logging
from typing import Tuple, Optional
import random
from typing import Tuple
from dcs.country import Country
from dcs.mapping import Point
from shapely.geometry import LineString, Point as ShapelyPoint
from game.theater.conflicttheater import ConflictTheater, FrontLine
from game.theater.controlpoint import ControlPoint
from game.utils import heading_sum, opposite_heading
from theater import ConflictTheater, ControlPoint
AIR_DISTANCE = 40000
CAPTURE_AIR_ATTACKERS_DISTANCE = 25000
CAPTURE_AIR_DEFENDERS_DISTANCE = 60000
STRIKE_AIR_ATTACKERS_DISTANCE = 45000
STRIKE_AIR_DEFENDERS_DISTANCE = 25000
CAP_CAS_DISTANCE = 10000, 120000
GROUND_INTERCEPT_SPREAD = 5000
GROUND_DISTANCE_FACTOR = 1.4
GROUND_DISTANCE = 2000
GROUND_ATTACK_DISTANCE = 25000, 13000
TRANSPORT_FRONTLINE_DIST = 1800
INTERCEPT_ATTACKERS_HEADING = -45, 45
INTERCEPT_DEFENDERS_HEADING = -10, 10
INTERCEPT_CONFLICT_DISTANCE = 50000
INTERCEPT_ATTACKERS_DISTANCE = 100000
INTERCEPT_MAX_DISTANCE = 160000
INTERCEPT_MIN_DISTANCE = 100000
NAVAL_INTERCEPT_DISTANCE_FACTOR = 1
NAVAL_INTERCEPT_DISTANCE_MAX = 40000
NAVAL_INTERCEPT_STEP = 5000
FRONTLINE_LENGTH = 80000
FRONTLINE_MIN_CP_DISTANCE = 5000
FRONTLINE_DISTANCE_STRENGTH_FACTOR = 0.7
def _opposite_heading(h):
return h+180
def _heading_sum(h, a) -> int:
h += a
if h > 360:
return h - 360
elif h < 0:
return 360 + h
else:
return h
class Conflict:
def __init__(
self,
theater: ConflictTheater,
front_line: FrontLine,
attackers_side: str,
defenders_side: str,
attackers_country: Country,
defenders_country: Country,
position: Point,
heading: Optional[int] = None,
size: Optional[int] = None,
):
def __init__(self,
theater: ConflictTheater,
from_cp: ControlPoint,
to_cp: ControlPoint,
attackers_side: str,
defenders_side: str,
attackers_country: Country,
defenders_country: Country,
position: Point,
heading=None,
distance=None,
ground_attackers_location: Point = None,
ground_defenders_location: Point = None,
air_attackers_location: Point = None,
air_defenders_location: Point = None):
self.attackers_side = attackers_side
self.defenders_side = defenders_side
self.attackers_country = attackers_country
self.defenders_country = defenders_country
self.front_line = front_line
self.from_cp = from_cp
self.to_cp = to_cp
self.theater = theater
self.position = position
self.heading = heading
self.size = size
self.distance = distance
self.size = to_cp.size
self.radials = to_cp.radials
self.ground_attackers_location = ground_attackers_location
self.ground_defenders_location = ground_defenders_location
self.air_attackers_location = air_attackers_location
self.air_defenders_location = air_defenders_location
@property
def blue_cp(self) -> ControlPoint:
return self.front_line.blue_cp
def center(self) -> Point:
return self.position.point_from_heading(self.heading, self.distance / 2)
@property
def red_cp(self) -> ControlPoint:
return self.front_line.red_cp
def tail(self) -> Point:
return self.position.point_from_heading(self.heading, self.distance)
@property
def is_vector(self) -> bool:
return self.heading is not None
@property
def opposite_heading(self) -> int:
return _heading_sum(self.heading, 180)
@property
def to_size(self):
return self.to_cp.size * GROUND_DISTANCE_FACTOR
def find_insertion_point(self, other_point: Point) -> Point:
if self.is_vector:
dx = self.position.x - self.tail.x
dy = self.position.y - self.tail.y
dr2 = float(dx ** 2 + dy ** 2)
lerp = ((other_point.x - self.tail.x) * dx + (other_point.y - self.tail.y) * dy) / dr2
if lerp < 0:
lerp = 0
elif lerp > 1:
lerp = 1
x = lerp * dx + self.tail.x
y = lerp * dy + self.tail.y
return Point(x, y)
else:
return self.position
def find_ground_position(self, at: Point, heading: int, max_distance: int = 40000) -> Point:
return Conflict._find_ground_position(at, max_distance, heading, self.theater)
@classmethod
def has_frontline_between(cls, from_cp: ControlPoint, to_cp: ControlPoint) -> bool:
return from_cp.has_frontline and to_cp.has_frontline
@classmethod
def frontline_position(
cls, frontline: FrontLine, theater: ConflictTheater
) -> Tuple[Point, int]:
attack_heading = int(frontline.attack_heading)
position = cls.find_ground_position(
frontline.position,
FRONTLINE_LENGTH,
heading_sum(attack_heading, 90),
theater,
)
if position is None:
raise RuntimeError("Could not find front line position")
return position, opposite_heading(attack_heading)
def frontline_position(cls, theater: ConflictTheater, from_cp: ControlPoint, to_cp: ControlPoint) -> Tuple[Point, int]:
attack_heading = from_cp.position.heading_between_point(to_cp.position)
attack_distance = from_cp.position.distance_to_point(to_cp.position)
middle_point = from_cp.position.point_from_heading(attack_heading, attack_distance / 2)
strength_delta = (from_cp.base.strength - to_cp.base.strength) / 1.0
position = middle_point.point_from_heading(attack_heading, strength_delta * attack_distance / 2 - FRONTLINE_MIN_CP_DISTANCE)
return position, _opposite_heading(attack_heading)
@classmethod
def frontline_vector(
cls, front_line: FrontLine, theater: ConflictTheater
) -> Tuple[Point, int, int]:
def frontline_vector(cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater) -> Tuple[Point, int, int]:
"""
Returns a vector for a valid frontline location avoiding exclusion zones.
probe_end_point = initial.point_from_heading(heading, FRONTLINE_LENGTH)
probe = geometry.LineString([(initial.x, initial.y), (probe_end_point.x, probe_end_point.y) ])
intersection = probe.intersection(theater.land_poly)
if isinstance(intersection, geometry.LineString):
intersection = intersection
elif isinstance(intersection, geometry.MultiLineString):
intersection = intersection.geoms[0]
else:
print(intersection)
return None
return Point(*intersection.xy[0]), _heading_sum(heading, 90), intersection.length
"""
center_position, heading = cls.frontline_position(front_line, theater)
left_heading = heading_sum(heading, -90)
right_heading = heading_sum(heading, 90)
left_position = cls.extend_ground_position(
center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater
)
right_position = cls.extend_ground_position(
center_position, int(FRONTLINE_LENGTH / 2), right_heading, theater
)
distance = int(left_position.distance_to_point(right_position))
return left_position, right_heading, distance
frontline = cls.frontline_position(theater, from_cp, to_cp)
center_position, heading = frontline
left_position, right_position = None, None
if not theater.is_on_land(center_position):
pos = cls._find_ground_position(center_position, FRONTLINE_LENGTH, _heading_sum(heading, -90), theater)
if pos:
right_position = pos
center_position = pos
else:
pos = cls._find_ground_position(center_position, FRONTLINE_LENGTH, _heading_sum(heading, +90), theater)
if pos:
left_position = pos
center_position = pos
if left_position is None:
left_position = cls._extend_ground_position(center_position, int(FRONTLINE_LENGTH/2), _heading_sum(heading, -90), theater)
if right_position is None:
right_position = cls._extend_ground_position(center_position, int(FRONTLINE_LENGTH/2), _heading_sum(heading, 90), theater)
return left_position, _heading_sum(heading, 90), int(right_position.distance_to_point(left_position))
@classmethod
def frontline_cas_conflict(
cls,
attacker_name: str,
defender_name: str,
attacker: Country,
defender: Country,
front_line: FrontLine,
theater: ConflictTheater,
) -> Conflict:
assert cls.has_frontline_between(front_line.blue_cp, front_line.red_cp)
position, heading, distance = cls.frontline_vector(front_line, theater)
conflict = cls(
def _extend_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point:
pos = initial
for offset in range(0, int(max_distance), 500):
new_pos = initial.point_from_heading(heading, offset)
if theater.is_on_land(new_pos):
pos = new_pos
else:
return pos
return pos
"""
probe_end_point = initial.point_from_heading(heading, max_distance)
probe = geometry.LineString([(initial.x, initial.y), (probe_end_point.x, probe_end_point.y)])
intersection = probe.intersection(theater.land_poly)
if intersection is geometry.LineString:
return Point(*intersection.xy[1])
elif intersection is geometry.MultiLineString:
return Point(*intersection.geoms[0].xy[1])
return None
"""
@classmethod
def _find_ground_position(cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater) -> Point:
pos = initial
for _ in range(0, int(max_distance), 500):
if theater.is_on_land(pos):
return pos
pos = pos.point_from_heading(heading, 500)
"""
probe_end_point = initial.point_from_heading(heading, max_distance)
probe = geometry.LineString([(initial.x, initial.y), (probe_end_point.x, probe_end_point.y) ])
intersection = probe.intersection(theater.land_poly)
if isinstance(intersection, geometry.LineString):
return Point(*intersection.xy[1])
elif isinstance(intersection, geometry.MultiLineString):
return Point(*intersection.geoms[0].xy[1])
"""
logging.error("Didn't find ground position ({})!".format(initial))
return initial
@classmethod
def capture_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
position = to_cp.position
attack_raw_heading = to_cp.position.heading_between_point(from_cp.position)
attack_heading = to_cp.find_radial(attack_raw_heading)
defense_heading = to_cp.find_radial(from_cp.position.heading_between_point(to_cp.position), ignored_radial=attack_heading)
distance = GROUND_DISTANCE
attackers_location = position.point_from_heading(attack_heading, distance)
attackers_location = Conflict._find_ground_position(attackers_location, distance * 2, attack_heading, theater)
defenders_location = position.point_from_heading(defense_heading, 0)
defenders_location = Conflict._find_ground_position(defenders_location, distance * 2, defense_heading, theater)
return cls(
position=position,
heading=heading,
theater=theater,
front_line=front_line,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
size=distance,
ground_attackers_location=attackers_location,
ground_defenders_location=defenders_location,
air_attackers_location=position.point_from_heading(attack_raw_heading, CAPTURE_AIR_ATTACKERS_DISTANCE),
air_defenders_location=position.point_from_heading(_opposite_heading(attack_raw_heading), CAPTURE_AIR_DEFENDERS_DISTANCE)
)
return conflict
@classmethod
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"""
extended = initial.point_from_heading(heading, max_distance)
if theater.landmap is None:
# TODO: Why is this possible?
return extended
def strike_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
position = to_cp.position
attack_raw_heading = to_cp.position.heading_between_point(from_cp.position)
attack_heading = to_cp.find_radial(attack_raw_heading)
defense_heading = to_cp.find_radial(from_cp.position.heading_between_point(to_cp.position), ignored_radial=attack_heading)
p0 = ShapelyPoint(initial.x, initial.y)
p1 = ShapelyPoint(extended.x, extended.y)
line = LineString([p0, p1])
distance = to_cp.size * GROUND_DISTANCE_FACTOR
attackers_location = position.point_from_heading(attack_heading, distance)
attackers_location = Conflict._find_ground_position(
attackers_location, int(distance * 2),
_heading_sum(attack_heading, 180), theater)
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
defenders_location = position.point_from_heading(defense_heading, distance)
defenders_location = Conflict._find_ground_position(
defenders_location, int(distance * 2),
_heading_sum(defense_heading, 180), theater)
# Otherwise extend the front line only up to the intersection.
return initial.point_from_heading(heading, p0.distance(intersection))
return cls(
position=position,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=attackers_location,
ground_defenders_location=defenders_location,
air_attackers_location=position.point_from_heading(attack_raw_heading, STRIKE_AIR_ATTACKERS_DISTANCE),
air_defenders_location=position.point_from_heading(_opposite_heading(attack_raw_heading), STRIKE_AIR_DEFENDERS_DISTANCE)
)
@classmethod
def find_ground_position(
cls,
initial: Point,
max_distance: int,
heading: int,
theater: ConflictTheater,
coerce: bool = True,
) -> Optional[Point]:
"""
Finds the nearest valid ground position along a provided heading and it's inverse up to max_distance.
`coerce=True` will return the closest land position to `initial` regardless of heading or distance
`coerce=False` will return None if a point isn't found
"""
pos = initial
if theater.is_on_land(pos):
return pos
for distance in range(0, int(max_distance), 100):
pos = initial.point_from_heading(heading, distance)
if theater.is_on_land(pos):
return pos
pos = initial.point_from_heading(opposite_heading(heading), distance)
if theater.is_on_land(pos):
return pos
if coerce:
pos = theater.nearest_land_pos(initial)
return pos
logging.error("Didn't find ground position ({})!".format(initial))
return None
def intercept_position(cls, from_cp: ControlPoint, to_cp: ControlPoint) -> Point:
raw_distance = from_cp.position.distance_to_point(to_cp.position) * 1.5
distance = max(min(raw_distance, INTERCEPT_MAX_DISTANCE), INTERCEPT_MIN_DISTANCE)
heading = _heading_sum(from_cp.position.heading_between_point(to_cp.position), random.choice([-1, 1]) * random.randint(60, 100))
return from_cp.position.point_from_heading(heading, distance)
@classmethod
def intercept_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, position: Point, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
heading = from_cp.position.heading_between_point(position)
return cls(
position=position.point_from_heading(position.heading_between_point(to_cp.position), INTERCEPT_CONFLICT_DISTANCE),
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=None,
ground_defenders_location=None,
air_attackers_location=position.point_from_heading(random.randint(*INTERCEPT_ATTACKERS_HEADING) + heading, INTERCEPT_ATTACKERS_DISTANCE),
air_defenders_location=position
)
@classmethod
def ground_attack_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
heading = random.choice(to_cp.radials)
initial_location = to_cp.position.random_point_within(*GROUND_ATTACK_DISTANCE)
position = Conflict._find_ground_position(initial_location, GROUND_INTERCEPT_SPREAD, _heading_sum(heading, 180), theater)
if not position:
heading = to_cp.find_radial(to_cp.position.heading_between_point(from_cp.position))
position = to_cp.position.point_from_heading(heading, to_cp.size * GROUND_DISTANCE_FACTOR)
return cls(
position=position,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=position,
ground_defenders_location=None,
air_attackers_location=None,
air_defenders_location=position.point_from_heading(heading, AIR_DISTANCE),
)
@classmethod
def convoy_strike_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
frontline_position, frontline_heading, frontline_length = Conflict.frontline_vector(from_cp, to_cp, theater)
if not frontline_position:
assert False
heading = frontline_heading
starting_position = Conflict._find_ground_position(frontline_position.point_from_heading(heading, 7000),
GROUND_INTERCEPT_SPREAD,
_opposite_heading(heading), theater)
if not starting_position:
starting_position = frontline_position
destination_position = frontline_position
else:
destination_position = frontline_position
return cls(
position=destination_position,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=None,
ground_defenders_location=starting_position,
air_attackers_location=starting_position.point_from_heading(_opposite_heading(heading), AIR_DISTANCE),
air_defenders_location=starting_position.point_from_heading(heading, AIR_DISTANCE),
)
@classmethod
def frontline_cas_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
assert cls.has_frontline_between(from_cp, to_cp)
position, heading, distance = cls.frontline_vector(from_cp, to_cp, theater)
return cls(
position=position,
heading=heading,
distance=distance,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=None,
ground_defenders_location=None,
air_attackers_location=position.point_from_heading(random.randint(*INTERCEPT_ATTACKERS_HEADING) + heading, AIR_DISTANCE),
air_defenders_location=position.point_from_heading(random.randint(*INTERCEPT_ATTACKERS_HEADING) + _opposite_heading(heading), AIR_DISTANCE),
)
@classmethod
def frontline_cap_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
assert cls.has_frontline_between(from_cp, to_cp)
position, heading, distance = cls.frontline_vector(from_cp, to_cp, theater)
attack_position = position.point_from_heading(heading, random.randint(0, int(distance)))
attackers_position = attack_position.point_from_heading(heading - 90, AIR_DISTANCE)
defenders_position = attack_position.point_from_heading(heading + 90, random.randint(*CAP_CAS_DISTANCE))
return cls(
position=position,
heading=heading,
distance=distance,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
air_attackers_location=attackers_position,
air_defenders_location=defenders_position,
)
@classmethod
def ground_base_attack(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
position = to_cp.position
attack_heading = to_cp.find_radial(to_cp.position.heading_between_point(from_cp.position))
defense_heading = to_cp.find_radial(from_cp.position.heading_between_point(to_cp.position), ignored_radial=attack_heading)
distance = to_cp.size * GROUND_DISTANCE_FACTOR
defenders_location = position.point_from_heading(defense_heading, distance)
defenders_location = Conflict._find_ground_position(
defenders_location, int(distance * 2),
_heading_sum(defense_heading, 180), theater)
return cls(
position=position,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=None,
ground_defenders_location=defenders_location,
air_attackers_location=position.point_from_heading(attack_heading, AIR_DISTANCE),
air_defenders_location=position
)
@classmethod
def naval_intercept_position(cls, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
radial = random.choice(to_cp.sea_radials)
initial_distance = min(int(from_cp.position.distance_to_point(to_cp.position) * NAVAL_INTERCEPT_DISTANCE_FACTOR), NAVAL_INTERCEPT_DISTANCE_MAX)
initial_position = to_cp.position.point_from_heading(radial, initial_distance)
for offset in range(0, initial_distance, NAVAL_INTERCEPT_STEP):
position = initial_position.point_from_heading(_opposite_heading(radial), offset)
if not theater.is_on_land(position):
break
return position
@classmethod
def naval_intercept_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, position: Point, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
attacker_heading = from_cp.position.heading_between_point(to_cp.position)
return cls(
position=position,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=None,
ground_defenders_location=position,
air_attackers_location=position.point_from_heading(attacker_heading, AIR_DISTANCE),
air_defenders_location=position.point_from_heading(_opposite_heading(attacker_heading), AIR_DISTANCE)
)
@classmethod
def transport_conflict(cls, attacker_name: str, defender_name: str, attacker: Country, defender: Country, from_cp: ControlPoint, to_cp: ControlPoint, theater: ConflictTheater):
frontline_position, heading = cls.frontline_position(theater, from_cp, to_cp)
initial_dest = frontline_position.point_from_heading(heading, TRANSPORT_FRONTLINE_DIST)
dest = cls._find_ground_position(initial_dest, from_cp.position.distance_to_point(to_cp.position) / 3, heading, theater)
if not dest:
radial = to_cp.find_radial(to_cp.position.heading_between_point(from_cp.position))
dest = to_cp.position.point_from_heading(radial, to_cp.size * GROUND_DISTANCE_FACTOR)
return cls(
position=dest,
theater=theater,
from_cp=from_cp,
to_cp=to_cp,
attackers_side=attacker_name,
defenders_side=defender_name,
attackers_country=attacker,
defenders_country=defender,
ground_attackers_location=from_cp.position,
ground_defenders_location=frontline_position,
air_attackers_location=from_cp.position.point_from_heading(0, 100),
air_defenders_location=frontline_position
)

View File

@@ -1,92 +0,0 @@
from __future__ import annotations
import itertools
from typing import TYPE_CHECKING
from dcs import Mission
from dcs.mapping import Point
from dcs.point import PointAction
from dcs.unit import Vehicle
from dcs.unitgroup import VehicleGroup
from game.dcs.groundunittype import GroundUnitType
from game.transfers import Convoy
from game.unitmap import UnitMap
from game.utils import kph
if TYPE_CHECKING:
from game import Game
class ConvoyGenerator:
def __init__(self, mission: Mission, game: Game, unit_map: UnitMap) -> None:
self.mission = mission
self.game = game
self.unit_map = unit_map
self.count = itertools.count()
def generate(self) -> None:
# Reset the count to make generation deterministic.
for convoy in self.game.transfers.convoys:
self.generate_convoy(convoy)
def generate_convoy(self, convoy: Convoy) -> VehicleGroup:
group = self._create_mixed_unit_group(
convoy.name,
convoy.route_start,
convoy.units,
convoy.player_owned,
)
group.add_waypoint(
convoy.route_end,
speed=kph(40).kph,
move_formation=PointAction.OnRoad,
)
self.make_drivable(group)
self.unit_map.add_convoy_units(group, convoy)
return group
def _create_mixed_unit_group(
self,
name: str,
position: Point,
units: dict[GroundUnitType, int],
for_player: bool,
) -> VehicleGroup:
country = self.mission.country(
self.game.player_country if for_player else self.game.enemy_country
)
unit_types = list(units.items())
main_unit_type, main_unit_count = unit_types[0]
group = self.mission.vehicle_group(
country,
name,
main_unit_type.dcs_unit_type,
position=position,
group_size=main_unit_count,
move_formation=PointAction.OnRoad,
)
unit_name_counter = itertools.count(main_unit_count + 1)
# pydcs spreads units out by 20 in the Y axis by default. Pick up where it left
# off.
y = itertools.count(position.y + main_unit_count * 20, 20)
for unit_type, count in unit_types[1:]:
for i in range(count):
v = self.mission.vehicle(
f"{name} Unit #{next(unit_name_counter)}", unit_type.dcs_unit_type
)
v.position.x = position.x
v.position.y = next(y)
v.heading = 0
group.add_unit(v)
return group
@staticmethod
def make_drivable(group: VehicleGroup) -> None:
for v in group.units:
if isinstance(v, Vehicle):
v.player_can_drive = True

View File

@@ -1,43 +1,24 @@
import random
from typing import Optional
from dcs.unitgroup import VehicleGroup
from dcs.vehicles import Armor
from game import db, Game
from game.data.groundunitclass import GroundUnitClass
from game.dcs.groundunittype import GroundUnitType
from game.theater.theatergroundobject import VehicleGroupGroundObject
from gen.defenses.armored_group_generator import (
ArmoredGroupGenerator,
FixedSizeArmorGroupGenerator,
)
from game import db
from gen.defenses.armored_group_generator import ArmoredGroupGenerator, FixedSizeArmorGroupGenerator
def generate_armor_group(
faction: str, game: Game, ground_object: VehicleGroupGroundObject
) -> Optional[VehicleGroup]:
def generate_armor_group(faction:str, game, ground_object):
"""
This generate a group of ground units
:return: Generated group
"""
armor_types = (
GroundUnitClass.Apc,
GroundUnitClass.Atgm,
GroundUnitClass.Ifv,
GroundUnitClass.Tank,
)
possible_unit = [
u for u in db.FACTIONS[faction].frontline_units if u.unit_class in armor_types
]
possible_unit = [u for u in db.FACTIONS[faction].frontline_units if u in Armor.__dict__.values()]
if len(possible_unit) > 0:
unit_type = random.choice(possible_unit)
return generate_armor_group_of_type(game, ground_object, unit_type)
return None
def generate_armor_group_of_type(
game: Game, ground_object: VehicleGroupGroundObject, unit_type: GroundUnitType
) -> VehicleGroup:
def generate_armor_group_of_type(game, ground_object, unit_type):
"""
This generate a group of ground units of given type
:return: Generated group
@@ -47,12 +28,7 @@ def generate_armor_group_of_type(
return generator.get_generated_group()
def generate_armor_group_of_type_and_size(
game: Game,
ground_object: VehicleGroupGroundObject,
unit_type: GroundUnitType,
size: int,
) -> VehicleGroup:
def generate_armor_group_of_type_and_size(game, ground_object, unit_type, size: int):
"""
This generate a group of ground units of given type and size
:return: Generated group
@@ -60,3 +36,4 @@ def generate_armor_group_of_type_and_size(
generator = FixedSizeArmorGroupGenerator(game, ground_object, unit_type, size)
generator.generate()
return generator.get_generated_group()

View File

@@ -1,22 +1,16 @@
import random
from game import Game
from game.dcs.groundunittype import GroundUnitType
from game.theater.theatergroundobject import VehicleGroupGroundObject
from gen.sam.group_generator import VehicleGroupGenerator
from gen.sam.group_generator import GroupGenerator
class ArmoredGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]):
def __init__(
self,
game: Game,
ground_object: VehicleGroupGroundObject,
unit_type: GroundUnitType,
) -> None:
super().__init__(game, ground_object)
class ArmoredGroupGenerator(GroupGenerator):
def __init__(self, game, ground_object, unit_type):
super(ArmoredGroupGenerator, self).__init__(game, ground_object)
self.unit_type = unit_type
def generate(self) -> None:
def generate(self):
grid_x = random.randint(2, 3)
grid_y = random.randint(1, 2)
@@ -26,37 +20,25 @@ class ArmoredGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]):
for i in range(grid_x):
for j in range(grid_y):
index = index + 1
self.add_unit(
self.unit_type.dcs_unit_type,
"Armor#" + str(index),
self.position.x + spacing * i,
self.position.y + spacing * j,
self.heading,
)
self.add_unit(self.unit_type, "Armor#" + str(index),
self.position.x + spacing * i,
self.position.y + spacing * j, self.heading)
class FixedSizeArmorGroupGenerator(VehicleGroupGenerator[VehicleGroupGroundObject]):
def __init__(
self,
game: Game,
ground_object: VehicleGroupGroundObject,
unit_type: GroundUnitType,
size: int,
) -> None:
super().__init__(game, ground_object)
class FixedSizeArmorGroupGenerator(GroupGenerator):
def __init__(self, game, ground_object, unit_type, size):
super(FixedSizeArmorGroupGenerator, self).__init__(game, ground_object)
self.unit_type = unit_type
self.size = size
def generate(self) -> None:
def generate(self):
spacing = random.randint(20, 70)
index = 0
for i in range(self.size):
index = index + 1
self.add_unit(
self.unit_type.dcs_unit_type,
"Armor#" + str(index),
self.position.x + spacing * i,
self.position.y,
self.heading,
)
self.add_unit(self.unit_type, "Armor#" + str(index),
self.position.x + spacing * i,
self.position.y, self.heading)

View File

@@ -17,12 +17,11 @@ class EnvironmentGenerator:
self.mission.weather.clouds_thickness = clouds.thickness
self.mission.weather.clouds_density = clouds.density
self.mission.weather.clouds_iprecptns = clouds.precipitation
self.mission.weather.clouds_preset = clouds.preset
def set_fog(self, fog: Optional[Fog]) -> None:
if fog is None:
return
self.mission.weather.fog_visibility = int(fog.visibility.meters)
self.mission.weather.fog_visibility = fog.visibility
self.mission.weather.fog_thickness = fog.thickness
def set_wind(self, wind: WindConditions) -> None:
@@ -30,7 +29,7 @@ class EnvironmentGenerator:
self.mission.weather.wind_at_2000 = wind.at_2000m
self.mission.weather.wind_at_8000 = wind.at_8000m
def generate(self) -> None:
def generate(self):
self.mission.start_time = self.conditions.start_time
self.set_clouds(self.conditions.weather.clouds)
self.set_fog(self.conditions.weather.fog)

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