Merge branch 'dcs-liberation:develop' into develop

This commit is contained in:
Mustang-25 2021-07-27 14:07:41 -07:00 committed by GitHub
commit e5a40bfb69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
145 changed files with 2695 additions and 681 deletions

View File

@ -1,15 +1,23 @@
# 5.0.0
Saves from 3.x are not compatible with 5.0.
Saves from 4.x are not compatible with 5.0.
## Features/Improvements
* **[Campaign]** Weapon data such as fallbacks and introduction years is now moddable. Due to the new architecture to support this, the old data was not automatically migrated.
* **[Campaign]** Era-restricted loadouts will now skip LGBs when no TGP is available in the loadout. This only applies to default loadouts; buddy-lasing can be coordinated with custom loadouts.
* **[Campaign AI]** Overhauled campaign AI target prioritization. This currently only affects the ordering of DEAD missions.
* **[Campaign AI]** Player front line stances can now be automated. Improved stance selection for AI.
* **[Campaign AI]** Reworked layout of hold, join, split, and ingress points. Should result in much shorter flight plans in general while still maintaining safe join/split/hold points.
* **[Campaign AI]** Auto-planning mission range limits are now specified per-aircraft. On average this means that longer range missions will now be plannable. The limit only accounts for the direct distance to the target, not the path taken.
* **[Campaign AI]** Aircraft will now only be automatically purchased or assigned at appropriate bases. Naval aircraft will default to only operating from carriers, Harriers will default to LHAs and shore bases, helicopters will operate from anywhere. This can be customized per-squadron.
* **[Kneeboard]** Minimum required fuel estimates have been added to the kneeboard for aircraft with supporting data (currently only the Hornet).
* **[New Game Wizard]** Can now customize the player's air wing before campaign start to disable or rename squadrons.
## Fixes
* **[Campaign]** Naval control points will no longer claim ground objectives during campaign generation and prevent them from spawning.
# 4.1.0
Saves from 4.0.0 are compatible with 4.1.0.
@ -19,6 +27,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[Campaign]** Air defense sites now generate a fixed number of launchers per type.
* **[Campaign]** Added support for Mariana Islands map.
* **[Mission Generation]** Improvements for better support of the Skynet Plugin and long range SAMs are now acting as EWR
* **[Mission Generation]** SAM sites are now headed towards the center of the conflict
* **[Plugins]** Increased time JTAC Autolase messages stay visible on the UI.
* **[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).
@ -34,6 +43,7 @@ Saves from 4.0.0 are compatible with 4.1.0.
* **[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]** Fix occasional KeyError preventing mission generation when all units of the same type in a convoy were killed.
* **[UI]** Statistics window tick marks are now always integers.
* **[UI]** Statistics window now shows the correct info for the turn

View File

@ -0,0 +1,80 @@
# Measuring estimated fuel consumption
To estimate fuel consumption numbers for an aircraft, create a mission with a
typical heavy load for the aircraft. For example, to measure for the F/A-18C, a
loadout with two bags, two GBU-31s, two sidewinders, an AMRAAM, and an ATFLIR.
Do **not** drop bags or weapons during the test flight.
Start the aircraft on the ground at a large airport (for example, Akrotiri) at a
parking space at the opposite end of the takeoff runway so you can estimate long
taxi fuel consumption.
When you enter the jet, note the amount of fuel below, then taxi to the far end
of the runway. Hold short and note the remaining fuel below.
Follow a typical takeoff pattern for the aircraft. For the F/A-18C, this might
be AB takeoff, reduce to MIL at 350KIAS, and maintian 350KIAS/0.85 mach until
cruise altitude (angles 25).
Once you reach angels 25, pause the game. Note your remaining fuel below and
measure the distance traveled from takeoff. Mark your location on the map.
Level out and increase to cruise speed if needed. Liberation assumes 0.85 mach
for supersonic aircraft, for subsonic aircraft it depends so pick something
reasonable and note your descision in a comment in the file when done. Maintain
speed, heading, and altitude for a long distance (the longer the distance, the
more accurate the result, but be careful to leave enough fuel for the final
section). Once complete, note the distance traveled and the remaining fuel.
Finally, increase speed as you would for an attack. At least MIL power,
potentially use AB sparingly, etc. The goal is to measure fuel consumption per
mile traveled during an attack run.
```
start:
taxi end:
to 25k distance:
at 25k fuel:
cruise (.85 mach) distance:
cruise (.85 mach) end fuel:
combat distance:
combat end fuel:
```
Finally, fill out the data in the aircraft data. Below is an example for the
F/A-18C:
```
start: 15290
taxi end: 15120
climb distance: 40NM
at 25k fuel: 13350
cruise (.85 mach) distance: 100NM
cruise (.85 mach) end fuel: 11140
combat distance: 100NM
combat end fuel: 8390
taxi = start - taxi end = 15290 - 15120 = 170
climb fuel = taxi end - at 25k fuel = 15120 - 13350 = 1770
climb ppm = climb fuel / climb distance = 1770 / 40 = 44.25
cruise fuel = at 25k fuel - cruise end fuel = 13350 - 11140 = 2210
cruise ppm = cruise fuel / cruise distance = 2210 / 100 = 22.1
combat fuel = cruise end fuel - combat end fuel = 11140 - 8390 = 2750
combat ppm = combat fuel / combat distance = 2750 / 100 = 27.5
```
```yaml
fuel:
# Parking A1 to RWY 32 at Akrotiri.
taxi: 170
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
climb_ppm: 44.25
# 0.85 mach for 100NM.
cruise_ppm: 22.1
# ~0.9 mach for 100NM. Occasional AB use.
combat_ppm: 27.5
min_safe: 2000
```
The last entry (`min_safe`) is the minimum amount of fuel that the aircraft
should land with.

View File

@ -150,6 +150,13 @@ class Coalition:
# is handled correctly.
self.transfers.perform_transfers()
def preinit_turn_0(self) -> None:
"""Runs final Coalition initialization.
Final initialization occurs before Game.initialize_turn runs for turn 0.
"""
self.air_wing.populate_for_turn_0()
def initialize_turn(self) -> None:
"""Processes coalition-specific turn initialization.

View File

@ -3,7 +3,8 @@ from typing import Optional, Tuple
from game.commander.missionproposals import ProposedFlight
from game.inventory import GlobalAircraftInventory
from game.squadrons import AirWing, Squadron
from game.theater import ControlPoint
from game.theater import ControlPoint, MissionTarget
from game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ClosestAirfields
from gen.flights.flight import FlightType
@ -25,7 +26,7 @@ class AircraftAllocator:
self.is_player = is_player
def find_squadron_for_flight(
self, flight: ProposedFlight
self, target: MissionTarget, flight: ProposedFlight
) -> Optional[Tuple[ControlPoint, Squadron]]:
"""Finds aircraft suitable for the given mission.
@ -45,17 +46,13 @@ class AircraftAllocator:
on subsequent calls. If the found aircraft are not used, the caller is
responsible for returning them to the inventory.
"""
return self.find_aircraft_for_task(flight, flight.task)
return self.find_aircraft_for_task(target, flight, flight.task)
def find_aircraft_for_task(
self, flight: ProposedFlight, task: FlightType
self, target: MissionTarget, flight: ProposedFlight, task: FlightType
) -> Optional[Tuple[ControlPoint, Squadron]]:
types = aircraft_for_task(task)
airfields_in_range = self.closest_airfields.operational_airfields_within(
flight.max_distance
)
for airfield in airfields_in_range:
for airfield in self.closest_airfields.operational_airfields:
if not airfield.is_friendly(self.is_player):
continue
inventory = self.global_inventory.for_control_point(airfield)
@ -64,13 +61,18 @@ class AircraftAllocator:
continue
if inventory.available(aircraft) < flight.num_aircraft:
continue
distance_to_target = meters(target.distance_to(airfield))
if distance_to_target > aircraft.max_mission_range:
continue
# Valid location with enough aircraft available. Find a squadron to fit
# the role.
squadrons = self.air_wing.auto_assignable_for_task_with_type(
aircraft, task
)
for squadron in squadrons:
if squadron.can_provide_pilots(flight.num_aircraft):
if squadron.operates_from(airfield) and squadron.can_provide_pilots(
flight.num_aircraft
):
inventory.remove_aircraft(aircraft, flight.num_aircraft)
return airfield, squadron
return None

View File

@ -3,7 +3,6 @@ from enum import Enum, auto
from typing import Optional
from game.theater import MissionTarget
from game.utils import Distance
from gen.flights.flight import FlightType
@ -27,9 +26,6 @@ class ProposedFlight:
#: The number of aircraft required.
num_aircraft: int
#: The maximum distance between the objective and the departure airfield.
max_distance: Distance
#: The type of threat this flight defends against if it is an escort. Escort
#: flights will be pruned if the rest of the package is not threatened by
#: the threat they defend against. If this flight is not an escort, this

View File

@ -44,7 +44,7 @@ class PackageBuilder:
caller should return any previously planned flights to the inventory
using release_planned_aircraft.
"""
assignment = self.allocator.find_squadron_for_flight(plan)
assignment = self.allocator.find_squadron_for_flight(self.package.target, plan)
if assignment is None:
return False
airfield, squadron = assignment

View File

@ -83,7 +83,6 @@ class PackageFulfiller:
missing_types.add(flight.task)
purchase_order = AircraftProcurementRequest(
near=mission.location,
range=flight.max_distance,
task_capability=flight.task,
number=flight.num_aircraft,
)

View File

@ -59,28 +59,23 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
coalition.ato.add_package(self.package)
@abstractmethod
def propose_flights(self, doctrine: Doctrine) -> None:
def propose_flights(self) -> None:
...
def propose_flight(
self,
task: FlightType,
num_aircraft: int,
max_distance: Optional[Distance],
escort_type: Optional[EscortType] = None,
) -> None:
if max_distance is None:
max_distance = Distance.inf()
self.flights.append(
ProposedFlight(task, num_aircraft, max_distance, escort_type)
)
self.flights.append(ProposedFlight(task, num_aircraft, escort_type))
@property
def asap(self) -> bool:
return False
def fulfill_mission(self, state: TheaterState) -> bool:
self.propose_flights(state.context.coalition.doctrine)
self.propose_flights()
fulfiller = PackageFulfiller(
state.context.coalition,
state.context.theater,
@ -92,20 +87,9 @@ class PackagePlanningTask(TheaterCommanderTask, Generic[MissionTargetT]):
)
return self.package is not None
def propose_common_escorts(self, doctrine: Doctrine) -> None:
self.propose_flight(
FlightType.SEAD_ESCORT,
2,
doctrine.mission_ranges.offensive,
EscortType.Sead,
)
self.propose_flight(
FlightType.ESCORT,
2,
doctrine.mission_ranges.offensive,
EscortType.AirToAir,
)
def propose_common_escorts(self) -> None:
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)
def iter_iads_ranges(
self, state: TheaterState, range_type: RangeType

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater import MissionTarget
from gen.flights.flight import FlightType
@ -19,8 +18,8 @@ class PlanAewc(PackagePlanningTask[MissionTarget]):
def apply_effects(self, state: TheaterState) -> None:
state.aewc_targets.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.AEWC, 1, doctrine.mission_ranges.aewc)
def propose_flights(self) -> None:
self.propose_flight(FlightType.AEWC, 1)
@property
def asap(self) -> bool:

View File

@ -5,7 +5,6 @@ from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater.theatergroundobject import NavalGroundObject
from gen.flights.flight import FlightType
@ -22,11 +21,6 @@ class PlanAntiShip(PackagePlanningTask[NavalGroundObject]):
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_ship(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive)
self.propose_flight(
FlightType.ESCORT,
2,
doctrine.mission_ranges.offensive,
EscortType.AirToAir,
)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.transfers import CargoShip
from gen.flights.flight import FlightType
@ -21,6 +20,6 @@ class PlanAntiShipping(PackagePlanningTask[CargoShip]):
def apply_effects(self, state: TheaterState) -> None:
state.enemy_shipping.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.ANTISHIP, 2, doctrine.mission_ranges.offensive)
self.propose_common_escorts(doctrine)
def propose_flights(self) -> None:
self.propose_flight(FlightType.ANTISHIP, 2)
self.propose_common_escorts()

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater.theatergroundobject import VehicleGroupGroundObject
from gen.flights.flight import FlightType
@ -21,6 +20,6 @@ class PlanBai(PackagePlanningTask[VehicleGroupGroundObject]):
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_garrison(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive)
self.propose_common_escorts(doctrine)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@ -19,5 +18,5 @@ class PlanBarcap(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None:
state.barcaps_needed[self.target] -= 1
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.BARCAP, 2, doctrine.mission_ranges.cap)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BARCAP, 2)

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater import FrontLine
from gen.flights.flight import FlightType
@ -19,6 +18,6 @@ class PlanCas(PackagePlanningTask[FrontLine]):
def apply_effects(self, state: TheaterState) -> None:
state.vulnerable_front_lines.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.CAS, 2, doctrine.mission_ranges.cas)
self.propose_flight(FlightType.TARCAP, 2, doctrine.mission_ranges.cap)
def propose_flights(self) -> None:
self.propose_flight(FlightType.CAS, 2)
self.propose_flight(FlightType.TARCAP, 2)

View File

@ -21,6 +21,6 @@ class PlanConvoyInterdiction(PackagePlanningTask[Convoy]):
def apply_effects(self, state: TheaterState) -> None:
state.enemy_convoys.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.BAI, 2, doctrine.mission_ranges.offensive)
self.propose_common_escorts(doctrine)
def propose_flights(self) -> None:
self.propose_flight(FlightType.BAI, 2)
self.propose_common_escorts()

View File

@ -5,7 +5,6 @@ from dataclasses import dataclass
from game.commander.missionproposals import EscortType
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater.theatergroundobject import IadsGroundObject
from gen.flights.flight import FlightType
@ -25,8 +24,8 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
def apply_effects(self, state: TheaterState) -> None:
state.eliminate_air_defense(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.DEAD, 2, doctrine.mission_ranges.offensive)
def propose_flights(self) -> None:
self.propose_flight(FlightType.DEAD, 2)
# Only include SEAD against SAMs that still have emitters. No need to
# suppress an EWR, and SEAD isn't useful against a SAM that no longer has a
@ -41,18 +40,7 @@ class PlanDead(PackagePlanningTask[IadsGroundObject]):
# package is *only* threatened by the target though. Could be improved, but
# needs a decent refactor to the escort planning to do so.
if self.target.has_live_radar_sam:
self.propose_flight(FlightType.SEAD, 2, doctrine.mission_ranges.offensive)
self.propose_flight(FlightType.SEAD, 2)
else:
self.propose_flight(
FlightType.SEAD_ESCORT,
2,
doctrine.mission_ranges.offensive,
EscortType.Sead,
)
self.propose_flight(
FlightType.ESCORT,
2,
doctrine.mission_ranges.offensive,
EscortType.AirToAir,
)
self.propose_flight(FlightType.SEAD_ESCORT, 2, EscortType.Sead)
self.propose_flight(FlightType.ESCORT, 2, EscortType.AirToAir)

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater import ControlPoint
from gen.flights.flight import FlightType
@ -23,10 +22,8 @@ class PlanOcaStrike(PackagePlanningTask[ControlPoint]):
def apply_effects(self, state: TheaterState) -> None:
state.oca_targets.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.OCA_RUNWAY, 2, doctrine.mission_ranges.offensive)
def propose_flights(self) -> None:
self.propose_flight(FlightType.OCA_RUNWAY, 2)
if self.aircraft_cold_start:
self.propose_flight(
FlightType.OCA_AIRCRAFT, 2, doctrine.mission_ranges.offensive
)
self.propose_common_escorts(doctrine)
self.propose_flight(FlightType.OCA_AIRCRAFT, 2)
self.propose_common_escorts()

View File

@ -4,7 +4,6 @@ from dataclasses import dataclass
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater import MissionTarget
from gen.flights.flight import FlightType
@ -19,5 +18,5 @@ class PlanRefueling(PackagePlanningTask[MissionTarget]):
def apply_effects(self, state: TheaterState) -> None:
state.refueling_targets.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.REFUELING, 1, doctrine.mission_ranges.refueling)
def propose_flights(self) -> None:
self.propose_flight(FlightType.REFUELING, 1)

View File

@ -5,7 +5,6 @@ from typing import Any
from game.commander.tasks.packageplanningtask import PackagePlanningTask
from game.commander.theaterstate import TheaterState
from game.data.doctrine import Doctrine
from game.theater.theatergroundobject import TheaterGroundObject
from gen.flights.flight import FlightType
@ -22,6 +21,6 @@ class PlanStrike(PackagePlanningTask[TheaterGroundObject[Any]]):
def apply_effects(self, state: TheaterState) -> None:
state.strike_targets.remove(self.target)
def propose_flights(self, doctrine: Doctrine) -> None:
self.propose_flight(FlightType.STRIKE, 2, doctrine.mission_ranges.offensive)
self.propose_common_escorts(doctrine)
def propose_flights(self) -> None:
self.propose_flight(FlightType.STRIKE, 2)
self.propose_common_escorts()

View File

@ -1,4 +1,4 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
@ -18,15 +18,6 @@ class GroundUnitProcurementRatios:
return 0.0
@dataclass(frozen=True)
class MissionPlannerMaxRanges:
cap: Distance = field(default=nautical_miles(100))
cas: Distance = field(default=nautical_miles(50))
offensive: Distance = field(default=nautical_miles(150))
aewc: Distance = field(default=Distance.inf())
refueling: Distance = field(default=nautical_miles(200))
@dataclass(frozen=True)
class Doctrine:
cas: bool
@ -47,8 +38,13 @@ class Doctrine:
#: fallback flight plan layout (when the departure airfield is near a threat zone).
join_distance: Distance
#: The distance between the ingress point (beginning of the attack) and target.
ingress_distance: Distance
#: The maximum distance between the ingress point (beginning of the attack) and
#: target.
max_ingress_distance: Distance
#: The minimum distance between the ingress point (beginning of the attack) and
#: target.
min_ingress_distance: Distance
ingress_altitude: Distance
@ -83,16 +79,33 @@ class Doctrine:
ground_unit_procurement_ratios: GroundUnitProcurementRatios
mission_ranges: MissionPlannerMaxRanges = field(default=MissionPlannerMaxRanges())
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "ingress_distance" not in state:
state["ingress_distance"] = state["ingress_egress_distance"]
del state["ingress_egress_distance"]
if "max_ingress_distance" not in state:
try:
state["max_ingress_distance"] = state["ingress_distance"]
del state["ingress_distance"]
except KeyError:
state["max_ingress_distance"] = state["ingress_egress_distance"]
del state["ingress_egress_distance"]
max_ip: Distance = state["max_ingress_distance"]
if "min_ingress_distance" not in state:
if max_ip < nautical_miles(10):
min_ip = nautical_miles(5)
else:
min_ip = nautical_miles(10)
state["min_ingress_distance"] = min_ip
self.__dict__.update(state)
class MissionPlannerMaxRanges:
@has_save_compat_for(5)
def __init__(self) -> None:
pass
MODERN_DOCTRINE = Doctrine(
cap=True,
cas=True,
@ -100,10 +113,11 @@ MODERN_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(25000),
hold_distance=nautical_miles(15),
hold_distance=nautical_miles(25),
push_distance=nautical_miles(20),
join_distance=nautical_miles(20),
ingress_distance=nautical_miles(45),
max_ingress_distance=nautical_miles(45),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(20000),
min_patrol_altitude=feet(15000),
max_patrol_altitude=feet(33000),
@ -136,10 +150,11 @@ COLDWAR_DOCTRINE = Doctrine(
strike=True,
antiship=True,
rendezvous_altitude=feet(22000),
hold_distance=nautical_miles(10),
hold_distance=nautical_miles(15),
push_distance=nautical_miles(10),
join_distance=nautical_miles(10),
ingress_distance=nautical_miles(30),
max_ingress_distance=nautical_miles(30),
min_ingress_distance=nautical_miles(10),
ingress_altitude=feet(18000),
min_patrol_altitude=feet(10000),
max_patrol_altitude=feet(24000),
@ -171,11 +186,12 @@ WWII_DOCTRINE = Doctrine(
sead=False,
strike=True,
antiship=True,
hold_distance=nautical_miles(5),
hold_distance=nautical_miles(10),
push_distance=nautical_miles(5),
join_distance=nautical_miles(5),
rendezvous_altitude=feet(10000),
ingress_distance=nautical_miles(7),
max_ingress_distance=nautical_miles(7),
min_ingress_distance=nautical_miles(5),
ingress_altitude=feet(8000),
min_patrol_altitude=feet(4000),
max_patrol_altitude=feet(15000),

View File

@ -4,6 +4,7 @@ import datetime
import inspect
import logging
from dataclasses import dataclass, field
from enum import unique, Enum
from functools import cached_property
from pathlib import Path
from typing import Iterator, Optional, Any, ClassVar
@ -61,7 +62,7 @@ class Weapon:
duplicate = cls._by_clsid[weapon.clsid]
raise ValueError(
"Weapon CLSID used in more than one weapon type: "
f"{duplicate.name} and {weapon.name}"
f"{duplicate.name} and {weapon.name}: {weapon.clsid}"
)
cls._by_clsid[weapon.clsid] = weapon
@ -91,6 +92,13 @@ class Weapon:
fallback = fallback.fallback
@unique
class WeaponType(Enum):
LGB = "LGB"
TGP = "TGP"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class WeaponGroup:
"""Group of "identical" weapons loaded from resources/weapons.
@ -101,7 +109,10 @@ class WeaponGroup:
"""
#: The name of the weapon group in the resource file.
name: str = field(compare=False)
name: str
#: The type of the weapon group.
type: WeaponType = field(compare=False)
#: The year of introduction.
introduction_year: Optional[int] = field(compare=False)
@ -152,9 +163,13 @@ class WeaponGroup:
with group_file_path.open(encoding="utf8") as group_file:
data = yaml.safe_load(group_file)
name = data["name"]
try:
weapon_type = WeaponType(data["type"])
except KeyError:
weapon_type = WeaponType.UNKNOWN
year = data.get("year")
fallback_name = data.get("fallback")
group = WeaponGroup(name, year, fallback_name)
group = WeaponGroup(name, weapon_type, year, fallback_name)
for clsid in data["clsids"]:
weapon = Weapon(clsid, group)
Weapon.register(weapon)
@ -163,7 +178,12 @@ class WeaponGroup:
@classmethod
def register_clean_pylon(cls) -> None:
group = WeaponGroup("Clean pylon", introduction_year=None, fallback_name=None)
group = WeaponGroup(
"Clean pylon",
type=WeaponType.UNKNOWN,
introduction_year=None,
fallback_name=None,
)
cls.register(group)
weapon = Weapon("<CLEAN>", group)
Weapon.register(weapon)
@ -172,7 +192,12 @@ class WeaponGroup:
@classmethod
def register_unknown_weapons(cls, seen_clsids: set[str]) -> None:
unknown_weapons = set(weapon_ids.keys()) - seen_clsids
group = WeaponGroup("Unknown", introduction_year=None, fallback_name=None)
group = WeaponGroup(
"Unknown",
type=WeaponType.UNKNOWN,
introduction_year=None,
fallback_name=None,
)
cls.register(group)
for clsid in unknown_weapons:
weapon = Weapon(clsid, group)
@ -181,6 +206,8 @@ class WeaponGroup:
@classmethod
def load_all(cls) -> None:
if cls._loaded:
return
seen_clsids: set[str] = set()
for group in cls._each_weapon_group():
cls.register(group)

View File

@ -29,7 +29,7 @@ from game.radio.channels import (
ViggenRadioChannelAllocator,
NoOpChannelAllocator,
)
from game.utils import Distance, Speed, feet, kph, knots
from game.utils import Distance, Speed, feet, kph, knots, nautical_miles
if TYPE_CHECKING:
from gen.aircraft import FlightData
@ -105,6 +105,35 @@ class PatrolConfig:
)
@dataclass(frozen=True)
class FuelConsumption:
#: The estimated taxi fuel requirement, in pounds.
taxi: int
#: The estimated fuel consumption for a takeoff climb, in pounds per nautical mile.
climb: float
#: The estimated fuel consumption for cruising, in pounds per nautical mile.
cruise: float
#: The estimated fuel consumption for combat speeds, in pounds per nautical mile.
combat: float
#: The minimum amount of fuel that the aircraft should land with, in pounds. This is
#: a reserve amount for landing delays or emergencies.
min_safe: int
@classmethod
def from_data(cls, data: dict[str, Any]) -> FuelConsumption:
return FuelConsumption(
int(data["taxi"]),
float(data["climb_ppm"]),
float(data["cruise_ppm"]),
float(data["combat_ppm"]),
int(data["min_safe"]),
)
# TODO: Split into PlaneType and HelicopterType?
@dataclass(frozen=True)
class AircraftType(UnitType[Type[FlyingType]]):
@ -112,13 +141,20 @@ class AircraftType(UnitType[Type[FlyingType]]):
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.
# 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]
#: The maximum range between the origin airfield and the target for which the auto-
#: planner will consider this aircraft usable for a mission.
max_mission_range: Distance
fuel_consumption: Optional[FuelConsumption]
intra_flight_radio: Optional[Radio]
channel_allocator: Optional[RadioChannelAllocator]
channel_namer: Type[ChannelNamer]
@ -230,6 +266,25 @@ class AircraftType(UnitType[Type[FlyingType]]):
radio_config = RadioConfig.from_data(data.get("radios", {}))
patrol_config = PatrolConfig.from_data(data.get("patrol", {}))
try:
mission_range = nautical_miles(int(data["max_range"]))
except (KeyError, ValueError):
mission_range = (
nautical_miles(50) if aircraft.helicopter else nautical_miles(150)
)
logging.warning(
f"{aircraft.id} does not specify a max_range. Defaulting to "
f"{mission_range.nautical_miles}NM"
)
fuel_data = data.get("fuel")
if fuel_data is not None:
fuel_consumption: Optional[FuelConsumption] = FuelConsumption.from_data(
fuel_data
)
else:
fuel_consumption = None
try:
introduction = data["introduced"]
if introduction is None:
@ -257,6 +312,8 @@ class AircraftType(UnitType[Type[FlyingType]]):
max_group_size=data.get("max_group_size", aircraft.group_size_max),
patrol_altitude=patrol_config.altitude,
patrol_speed=patrol_config.speed,
max_mission_range=mission_range,
fuel_consumption=fuel_consumption,
intra_flight_radio=radio_config.intra_flight,
channel_allocator=radio_config.channel_allocator,
channel_namer=radio_config.channel_namer,

View File

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

View File

@ -0,0 +1,108 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import Point as ShapelyPoint, Polygon, MultiPolygon
from game.theater import ConflictTheater
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class HoldZoneGeometry:
"""Defines the zones used for finding optimal hold point placement.
The zones themselves are stored in the class rather than just the resulting hold
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self,
target: Point,
home: Point,
ip: Point,
join: Point,
coalition: Coalition,
theater: ConflictTheater,
) -> None:
# Hold points are placed one of two ways. Either approach guarantees:
#
# * Safe hold point.
# * Minimum distance to the join point.
# * Not closer to the target than the join point.
#
# 1. As near the join point as possible with a specific distance from the
# departure airfield. This prevents loitering directly above the airfield but
# also keeps the hold point close to the departure airfield.
#
# 2. Alternatively, if the entire home zone is excluded by the above criteria,
# as neat the departure airfield as possible within a minimum distance from
# the join point, with a restricted turn angle at the join point. This
# handles the case where we need to backtrack from the departure airfield and
# the join point to place the hold point, but the turn angle limit restricts
# the maximum distance of the backtrack while maintaining the direction of
# the flight plan.
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.join = ShapelyPoint(join.x, join.y)
self.join_bubble = self.join.buffer(coalition.doctrine.push_distance.meters)
join_to_target_distance = join.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(
join_to_target_distance
)
self.home_bubble = self.home.buffer(coalition.doctrine.hold_distance.meters)
excluded_zones = shapely.ops.unary_union(
[self.join_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
join_heading = ip.heading_between_point(join)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
join_limit_ccw = join.point_from_heading(
join_heading - turn_limit, large_distance
)
join_limit_cw = join.point_from_heading(
join_heading + turn_limit, large_distance
)
join_direction_limit_wedge = Polygon(
[
(join.x, join.y),
(join_limit_ccw.x, join_limit_ccw.y),
(join_limit_cw.x, join_limit_cw.y),
]
)
permissible_zones = (
coalition.nav_mesh.map_bounds(theater)
.intersection(join_direction_limit_wedge)
.difference(self.excluded_zones)
.difference(self.home_bubble)
)
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
self.preferred_lines = self.home_bubble.boundary.difference(self.excluded_zones)
def find_best_hold_point(self) -> Point:
if self.preferred_lines.is_empty:
hold, _ = shapely.ops.nearest_points(self.permissible_zones, self.home)
else:
hold, _ = shapely.ops.nearest_points(self.preferred_lines, self.join)
return Point(hold.x, hold.y)

View File

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

View File

@ -0,0 +1,103 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import shapely.ops
from dcs import Point
from shapely.geometry import (
Point as ShapelyPoint,
Polygon,
MultiPolygon,
MultiLineString,
)
from game.utils import nautical_miles
if TYPE_CHECKING:
from game.coalition import Coalition
class JoinZoneGeometry:
"""Defines the zones used for finding optimal join point placement.
The zones themselves are stored in the class rather than just the resulting join
point so that the zones can be drawn in the map for debugging purposes.
"""
def __init__(
self, target: Point, home: Point, ip: Point, coalition: Coalition
) -> None:
# Normal join placement is based on the path from home to the IP. If no path is
# found it means that the target is on a direct path. In that case we instead
# want to enforce that the join point is:
#
# * Not closer to the target than the IP.
# * Not too close to the home airfield.
# * Not threatened.
# * A minimum distance from the IP.
# * Not too sharp a turn at the ingress point.
self.ip = ShapelyPoint(ip.x, ip.y)
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.ip_bubble = self.ip.buffer(coalition.doctrine.join_distance.meters)
ip_distance = ip.distance_to_point(target)
self.target_bubble = ShapelyPoint(target.x, target.y).buffer(ip_distance)
# The minimum distance between the home location and the IP.
min_distance_from_home = nautical_miles(5)
self.home_bubble = self.home.buffer(min_distance_from_home.meters)
excluded_zones = shapely.ops.unary_union(
[self.ip_bubble, self.target_bubble, self.threat_zone]
)
if not isinstance(excluded_zones, MultiPolygon):
excluded_zones = MultiPolygon([excluded_zones])
self.excluded_zones = excluded_zones
ip_heading = target.heading_between_point(ip)
# Arbitrarily large since this is later constrained by the map boundary, and
# we'll be picking a location close to the IP anyway. Just used to avoid real
# distance calculations to project to the map edge.
large_distance = nautical_miles(400).meters
turn_limit = 40
ip_limit_ccw = ip.point_from_heading(ip_heading - turn_limit, large_distance)
ip_limit_cw = ip.point_from_heading(ip_heading + turn_limit, large_distance)
ip_direction_limit_wedge = Polygon(
[
(ip.x, ip.y),
(ip_limit_ccw.x, ip_limit_ccw.y),
(ip_limit_cw.x, ip_limit_cw.y),
]
)
permissible_zones = ip_direction_limit_wedge.difference(
self.excluded_zones
).difference(self.home_bubble)
if permissible_zones.is_empty:
permissible_zones = MultiPolygon([])
if not isinstance(permissible_zones, MultiPolygon):
permissible_zones = MultiPolygon([permissible_zones])
self.permissible_zones = permissible_zones
preferred_lines = ip_direction_limit_wedge.intersection(
self.excluded_zones.boundary
).difference(self.home_bubble)
if preferred_lines.is_empty:
preferred_lines = MultiLineString([])
if not isinstance(preferred_lines, MultiLineString):
preferred_lines = MultiLineString([preferred_lines])
self.preferred_lines = preferred_lines
def find_best_join_point(self) -> Point:
if self.preferred_lines.is_empty:
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
else:
join, _ = shapely.ops.nearest_points(self.preferred_lines, self.home)
return Point(join.x, join.y)

View File

@ -294,6 +294,8 @@ class Game:
def begin_turn_0(self) -> None:
"""Initialization for the first turn of the game."""
self.turn = 0
self.blue.preinit_turn_0()
self.red.preinit_turn_0()
self.initialize_turn()
def pass_turn(self, no_action: bool = False) -> None:

View File

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

View File

@ -11,7 +11,7 @@ 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 game.utils import meters
from gen.flights.ai_flight_planner_db import aircraft_for_task
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.flights.flight import FlightType
@ -25,15 +25,13 @@ 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}"
return f"{self.number} ship {task} near {target}"
class ProcurementAi:
@ -211,24 +209,28 @@ class ProcurementAi:
return GroundUnitClass.Tank
return worst_balanced
def _affordable_aircraft_for_task(
self,
task: FlightType,
airbase: ControlPoint,
number: int,
max_price: float,
def affordable_aircraft_for(
self, request: AircraftProcurementRequest, airbase: ControlPoint, budget: float
) -> Optional[AircraftType]:
best_choice: Optional[AircraftType] = None
for unit in aircraft_for_task(task):
for unit in aircraft_for_task(request.task_capability):
if unit not in self.faction.aircrafts:
continue
if unit.price * number > max_price:
if unit.price * request.number > budget:
continue
if not airbase.can_operate(unit):
continue
distance_to_target = meters(request.near.distance_to(airbase))
if distance_to_target > unit.max_mission_range:
continue
for squadron in self.air_wing.squadrons_for(unit):
if task in squadron.auto_assignable_mission_types:
if (
squadron.operates_from(airbase)
and request.task_capability
in squadron.auto_assignable_mission_types
):
break
else:
continue
@ -241,13 +243,6 @@ class ProcurementAi:
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]:
@ -293,7 +288,7 @@ class ProcurementAi:
) -> Iterator[ControlPoint]:
distance_cache = ObjectiveDistanceCache.get_closest_airfields(request.near)
threatened = []
for cp in distance_cache.operational_airfields_within(request.range):
for cp in distance_cache.operational_airfields:
if not cp.is_friendly(self.is_player):
continue
if cp.unclaimed_parking(self.game) < request.number:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import itertools
import logging
import random
@ -26,6 +27,7 @@ if TYPE_CHECKING:
from game import Game
from game.coalition import Coalition
from gen.flights.flight import FlightType
from game.theater import ControlPoint
@dataclass
@ -73,6 +75,33 @@ class Pilot:
return Pilot(faker.name())
@dataclass(frozen=True)
class OperatingBases:
shore: bool
carrier: bool
lha: bool
@classmethod
def default_for_aircraft(cls, aircraft: AircraftType) -> OperatingBases:
if aircraft.dcs_unit_type.helicopter:
# Helicopters operate from anywhere by default.
return OperatingBases(shore=True, carrier=True, lha=True)
if aircraft.lha_capable:
# Marine aircraft operate from LHAs and the shore by default.
return OperatingBases(shore=True, carrier=False, lha=True)
if aircraft.carrier_capable:
# Carrier aircraft operate from carriers by default.
return OperatingBases(shore=False, carrier=True, lha=False)
# And the rest are only capable of shore operation.
return OperatingBases(shore=True, carrier=False, lha=False)
@classmethod
def from_yaml(cls, aircraft: AircraftType, data: dict[str, bool]) -> OperatingBases:
return dataclasses.replace(
OperatingBases.default_for_aircraft(aircraft), **data
)
@dataclass
class Squadron:
name: str
@ -82,6 +111,7 @@ class Squadron:
aircraft: AircraftType
livery: Optional[str]
mission_types: tuple[FlightType, ...]
operating_bases: OperatingBases
#: 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
@ -101,9 +131,6 @@ class Squadron:
settings: Settings = field(hash=False, compare=False)
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.settings.squadron_pilot_limit)
self.auto_assignable_mission_types = set(self.mission_types)
def __str__(self) -> str:
@ -181,6 +208,11 @@ class Squadron:
self.current_roster.extend(new_pilots)
self.available_pilots.extend(new_pilots)
def populate_for_turn_0(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.settings.squadron_pilot_limit)
def replenish_lost_pilots(self) -> None:
if not self.pilot_limits_enabled:
return
@ -250,6 +282,14 @@ class Squadron:
def can_auto_assign(self, task: FlightType) -> bool:
return task in self.auto_assignable_mission_types
def operates_from(self, control_point: ControlPoint) -> bool:
if control_point.is_carrier:
return self.operating_bases.carrier
elif control_point.is_lha:
return self.operating_bases.lha
else:
return self.operating_bases.shore
def pilot_at_index(self, index: int) -> Pilot:
return self.current_roster[index]
@ -288,6 +328,7 @@ class Squadron:
aircraft=unit_type,
livery=data.get("livery"),
mission_types=tuple(mission_types),
operating_bases=OperatingBases.from_yaml(unit_type, data.get("bases", {})),
pilot_pool=pilots,
coalition=coalition,
settings=game.settings,
@ -377,6 +418,7 @@ class AirWing:
aircraft=aircraft,
livery=None,
mission_types=tuple(tasks_for_aircraft(aircraft)),
operating_bases=OperatingBases.default_for_aircraft(aircraft),
pilot_pool=[],
coalition=coalition,
settings=game.settings,
@ -414,6 +456,10 @@ class AirWing:
def squadron_at_index(self, index: int) -> Squadron:
return list(self.iter_squadrons())[index]
def populate_for_turn_0(self) -> None:
for squadron in self.iter_squadrons():
squadron.populate_for_turn_0()
def replenish(self) -> None:
for squadron in self.iter_squadrons():
squadron.replenish_lost_pilots()

View File

@ -59,7 +59,7 @@ 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
from ..utils import Distance, Heading, meters
if TYPE_CHECKING:
from . import TheaterGroundObject
@ -389,8 +389,10 @@ class MizCampaignLoader:
origin, list(reversed(waypoints))
)
def objective_info(self, near: Positioned) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position)
def objective_info(
self, near: Positioned, allow_naval: bool = False
) -> Tuple[ControlPoint, Distance]:
closest = self.theater.closest_control_point(near.position, allow_naval)
distance = meters(closest.position.distance_to_point(near.position))
return closest, distance
@ -398,85 +400,113 @@ class MizCampaignLoader:
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)
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for ship in self.ships:
closest, distance = self.objective_info(ship)
closest, distance = self.objective_info(ship, allow_naval=True)
closest.preset_locations.ships.append(
PointWithHeading.from_point(ship.position, ship.units[0].heading)
PointWithHeading.from_point(
ship.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
group.position, Heading.from_degrees(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)
PointWithHeading.from_point(
static.position, Heading.from_degrees(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)
PointWithHeading.from_point(
static.position, Heading.from_degrees(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)
PointWithHeading.from_point(
static.position, Heading.from_degrees(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)
PointWithHeading.from_point(
static.position, Heading.from_degrees(static.units[0].heading)
)
)
for scenery_group in self.scenery:
@ -497,6 +527,17 @@ class ReferencePoint:
image_coordinates: Point
@dataclass(frozen=True)
class SeasonalConditions:
# Units are inHg and degrees Celsius
# Future improvement: add clouds/precipitation
summer_avg_pressure: float
winter_avg_pressure: float
summer_avg_temperature: float
winter_avg_temperature: float
temperature_day_night_difference: float
class ConflictTheater:
terrain: Terrain
@ -633,10 +674,14 @@ class ConflictTheater:
def enemy_points(self) -> List[ControlPoint]:
return list(self.control_points_for(player=False))
def closest_control_point(self, point: Point) -> ControlPoint:
def closest_control_point(
self, point: Point, allow_naval: bool = False
) -> ControlPoint:
closest = self.controlpoints[0]
closest_distance = point.distance_to_point(closest.position)
for control_point in self.controlpoints[1:]:
if control_point.is_fleet and not allow_naval:
continue
distance = point.distance_to_point(control_point.position)
if distance < closest_distance:
closest = control_point
@ -719,6 +764,10 @@ class ConflictTheater:
MizCampaignLoader(directory / miz, t).populate_theater()
return t
@property
def seasonal_conditions(self) -> SeasonalConditions:
raise NotImplementedError
@property
def projection_parameters(self) -> TransverseMercator:
raise NotImplementedError
@ -748,6 +797,16 @@ class CaucasusTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=30.02, # TODO: More science
winter_avg_pressure=29.72, # TODO: More science
summer_avg_temperature=22.5,
winter_avg_temperature=3.0,
temperature_day_night_difference=6.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .caucasus import PARAMETERS
@ -770,6 +829,16 @@ class PersianGulfTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=29.98, # TODO: More science
winter_avg_pressure=29.80, # TODO: More science
summer_avg_temperature=32.5,
winter_avg_temperature=15.0,
temperature_day_night_difference=2.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .persiangulf import PARAMETERS
@ -792,6 +861,16 @@ class NevadaTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=30.02, # TODO: More science
winter_avg_pressure=29.72, # TODO: More science
summer_avg_temperature=31.5,
winter_avg_temperature=5.0,
temperature_day_night_difference=6.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .nevada import PARAMETERS
@ -814,6 +893,16 @@ class NormandyTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=30.02, # TODO: More science
winter_avg_pressure=29.72, # TODO: More science
summer_avg_temperature=20.0,
winter_avg_temperature=0.0,
temperature_day_night_difference=5.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .normandy import PARAMETERS
@ -836,6 +925,16 @@ class TheChannelTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=30.02, # TODO: More science
winter_avg_pressure=29.72, # TODO: More science
summer_avg_temperature=20.0,
winter_avg_temperature=0.0,
temperature_day_night_difference=5.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .thechannel import PARAMETERS
@ -858,6 +957,16 @@ class SyriaTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=29.98, # TODO: More science
winter_avg_pressure=29.86, # TODO: More science
summer_avg_temperature=28.5,
winter_avg_temperature=10.0,
temperature_day_night_difference=8.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .syria import PARAMETERS
@ -877,6 +986,16 @@ class MarianaIslandsTheater(ConflictTheater):
"night": (0, 5),
}
@property
def seasonal_conditions(self) -> SeasonalConditions:
return SeasonalConditions(
summer_avg_pressure=30.02, # TODO: More science
winter_avg_pressure=29.82, # TODO: More science
summer_avg_temperature=28.0,
winter_avg_temperature=27.0,
temperature_day_night_difference=1.0,
)
@property
def projection_parameters(self) -> TransverseMercator:
from .marianaislands import PARAMETERS

View File

@ -35,6 +35,7 @@ from dcs.unit import Unit
from game import db
from game.point_with_heading import PointWithHeading
from game.scenery_group import SceneryGroup
from game.utils import Heading
from gen.flights.closestairfields import ObjectiveDistanceCache
from gen.ground_forces.combat_stance import CombatStance
from gen.runways import RunwayAssigner, RunwayData
@ -335,7 +336,7 @@ class ControlPoint(MissionTarget, ABC):
@property
@abstractmethod
def heading(self) -> int:
def heading(self) -> Heading:
...
def __str__(self) -> str:
@ -838,8 +839,8 @@ class Airfield(ControlPoint):
return len(self.airport.parking_slots)
@property
def heading(self) -> int:
return self.airport.runways[0].heading
def heading(self) -> Heading:
return Heading.from_degrees(self.airport.runways[0].heading)
def runway_is_operational(self) -> bool:
return not self.runway_status.damaged
@ -903,8 +904,8 @@ class NavalControlPoint(ControlPoint, ABC):
yield from super().mission_types(for_player)
@property
def heading(self) -> int:
return 0 # TODO compute heading
def heading(self) -> Heading:
return Heading.from_degrees(0) # TODO compute heading
def find_main_tgo(self) -> GenericCarrierGroundObject:
for g in self.ground_objects:
@ -933,7 +934,9 @@ class NavalControlPoint(ControlPoint, ABC):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
# TODO: Assign TACAN and ICLS earlier so we don't need this.
fallback = RunwayData(self.full_name, runway_heading=0, runway_name="")
fallback = RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
return dynamic_runways.get(self.name, fallback)
@property
@ -1071,14 +1074,16 @@ class OffMapSpawn(ControlPoint):
return True
@property
def heading(self) -> int:
return 0
def heading(self) -> Heading:
return Heading.from_degrees(0)
def active_runway(
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
logging.warning("TODO: Off map spawns have no runways.")
return RunwayData(self.full_name, runway_heading=0, runway_name="")
return RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
@property
def runway_status(self) -> RunwayStatus:
@ -1120,7 +1125,9 @@ class Fob(ControlPoint):
self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]
) -> RunwayData:
logging.warning("TODO: FOBs have no runways.")
return RunwayData(self.full_name, runway_heading=0, runway_name="")
return RunwayData(
self.full_name, runway_heading=Heading.from_degrees(0), runway_name=""
)
@property
def runway_status(self) -> RunwayStatus:
@ -1142,8 +1149,8 @@ class Fob(ControlPoint):
return False
@property
def heading(self) -> int:
return 0
def heading(self) -> Heading:
return Heading.from_degrees(0)
@property
def can_deploy_ground_units(self) -> bool:

View File

@ -11,7 +11,7 @@ from .controlpoint import (
ControlPoint,
MissionTarget,
)
from ..utils import pairwise
from ..utils import Heading, pairwise
FRONTLINE_MIN_CP_DISTANCE = 5000
@ -27,9 +27,9 @@ class FrontLineSegment:
point_b: Point
@property
def attack_heading(self) -> float:
def attack_heading(self) -> Heading:
"""The heading of the frontline segment from player to enemy control point"""
return self.point_a.heading_between_point(self.point_b)
return Heading.from_degrees(self.point_a.heading_between_point(self.point_b))
@property
def attack_distance(self) -> float:
@ -123,7 +123,7 @@ class FrontLine(MissionTarget):
return sum(i.attack_distance for i in self.segments)
@property
def attack_heading(self) -> float:
def attack_heading(self) -> Heading:
"""The heading of the active attack segment from player to enemy control point"""
return self.active_segment.attack_heading
@ -150,13 +150,13 @@ class FrontLine(MissionTarget):
"""
if distance < self.segments[0].attack_distance:
return self.blue_cp.position.point_from_heading(
self.segments[0].attack_heading, distance
self.segments[0].attack_heading.degrees, 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
segment.attack_heading.degrees, remaining_dist
)
else:
remaining_dist -= segment.attack_distance

View File

@ -28,6 +28,7 @@ from game.theater.theatergroundobject import (
VehicleGroupGroundObject,
CoastalSiteGroundObject,
)
from game.utils import Heading
from game.version import VERSION
from gen import namegen
from gen.coastal.coastal_group_generator import generate_coastal_group
@ -123,7 +124,6 @@ class GameGenerator:
GroundObjectGenerator(game, self.generator_settings).generate()
game.settings.version = VERSION
game.begin_turn_0()
return game
def prepare_theater(self) -> None:
@ -386,7 +386,7 @@ class AirbaseGroundObjectGenerator(ControlPointGroundObjectGenerator):
group_id,
object_id,
position + template_point,
unit["heading"],
Heading.from_degrees(unit["heading"]),
self.control_point,
unit["type"],
)
@ -586,7 +586,7 @@ class FobGroundObjectGenerator(AirbaseGroundObjectGenerator):
group_id,
object_id,
point + template_point,
unit["heading"],
Heading.from_degrees(unit["heading"]),
self.control_point,
unit["type"],
is_fob_structure=True,

View File

@ -17,7 +17,7 @@ from ..data.radar_db import (
TELARS,
LAUNCHER_TRACKER_PAIRS,
)
from ..utils import Distance, meters
from ..utils import Distance, Heading, meters
if TYPE_CHECKING:
from .controlpoint import ControlPoint
@ -58,7 +58,7 @@ class TheaterGroundObject(MissionTarget, Generic[GroupT]):
category: str,
group_id: int,
position: Point,
heading: int,
heading: Heading,
control_point: ControlPoint,
dcs_identifier: str,
sea_object: bool,
@ -222,7 +222,7 @@ class BuildingGroundObject(TheaterGroundObject[VehicleGroup]):
group_id: int,
object_id: int,
position: Point,
heading: int,
heading: Heading,
control_point: ControlPoint,
dcs_identifier: str,
is_fob_structure: bool = False,
@ -310,7 +310,7 @@ class SceneryGroundObject(BuildingGroundObject):
group_id=group_id,
object_id=object_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier=dcs_identifier,
is_fob_structure=False,
@ -334,7 +334,7 @@ class FactoryGroundObject(BuildingGroundObject):
name: str,
group_id: int,
position: Point,
heading: int,
heading: Heading,
control_point: ControlPoint,
) -> None:
super().__init__(
@ -385,7 +385,7 @@ class CarrierGroundObject(GenericCarrierGroundObject):
category="CARRIER",
group_id=group_id,
position=control_point.position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="CARRIER",
sea_object=True,
@ -406,7 +406,7 @@ class LhaGroundObject(GenericCarrierGroundObject):
category="LHA",
group_id=group_id,
position=control_point.position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="LHA",
sea_object=True,
@ -428,7 +428,7 @@ class MissileSiteGroundObject(TheaterGroundObject[VehicleGroup]):
category="missile",
group_id=group_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
@ -450,7 +450,7 @@ class CoastalSiteGroundObject(TheaterGroundObject[VehicleGroup]):
group_id: int,
position: Point,
control_point: ControlPoint,
heading: int,
heading: Heading,
) -> None:
super().__init__(
name=name,
@ -497,7 +497,7 @@ class SamGroundObject(IadsGroundObject):
category="aa",
group_id=group_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
@ -565,7 +565,7 @@ class VehicleGroupGroundObject(TheaterGroundObject[VehicleGroup]):
category="armor",
group_id=group_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=False,
@ -593,7 +593,7 @@ class EwrGroundObject(IadsGroundObject):
category="ewr",
group_id=group_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="EWR",
sea_object=False,
@ -627,7 +627,7 @@ class ShipGroundObject(NavalGroundObject):
category="ship",
group_id=group_id,
position=position,
heading=0,
heading=Heading.from_degrees(0),
control_point=control_point,
dcs_identifier="AA",
sea_object=True,

View File

@ -688,7 +688,5 @@ class PendingTransfers:
gap += 1
self.game.procurement_requests_for(self.player).append(
AircraftProcurementRequest(
control_point, nautical_miles(200), FlightType.TRANSPORT, gap
)
AircraftProcurementRequest(control_point, FlightType.TRANSPORT, gap)
)

View File

@ -40,7 +40,10 @@ class PendingUnitDeliveries:
def sell(self, units: dict[UnitType[Any], int]) -> None:
for k, v in units.items():
self.units[k] -= v
if self.units[k] > v:
self.units[k] -= v
else:
del self.units[k]
def refund_all(self, coalition: Coalition) -> None:
self.refund(coalition, self.units)

View File

@ -2,9 +2,10 @@ from __future__ import annotations
import itertools
import math
import random
from collections import Iterable
from dataclasses import dataclass
from typing import Union, Any
from typing import Union, Any, TypeVar
METERS_TO_FEET = 3.28084
FEET_TO_METERS = 1 / METERS_TO_FEET
@ -16,14 +17,8 @@ KPH_TO_KNOTS = 1 / KNOTS_TO_KPH
MS_TO_KPH = 3.6
KPH_TO_MS = 1 / MS_TO_KPH
def heading_sum(h: int, a: int) -> int:
h += a
return h % 360
def opposite_heading(h: int) -> int:
return heading_sum(h, 180)
INHG_TO_HPA = 33.86389
INHG_TO_MMHG = 25.400002776728
@dataclass(frozen=True, order=True)
@ -181,7 +176,85 @@ def mach(value: float, altitude: Distance) -> Speed:
SPEED_OF_SOUND_AT_SEA_LEVEL = knots(661.5)
def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
@dataclass(frozen=True, order=True)
class Heading:
heading_in_degrees: int
@property
def degrees(self) -> int:
return Heading.reduce_angle(self.heading_in_degrees)
@property
def radians(self) -> float:
return math.radians(Heading.reduce_angle(self.heading_in_degrees))
@property
def opposite(self) -> Heading:
return self + Heading.from_degrees(180)
@property
def right(self) -> Heading:
return self + Heading.from_degrees(90)
@property
def left(self) -> Heading:
return self - Heading.from_degrees(90)
def angle_between(self, other: Heading) -> Heading:
angle_between = abs(self.degrees - other.degrees)
if angle_between > 180:
angle_between = 360 - angle_between
return Heading.from_degrees(angle_between)
@staticmethod
def reduce_angle(angle: int) -> int:
return angle % 360
@classmethod
def from_degrees(cls, angle: Union[int, float]) -> Heading:
return cls(Heading.reduce_angle(round(angle)))
@classmethod
def from_radians(cls, angle: Union[int, float]) -> Heading:
deg = round(math.degrees(angle))
return cls(Heading.reduce_angle(deg))
@classmethod
def random(cls, min_angle: int = 0, max_angle: int = 0) -> Heading:
return Heading.from_degrees(random.randint(min_angle, max_angle))
def __add__(self, other: Heading) -> Heading:
return Heading.from_degrees(self.degrees + other.degrees)
def __sub__(self, other: Heading) -> Heading:
return Heading.from_degrees(self.degrees - other.degrees)
@dataclass(frozen=True, order=True)
class Pressure:
pressure_in_inches_hg: float
@property
def inches_hg(self) -> float:
return self.pressure_in_inches_hg
@property
def mm_hg(self) -> float:
return self.pressure_in_inches_hg * INHG_TO_MMHG
@property
def hecto_pascals(self) -> float:
return self.pressure_in_inches_hg * INHG_TO_HPA
def inches_hg(value: float) -> Pressure:
return Pressure(value)
PairwiseT = TypeVar("PairwiseT")
def pairwise(iterable: Iterable[PairwiseT]) -> Iterable[tuple[PairwiseT, PairwiseT]]:
"""
itertools recipe
s -> (s0,s1), (s1,s2), (s2, s3), ...
@ -189,3 +262,15 @@ def pairwise(iterable: Iterable[Any]) -> Iterable[tuple[Any, Any]]:
a, b = itertools.tee(iterable)
next(b, None)
return zip(a, b)
def interpolate(value1: float, value2: float, factor: float, clamp: bool) -> float:
"""Inerpolate between two values, factor 0-1"""
interpolated = value1 + (value2 - value1) * factor
if clamp:
bigger_value = max(value1, value2)
smaller_value = min(value1, value2)
return min(bigger_value, max(smaller_value, interpolated))
else:
return interpolated

View File

@ -106,4 +106,8 @@ VERSION = _build_version_string()
#:
#: Version 7.1
#: * Support for Mariana Islands terrain
CAMPAIGN_FORMAT_VERSION = (7, 1)
#:
#: 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

@ -5,16 +5,18 @@ import logging
import random
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, TYPE_CHECKING
from typing import Optional, TYPE_CHECKING, Any
from dcs.cloud_presets import Clouds as PydcsClouds
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
from game.savecompat import has_save_compat_for
from game.settings import Settings
from game.utils import Distance, meters
from game.utils import Distance, Heading, meters, interpolate, Pressure, inches_hg
if TYPE_CHECKING:
from game.theater import ConflictTheater
from game.theater.conflicttheater import SeasonalConditions
class TimeOfDay(Enum):
@ -26,11 +28,19 @@ class TimeOfDay(Enum):
@dataclass(frozen=True)
class AtmosphericConditions:
#: Pressure at sea level in inches of mercury.
qnh_inches_mercury: float
#: Pressure at sea level.
qnh: Pressure
#: Temperature at sea level in Celcius.
temperature_celsius: float
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "qnh" not in state:
state["qnh"] = inches_hg(state["qnh_inches_mercury"])
del state["qnh_inches_mercury"]
self.__dict__.update(state)
@dataclass(frozen=True)
class WindConditions:
@ -71,15 +81,56 @@ class Fog:
class Weather:
def __init__(self) -> None:
def __init__(
self,
seasonal_conditions: SeasonalConditions,
day: datetime.date,
time_of_day: TimeOfDay,
) -> None:
# Future improvement: Use theater, day and time of day
# to get a more realistic conditions
self.atmospheric = self.generate_atmospheric()
self.atmospheric = self.generate_atmospheric(
seasonal_conditions, day, time_of_day
)
self.clouds = self.generate_clouds()
self.fog = self.generate_fog()
self.wind = self.generate_wind()
def generate_atmospheric(self) -> AtmosphericConditions:
def generate_atmospheric(
self,
seasonal_conditions: SeasonalConditions,
day: datetime.date,
time_of_day: TimeOfDay,
) -> AtmosphericConditions:
pressure = self.interpolate_summer_winter(
seasonal_conditions.summer_avg_pressure,
seasonal_conditions.winter_avg_pressure,
day,
)
temperature = self.interpolate_summer_winter(
seasonal_conditions.summer_avg_temperature,
seasonal_conditions.winter_avg_temperature,
day,
)
if time_of_day == TimeOfDay.Day:
temperature += seasonal_conditions.temperature_day_night_difference / 2
if time_of_day == TimeOfDay.Night:
temperature -= seasonal_conditions.temperature_day_night_difference / 2
pressure += self.pressure_adjustment
temperature += self.temperature_adjustment
conditions = AtmosphericConditions(
qnh=self.random_pressure(pressure),
temperature_celsius=self.random_temperature(temperature),
)
return conditions
@property
def pressure_adjustment(self) -> float:
raise NotImplementedError
@property
def temperature_adjustment(self) -> float:
raise NotImplementedError
def generate_clouds(self) -> Optional[Clouds]:
@ -98,7 +149,7 @@ class Weather:
@staticmethod
def random_wind(minimum: int, maximum: int) -> WindConditions:
wind_direction = random.randint(0, 360)
wind_direction = Heading.random()
at_0m_factor = 1
at_2000m_factor = 2
at_8000m_factor = 3
@ -106,9 +157,9 @@ class Weather:
return WindConditions(
# 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_0m=Wind(wind_direction.degrees, max(1, base_wind * at_0m_factor)),
at_2000m=Wind(wind_direction.degrees, base_wind * at_2000m_factor),
at_8000m=Wind(wind_direction.degrees, base_wind * at_8000m_factor),
)
@staticmethod
@ -120,14 +171,14 @@ class Weather:
return random.randint(100, 400)
@staticmethod
def random_pressure(average_pressure: float) -> float:
def random_pressure(average_pressure: float) -> Pressure:
# "Safe" constants based roughly on ME and viper altimeter.
# Units are inches of mercury.
SAFE_MIN = 28.4
SAFE_MAX = 30.9
# Use normalvariate to get normal distribution, more realistic than uniform
pressure = random.normalvariate(average_pressure, 0.2)
return max(SAFE_MIN, min(SAFE_MAX, pressure))
pressure = random.normalvariate(average_pressure, 0.1)
return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure)))
@staticmethod
def random_temperature(average_temperature: float) -> float:
@ -136,17 +187,29 @@ class Weather:
SAFE_MIN = -12
SAFE_MAX = 49
# Use normalvariate to get normal distribution, more realistic than uniform
temperature = random.normalvariate(average_temperature, 4)
temperature = random.normalvariate(average_temperature, 2)
temperature = round(temperature)
return max(SAFE_MIN, min(SAFE_MAX, temperature))
@staticmethod
def interpolate_summer_winter(
summer_value: float, winter_value: float, day: datetime.date
) -> float:
day_of_year = day.timetuple().tm_yday
day_of_year_peak_summer = 183
distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year)
winter_factor = distance_from_peak_summer / day_of_year_peak_summer
return interpolate(summer_value, winter_value, winter_factor, clamp=True)
class ClearSkies(Weather):
def generate_atmospheric(self) -> AtmosphericConditions:
return AtmosphericConditions(
qnh_inches_mercury=self.random_pressure(29.96),
temperature_celsius=self.random_temperature(22),
)
@property
def pressure_adjustment(self) -> float:
return 0.22
@property
def temperature_adjustment(self) -> float:
return 3.0
def generate_clouds(self) -> Optional[Clouds]:
return None
@ -159,11 +222,13 @@ class ClearSkies(Weather):
class Cloudy(Weather):
def generate_atmospheric(self) -> AtmosphericConditions:
return AtmosphericConditions(
qnh_inches_mercury=self.random_pressure(29.90),
temperature_celsius=self.random_temperature(20),
)
@property
def pressure_adjustment(self) -> float:
return 0.0
@property
def temperature_adjustment(self) -> float:
return 0.0
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=False)
@ -177,11 +242,13 @@ class Cloudy(Weather):
class Raining(Weather):
def generate_atmospheric(self) -> AtmosphericConditions:
return AtmosphericConditions(
qnh_inches_mercury=self.random_pressure(29.70),
temperature_celsius=self.random_temperature(16),
)
@property
def pressure_adjustment(self) -> float:
return -0.22
@property
def temperature_adjustment(self) -> float:
return -3.0
def generate_clouds(self) -> Optional[Clouds]:
return Clouds.random_preset(rain=True)
@ -195,11 +262,13 @@ class Raining(Weather):
class Thunderstorm(Weather):
def generate_atmospheric(self) -> AtmosphericConditions:
return AtmosphericConditions(
qnh_inches_mercury=self.random_pressure(29.60),
temperature_celsius=self.random_temperature(15),
)
@property
def pressure_adjustment(self) -> float:
return 0.1
@property
def temperature_adjustment(self) -> float:
return -3.0
def generate_clouds(self) -> Optional[Clouds]:
return Clouds(
@ -233,7 +302,7 @@ class Conditions:
return cls(
time_of_day=time_of_day,
start_time=_start_time,
weather=cls.generate_weather(),
weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day),
)
@classmethod
@ -259,7 +328,13 @@ class Conditions:
return datetime.datetime.combine(day, time)
@classmethod
def generate_weather(cls) -> Weather:
def generate_weather(
cls,
seasonal_conditions: SeasonalConditions,
day: datetime.date,
time_of_day: TimeOfDay,
) -> Weather:
# Future improvement: use seasonal weights for theaters
chances = {
Thunderstorm: 1,
Raining: 20,
@ -269,4 +344,4 @@ class Conditions:
weather_type = random.choices(
list(chances.keys()), weights=list(chances.values())
)[0]
return weather_type()
return weather_type(seasonal_conditions, day, time_of_day)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import itertools
import logging
import random
from dataclasses import dataclass
@ -80,7 +81,7 @@ from game.theater.missiontarget import MissionTarget
from game.theater.theatergroundobject import TheaterGroundObject
from game.transfers import MultiGroupTransport
from game.unitmap import UnitMap
from game.utils import Distance, meters, nautical_miles
from game.utils import Distance, Heading, meters, nautical_miles, pairwise
from gen.ato import AirTaskingOrder, Package
from gen.callsigns import create_group_callsign_from_unit
from gen.flights.flight import (
@ -1194,8 +1195,57 @@ class AircraftConflictGenerator:
).build()
# Set here rather than when the FlightData is created so they waypoints
# have their TOTs set.
self.flights[-1].waypoints = [takeoff_point] + flight.points
# have their TOTs and fuel minimums set. Once we're more confident in our fuel
# estimation ability the minimum fuel amounts will be calculated during flight
# plan construction, but for now it's only used by the kneeboard so is generated
# late.
waypoints = [takeoff_point] + flight.points
self._estimate_min_fuel_for(flight, waypoints)
self.flights[-1].waypoints = waypoints
@staticmethod
def _estimate_min_fuel_for(flight: Flight, waypoints: list[FlightWaypoint]) -> None:
if flight.unit_type.fuel_consumption is None:
return
combat_speed_types = {
FlightWaypointType.INGRESS_BAI,
FlightWaypointType.INGRESS_CAS,
FlightWaypointType.INGRESS_DEAD,
FlightWaypointType.INGRESS_ESCORT,
FlightWaypointType.INGRESS_OCA_AIRCRAFT,
FlightWaypointType.INGRESS_OCA_RUNWAY,
FlightWaypointType.INGRESS_SEAD,
FlightWaypointType.INGRESS_STRIKE,
FlightWaypointType.INGRESS_SWEEP,
FlightWaypointType.SPLIT,
} | set(TARGET_WAYPOINTS)
consumption = flight.unit_type.fuel_consumption
min_fuel: float = consumption.min_safe
# The flight plan (in reverse) up to and including the arrival point.
main_flight_plan = reversed(waypoints)
try:
while waypoint := next(main_flight_plan):
if waypoint.waypoint_type is FlightWaypointType.LANDING_POINT:
waypoint.min_fuel = min_fuel
main_flight_plan = itertools.chain([waypoint], main_flight_plan)
break
except StopIteration:
# Some custom flight plan without a landing point. Skip it.
return
for b, a in pairwise(main_flight_plan):
distance = meters(a.position.distance_to_point(b.position))
if a.waypoint_type is FlightWaypointType.TAKEOFF:
ppm = consumption.climb
elif b.waypoint_type in combat_speed_types:
ppm = consumption.combat
else:
ppm = consumption.cruise
min_fuel += distance.nautical_miles * ppm
a.min_fuel = min_fuel
def should_delay_flight(self, flight: Flight, start_time: timedelta) -> bool:
if start_time.total_seconds() <= 0:

View File

@ -17,6 +17,9 @@ from dcs.task import (
)
from dcs.unittype import UnitType
from game.utils import Heading
from .flights.ai_flight_planner_db import AEWC_CAPABLE
from .naming import namegen
from .callsigns import callsign_for_support_unit
from .conflictgen import Conflict
from .flights.ai_flight_planner_db import AEWC_CAPABLE
@ -122,14 +125,14 @@ class AirSupportConflictGenerator:
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 = (
tanker_heading = Heading.from_degrees(
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_heading.degrees, TANKER_DISTANCE
)
tanker_group = self.mission.refuel_flight(
country=country,

View File

@ -32,7 +32,7 @@ from game.dcs.aircrafttype import AircraftType
from game.dcs.groundunittype import GroundUnitType
from game.theater.controlpoint import ControlPoint
from game.unitmap import UnitMap
from game.utils import heading_sum, opposite_heading
from game.utils import Heading
from gen.ground_forces.ai_ground_planner import (
DISTANCE_FROM_FRONTLINE,
CombatGroup,
@ -130,7 +130,7 @@ class GroundConflictGenerator:
self.player_stance,
player_groups,
enemy_groups,
self.conflict.heading + 90,
self.conflict.heading.right,
self.conflict.blue_cp,
self.conflict.red_cp,
)
@ -138,7 +138,7 @@ class GroundConflictGenerator:
self.enemy_stance,
enemy_groups,
player_groups,
self.conflict.heading - 90,
self.conflict.heading.left,
self.conflict.red_cp,
self.conflict.blue_cp,
)
@ -182,7 +182,11 @@ class GroundConflictGenerator:
)
def gen_infantry_group_for_group(
self, group: VehicleGroup, is_player: bool, side: Country, forward_heading: int
self,
group: VehicleGroup,
is_player: bool,
side: Country,
forward_heading: Heading,
) -> None:
infantry_position = self.conflict.find_ground_position(
@ -217,7 +221,7 @@ class GroundConflictGenerator:
u.dcs_unit_type,
position=infantry_position,
group_size=1,
heading=forward_heading,
heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
return
@ -244,7 +248,7 @@ class GroundConflictGenerator:
units[0].dcs_unit_type,
position=infantry_position,
group_size=1,
heading=forward_heading,
heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
@ -256,17 +260,19 @@ class GroundConflictGenerator:
unit.dcs_unit_type,
position=position,
group_size=1,
heading=forward_heading,
heading=forward_heading.degrees,
move_formation=PointAction.OffRoad,
)
def _set_reform_waypoint(
self, dcs_group: VehicleGroup, forward_heading: int
self, dcs_group: VehicleGroup, forward_heading: Heading
) -> None:
"""Setting a waypoint close to the spawn position allows the group to reform gracefully
rather than spin
"""
reform_point = dcs_group.position.point_from_heading(forward_heading, 50)
reform_point = dcs_group.position.point_from_heading(
forward_heading.degrees, 50
)
dcs_group.add_waypoint(reform_point)
def _plan_artillery_action(
@ -274,7 +280,7 @@ class GroundConflictGenerator:
stance: CombatStance,
gen_group: CombatGroup,
dcs_group: VehicleGroup,
forward_heading: int,
forward_heading: Heading,
target: Point,
) -> bool:
"""
@ -308,7 +314,7 @@ class GroundConflictGenerator:
dcs_group, forward_heading, (int)(RETREAT_DISTANCE / 3)
)
dcs_group.add_waypoint(
dcs_group.position.point_from_heading(forward_heading, 1),
dcs_group.position.point_from_heading(forward_heading.degrees, 1),
PointAction.OffRoad,
)
dcs_group.points[2].tasks.append(Hold())
@ -336,7 +342,7 @@ class GroundConflictGenerator:
self.mission.triggerrules.triggers.append(artillery_fallback)
for u in dcs_group.units:
u.heading = forward_heading + random.randint(-5, 5)
u.heading = (forward_heading + Heading.random(-5, 5)).degrees
return True
return False
@ -345,7 +351,7 @@ class GroundConflictGenerator:
stance: CombatStance,
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
dcs_group: VehicleGroup,
forward_heading: int,
forward_heading: Heading,
to_cp: ControlPoint,
) -> bool:
"""
@ -378,9 +384,7 @@ class GroundConflictGenerator:
else:
# We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
offset_heading = forward_heading - 2
if offset_heading < 0:
offset_heading = 358
offset_heading = forward_heading - Heading.from_degrees(2)
attack_point = self.find_offensive_point(
dcs_group, offset_heading, AGGRESIVE_MOVE_DISTANCE
)
@ -398,9 +402,7 @@ class GroundConflictGenerator:
else:
# We use an offset heading here because DCS doesn't always
# force vehicles to move if there's no heading change.
offset_heading = forward_heading - 1
if offset_heading < 0:
offset_heading = 359
offset_heading = forward_heading - Heading.from_degrees(1)
attack_point = self.find_offensive_point(
dcs_group, offset_heading, BREAKTHROUGH_OFFENSIVE_DISTANCE
)
@ -436,7 +438,7 @@ class GroundConflictGenerator:
self,
stance: CombatStance,
dcs_group: VehicleGroup,
forward_heading: int,
forward_heading: Heading,
to_cp: ControlPoint,
) -> bool:
"""
@ -473,7 +475,7 @@ class GroundConflictGenerator:
stance: CombatStance,
ally_groups: List[Tuple[VehicleGroup, CombatGroup]],
enemy_groups: List[Tuple[VehicleGroup, CombatGroup]],
forward_heading: int,
forward_heading: Heading,
from_cp: ControlPoint,
to_cp: ControlPoint,
) -> None:
@ -514,12 +516,14 @@ class GroundConflictGenerator:
else:
retreat_point = self.find_retreat_point(dcs_group, forward_heading)
reposition_point = retreat_point.point_from_heading(
forward_heading, 10
forward_heading.degrees, 10
) # Another point to make the unit face the enemy
dcs_group.add_waypoint(retreat_point, PointAction.OffRoad)
dcs_group.add_waypoint(reposition_point, PointAction.OffRoad)
def add_morale_trigger(self, dcs_group: VehicleGroup, forward_heading: int) -> None:
def add_morale_trigger(
self, dcs_group: VehicleGroup, forward_heading: Heading
) -> None:
"""
This add a trigger to manage units fleeing whenever their group is hit hard, or being engaged by CAS
"""
@ -532,7 +536,7 @@ class GroundConflictGenerator:
# Force unit heading
for unit in dcs_group.units:
unit.heading = forward_heading
unit.heading = forward_heading.degrees
dcs_group.manualHeading = True
# We add a new retreat waypoint
@ -563,7 +567,7 @@ class GroundConflictGenerator:
def find_retreat_point(
self,
dcs_group: VehicleGroup,
frontline_heading: int,
frontline_heading: Heading,
distance: int = RETREAT_DISTANCE,
) -> Point:
"""
@ -573,14 +577,14 @@ class GroundConflictGenerator:
:return: dcs.mapping.Point object with the desired position
"""
desired_point = dcs_group.points[0].position.point_from_heading(
heading_sum(frontline_heading, +180), distance
frontline_heading.opposite.degrees, distance
)
if self.conflict.theater.is_on_land(desired_point):
return desired_point
return self.conflict.theater.nearest_land_pos(desired_point)
def find_offensive_point(
self, dcs_group: VehicleGroup, frontline_heading: int, distance: int
self, dcs_group: VehicleGroup, frontline_heading: Heading, distance: int
) -> Point:
"""
Find a point to attack
@ -590,7 +594,7 @@ class GroundConflictGenerator:
:return: dcs.mapping.Point object with the desired position
"""
desired_point = dcs_group.points[0].position.point_from_heading(
frontline_heading, distance
frontline_heading.degrees, distance
)
if self.conflict.theater.is_on_land(desired_point):
return desired_point
@ -688,14 +692,14 @@ class GroundConflictGenerator:
conflict_position: Point,
combat_width: int,
distance_from_frontline: int,
heading: int,
spawn_heading: int,
heading: Heading,
spawn_heading: Heading,
) -> Optional[Point]:
shifted = conflict_position.point_from_heading(
heading, random.randint(0, combat_width)
heading.degrees, random.randint(0, combat_width)
)
desired_point = shifted.point_from_heading(
spawn_heading, distance_from_frontline
spawn_heading.degrees, distance_from_frontline
)
return Conflict.find_ground_position(
desired_point, combat_width, heading, self.conflict.theater
@ -704,17 +708,13 @@ class GroundConflictGenerator:
def _generate_groups(
self,
groups: list[CombatGroup],
frontline_vector: Tuple[Point, int, int],
frontline_vector: Tuple[Point, Heading, int],
is_player: bool,
) -> List[Tuple[VehicleGroup, CombatGroup]]:
"""Finds valid positions for planned groups and generates a pydcs group for them"""
positioned_groups = []
position, heading, combat_width = frontline_vector
spawn_heading = (
int(heading_sum(heading, -90))
if is_player
else int(heading_sum(heading, 90))
)
spawn_heading = heading.left if is_player else heading.right
country = self.game.coalition_for(is_player).country_name
for group in groups:
if group.role == CombatGroupRole.ARTILLERY:
@ -737,7 +737,7 @@ class GroundConflictGenerator:
group.unit_type,
group.size,
final_position,
heading=opposite_heading(spawn_heading),
heading=spawn_heading.opposite,
)
if is_player:
g.set_skill(Skill(self.game.settings.player_skill))
@ -750,7 +750,7 @@ class GroundConflictGenerator:
g,
is_player,
self.mission.country(country),
opposite_heading(spawn_heading),
spawn_heading.opposite,
)
else:
logging.warning(f"Unable to get valid position for {group}")
@ -764,7 +764,7 @@ class GroundConflictGenerator:
count: int,
at: Point,
move_formation: PointAction = PointAction.OffRoad,
heading: int = 0,
heading: Heading = Heading.from_degrees(0),
) -> VehicleGroup:
if side == self.conflict.attackers_country:
@ -778,7 +778,7 @@ class GroundConflictGenerator:
unit_type.dcs_unit_type,
position=at,
group_size=count,
heading=heading,
heading=heading.degrees,
move_formation=move_formation,
)

View File

@ -3,6 +3,7 @@ from dcs.vehicles import MissilesSS, Unarmed, AirDefence
from game import Game
from game.factions.faction import Faction
from game.theater.theatergroundobject import CoastalSiteGroundObject
from game.utils import Heading
from gen.sam.group_generator import VehicleGroupGenerator
@ -59,5 +60,5 @@ class SilkwormGenerator(VehicleGroupGenerator[CoastalSiteGroundObject]):
"STRELA#0",
self.position.x + 200,
self.position.y + 15,
90,
Heading.from_degrees(90),
)

View File

@ -9,7 +9,7 @@ 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 game.utils import Heading
FRONTLINE_LENGTH = 80000
@ -25,7 +25,7 @@ class Conflict:
attackers_country: Country,
defenders_country: Country,
position: Point,
heading: Optional[int] = None,
heading: Optional[Heading] = None,
size: Optional[int] = None,
):
@ -55,28 +55,28 @@ class Conflict:
@classmethod
def frontline_position(
cls, frontline: FrontLine, theater: ConflictTheater
) -> Tuple[Point, int]:
attack_heading = int(frontline.attack_heading)
) -> Tuple[Point, Heading]:
attack_heading = frontline.attack_heading
position = cls.find_ground_position(
frontline.position,
FRONTLINE_LENGTH,
heading_sum(attack_heading, 90),
attack_heading.right,
theater,
)
if position is None:
raise RuntimeError("Could not find front line position")
return position, opposite_heading(attack_heading)
return position, attack_heading.opposite
@classmethod
def frontline_vector(
cls, front_line: FrontLine, theater: ConflictTheater
) -> Tuple[Point, int, int]:
) -> Tuple[Point, Heading, int]:
"""
Returns a vector for a valid frontline location avoiding exclusion zones.
"""
center_position, heading = cls.frontline_position(front_line, theater)
left_heading = heading_sum(heading, -90)
right_heading = heading_sum(heading, 90)
left_heading = heading.left
right_heading = heading.right
left_position = cls.extend_ground_position(
center_position, int(FRONTLINE_LENGTH / 2), left_heading, theater
)
@ -113,10 +113,14 @@ class Conflict:
@classmethod
def extend_ground_position(
cls, initial: Point, max_distance: int, heading: int, theater: ConflictTheater
cls,
initial: Point,
max_distance: int,
heading: Heading,
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)
extended = initial.point_from_heading(heading.degrees, max_distance)
if theater.landmap is None:
# TODO: Why is this possible?
return extended
@ -133,14 +137,14 @@ class Conflict:
return extended
# Otherwise extend the front line only up to the intersection.
return initial.point_from_heading(heading, p0.distance(intersection))
return initial.point_from_heading(heading.degrees, p0.distance(intersection))
@classmethod
def find_ground_position(
cls,
initial: Point,
max_distance: int,
heading: int,
heading: Heading,
theater: ConflictTheater,
coerce: bool = True,
) -> Optional[Point]:
@ -153,10 +157,10 @@ class Conflict:
if theater.is_on_land(pos):
return pos
for distance in range(0, int(max_distance), 100):
pos = initial.point_from_heading(heading, distance)
pos = initial.point_from_heading(heading.degrees, distance)
if theater.is_on_land(pos):
return pos
pos = initial.point_from_heading(opposite_heading(heading), distance)
pos = initial.point_from_heading(heading.opposite.degrees, distance)
if theater.is_on_land(pos):
return pos
if coerce:

View File

@ -3,7 +3,6 @@ from typing import Optional
from dcs.mission import Mission
from game.weather import Clouds, Fog, Conditions, WindConditions, AtmosphericConditions
from .units import inches_hg_to_mm_hg
class EnvironmentGenerator:
@ -12,7 +11,7 @@ class EnvironmentGenerator:
self.conditions = conditions
def set_atmospheric(self, atmospheric: AtmosphericConditions) -> None:
self.mission.weather.qnh = inches_hg_to_mm_hg(atmospheric.qnh_inches_mercury)
self.mission.weather.qnh = atmospheric.qnh.mm_hg
self.mission.weather.season_temperature = atmospheric.temperature_celsius
def set_clouds(self, clouds: Optional[Clouds]) -> None:

View File

@ -1,6 +1,7 @@
import random
from gen.sam.group_generator import ShipGroupGenerator
from game.utils import Heading
from dcs.ships import USS_Arleigh_Burke_IIa, TICONDEROG
@ -54,7 +55,7 @@ class CarrierGroupGenerator(ShipGroupGenerator):
)
# Add Ticonderoga escort
if self.heading >= 180:
if self.heading >= Heading.from_degrees(180):
self.add_unit(
TICONDEROG,
"USS Hué City",

View File

@ -2,13 +2,14 @@ from __future__ import annotations
from datetime import timedelta
from enum import Enum
from typing import List, Optional, TYPE_CHECKING, Union, Sequence
from typing import List, Optional, TYPE_CHECKING, Union, Sequence, Any
from dcs.mapping import Point
from dcs.point import MovingPoint, PointAction
from dcs.unit import Unit
from game.dcs.aircrafttype import AircraftType
from game.savecompat import has_save_compat_for
from game.squadrons import Pilot, Squadron
from game.theater.controlpoint import ControlPoint, MissionTarget
from game.utils import Distance, meters
@ -138,7 +139,7 @@ class FlightWaypoint:
Args:
waypoint_type: The waypoint type.
x: X cooidinate of the waypoint.
x: X coordinate of the waypoint.
y: Y coordinate of the waypoint.
alt: Altitude of the waypoint. By default this is AGL, but it can be
changed to MSL by setting alt_type to "RADIO".
@ -158,6 +159,8 @@ class FlightWaypoint:
self.pretty_name = ""
self.only_for_player = False
self.flyover = False
# The minimum amount of fuel remaining at this waypoint in pounds.
self.min_fuel: Optional[float] = None
# These are set very late by the air conflict generator (part of mission
# generation). We do it late so that we don't need to propagate changes
@ -166,6 +169,12 @@ class FlightWaypoint:
self.tot: Optional[timedelta] = None
self.departure_time: Optional[timedelta] = None
@has_save_compat_for(5)
def __setstate__(self, state: dict[str, Any]) -> None:
if "min_fuel" not in state:
state["min_fuel"] = None
self.__dict__.update(state)
@property
def position(self) -> Point:
return Point(self.x, self.y)

View File

@ -20,6 +20,8 @@ from dcs.unit import Unit
from shapely.geometry import Point as ShapelyPoint
from game.data.doctrine import Doctrine
from game.dcs.aircrafttype import FuelConsumption
from game.flightplan import IpZoneGeometry, JoinZoneGeometry, HoldZoneGeometry
from game.theater import (
Airfield,
ControlPoint,
@ -35,8 +37,10 @@ from game.theater.theatergroundobject import (
NavalGroundObject,
BuildingGroundObject,
)
from game.threatzones import ThreatZones
from game.utils import Distance, Speed, feet, meters, nautical_miles, knots
from game.utils import Distance, Heading, Speed, feet, meters, nautical_miles, knots
from .closestairfields import ObjectiveDistanceCache
from .flight import Flight, FlightType, FlightWaypoint, FlightWaypointType
from .traveltime import GroundSpeed, TravelTime
@ -137,6 +141,17 @@ class FlightPlan:
@cached_property
def bingo_fuel(self) -> int:
"""Bingo fuel value for the FlightPlan"""
if (fuel := self.flight.unit_type.fuel_consumption) is not None:
return self._bingo_estimate(fuel)
return self._legacy_bingo_estimate()
def _bingo_estimate(self, fuel: FuelConsumption) -> int:
distance_to_arrival = self.max_distance_from(self.flight.arrival)
fuel_consumed = fuel.cruise * distance_to_arrival.nautical_miles
bingo = fuel_consumed + fuel.min_safe
return math.ceil(bingo / 100) * 100
def _legacy_bingo_estimate(self) -> int:
distance_to_arrival = self.max_distance_from(self.flight.arrival)
bingo = 1000.0 # Minimum Emergency Fuel
@ -946,87 +961,33 @@ class FlightPlanBuilder:
raise PlanningError(f"{task} flight plan generation not implemented")
def regenerate_package_waypoints(self) -> None:
# The simple case is where the target is not near the departure airfield. In
# this case, we can plan the shortest route from the departure airfield to the
# target, use the nearest non-threatened point *that's farther from the target
# than the ingress point to avoid backtracking) as the join point.
#
# The other case that we need to handle is when the target is close to
# the origin airfield. In this case we currently fall back to the old planning
# behavior.
#
# A messy (and very unlikely) case that we can't do much about:
#
# +--------------+ +---------------+
# | | | |
# | IP-+---+-T |
# | | | |
# | | | |
# +--------------+ +---------------+
from gen.ato import PackageWaypoints
target = self.package.target.position
package_airfield = self.package_airfield()
for join_point in self.preferred_join_points():
join_distance = meters(join_point.distance_to_point(target))
if join_distance > self.doctrine.ingress_distance:
break
else:
# The entire path to the target is threatened. Use the fallback behavior for
# now.
self.legacy_package_waypoints_impl()
return
# Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry(
self.package.target.position,
package_airfield.position,
self.coalition,
).find_best_ip()
attack_heading = join_point.heading_between_point(target)
ingress_point = self._ingress_point(attack_heading)
# The first case described above. The ingress and join points are placed
# reasonably relative to each other.
self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point),
join_point = JoinZoneGeometry(
self.package.target.position,
package_airfield.position,
ingress_point,
WaypointBuilder.perturb(
self.preferred_split_point(ingress_point, join_point)
),
)
self.coalition,
).find_best_join_point()
def retreat_point(self, origin: Point) -> Point:
return self.threat_zones.closest_boundary(origin)
def legacy_package_waypoints_impl(self) -> None:
from gen.ato import PackageWaypoints
ingress_point = self._ingress_point(self._target_heading_to_package_airfield())
join_point = self._rendezvous_point(ingress_point)
# And the split point based on the best route from the IP. Since that's no
# different than the best route *to* the IP, this is the same as the join point.
# TODO: Estimate attack completion point based on the IP and split from there?
self.package.waypoints = PackageWaypoints(
WaypointBuilder.perturb(join_point),
ingress_point,
WaypointBuilder.perturb(join_point),
)
def safe_points_between(self, a: Point, b: Point) -> Iterator[Point]:
for point in self.coalition.nav_mesh.shortest_path(a, b)[1:-1]:
if not self.threat_zones.threatened(point):
yield point
def preferred_join_points(self) -> Iterator[Point]:
# Use non-threatened points along the path to the target as the join point. We
# may need to try more than one in the event that the close non-threatened
# points are closer than the ingress point itself.
return self.safe_points_between(
self.package.target.position, self.package_airfield().position
)
def preferred_split_point(self, ingress_point: Point, join_point: Point) -> Point:
# Use non-threatened points along the path to the target as the join point. We
# may need to try more than one in the event that the close non-threatened
# points are closer than the ingress point itself.
for point in self.safe_points_between(
ingress_point, self.package_airfield().position
):
return point
return join_point
def generate_strike(self, flight: Flight) -> StrikeFlightPlan:
"""Generates a strike flight plan.
@ -1192,10 +1153,11 @@ class FlightPlanBuilder:
"""
assert self.package.waypoints is not None
target = self.package.target.position
heading = self.package.waypoints.join.heading_between_point(target)
heading = Heading.from_degrees(
self.package.waypoints.join.heading_between_point(target)
)
start_pos = target.point_from_heading(
heading, -self.doctrine.sweep_distance.meters
heading.degrees, -self.doctrine.sweep_distance.meters
)
builder = WaypointBuilder(flight, self.coalition)
@ -1290,7 +1252,9 @@ class FlightPlanBuilder:
else:
raise PlanningError("Could not find any enemy airfields")
heading = location.position.heading_between_point(closest_airfield.position)
heading = Heading.from_degrees(
location.position.heading_between_point(closest_airfield.position)
)
position = ShapelyPoint(
self.package.target.position.x, self.package.target.position.y
@ -1326,20 +1290,20 @@ class FlightPlanBuilder:
)
end = location.position.point_from_heading(
heading,
heading.degrees,
random.randint(int(min_cap_distance.meters), int(max_cap_distance.meters)),
)
diameter = random.randint(
int(self.doctrine.cap_min_track_length.meters),
int(self.doctrine.cap_max_track_length.meters),
)
start = end.point_from_heading(heading - 180, diameter)
start = end.point_from_heading(heading.opposite.degrees, diameter)
return start, end
def aewc_orbit(self, location: MissionTarget) -> Point:
closest_boundary = self.threat_zones.closest_boundary(location.position)
heading_to_threat_boundary = location.position.heading_between_point(
closest_boundary
heading_to_threat_boundary = Heading.from_degrees(
location.position.heading_between_point(closest_boundary)
)
distance_to_threat = meters(
location.position.distance_to_point(closest_boundary)
@ -1353,7 +1317,7 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
return location.position.point_from_heading(
orbit_heading, orbit_distance.meters
orbit_heading.degrees, orbit_distance.meters
)
def racetrack_for_frontline(
@ -1361,9 +1325,9 @@ class FlightPlanBuilder:
) -> Tuple[Point, Point]:
# Find targets waypoints
ingress, heading, distance = Conflict.frontline_vector(front_line, self.theater)
center = ingress.point_from_heading(heading, distance / 2)
center = ingress.point_from_heading(heading.degrees, distance / 2)
orbit_center = center.point_from_heading(
heading - 90,
heading.left.degrees,
random.randint(
int(nautical_miles(6).meters), int(nautical_miles(15).meters)
),
@ -1376,8 +1340,8 @@ class FlightPlanBuilder:
combat_width = 35000
radius = combat_width * 1.25
start = orbit_center.point_from_heading(heading, radius)
end = orbit_center.point_from_heading(heading + 180, radius)
start = orbit_center.point_from_heading(heading.degrees, radius)
end = orbit_center.point_from_heading(heading.opposite.degrees, radius)
if end.distance_to_point(origin) < start.distance_to_point(origin):
start, end = end, start
@ -1571,8 +1535,8 @@ class FlightPlanBuilder:
raise InvalidObjectiveLocation(flight.flight_type, location)
ingress, heading, distance = Conflict.frontline_vector(location, self.theater)
center = ingress.point_from_heading(heading, distance / 2)
egress = ingress.point_from_heading(heading, distance)
center = ingress.point_from_heading(heading.degrees, distance / 2)
egress = ingress.point_from_heading(heading.degrees, distance)
ingress_distance = ingress.distance_to_point(flight.departure.position)
egress_distance = egress.distance_to_point(flight.departure.position)
@ -1607,8 +1571,8 @@ class FlightPlanBuilder:
location = self.package.target
closest_boundary = self.threat_zones.closest_boundary(location.position)
heading_to_threat_boundary = location.position.heading_between_point(
closest_boundary
heading_to_threat_boundary = Heading.from_degrees(
location.position.heading_between_point(closest_boundary)
)
distance_to_threat = meters(
location.position.distance_to_point(closest_boundary)
@ -1623,16 +1587,16 @@ class FlightPlanBuilder:
orbit_distance = distance_to_threat - threat_buffer
racetrack_center = location.position.point_from_heading(
orbit_heading, orbit_distance.meters
orbit_heading.degrees, orbit_distance.meters
)
racetrack_half_distance = Distance.from_nautical_miles(20).meters
racetrack_start = racetrack_center.point_from_heading(
orbit_heading + 90, racetrack_half_distance
orbit_heading.right.degrees, racetrack_half_distance
)
racetrack_end = racetrack_center.point_from_heading(
orbit_heading - 90, racetrack_half_distance
orbit_heading.left.degrees, racetrack_half_distance
)
builder = WaypointBuilder(flight, self.coalition)
@ -1702,48 +1666,10 @@ class FlightPlanBuilder:
origin = flight.departure.position
target = self.package.target.position
join = self.package.waypoints.join
origin_to_join = origin.distance_to_point(join)
if meters(origin_to_join) < self.doctrine.push_distance:
# If the origin airfield is closer to the join point, than the minimum push
# distance. Plan the hold point such that it retreats from the origin
# airfield.
return join.point_from_heading(
target.heading_between_point(origin), self.doctrine.push_distance.meters
)
heading_to_join = origin.heading_between_point(join)
hold_point = origin.point_from_heading(
heading_to_join, self.doctrine.push_distance.meters
)
hold_distance = meters(hold_point.distance_to_point(join))
if hold_distance >= self.doctrine.push_distance:
# Hold point is between the origin airfield and the join point and
# spaced sufficiently.
return hold_point
# The hold point is between the origin airfield and the join point, but
# the distance between the hold point and the join point is too short.
# Bend the hold point out to extend the distance while maintaining the
# minimum distance from the origin airfield to keep the AI flying
# properly.
origin_to_join = origin.distance_to_point(join)
cos_theta = (
self.doctrine.hold_distance.meters ** 2
+ origin_to_join ** 2
- self.doctrine.join_distance.meters ** 2
) / (2 * self.doctrine.hold_distance.meters * origin_to_join)
try:
theta = math.acos(cos_theta)
except ValueError:
# No solution that maintains hold and join distances. Extend the
# hold point away from the target.
return origin.point_from_heading(
target.heading_between_point(origin), self.doctrine.hold_distance.meters
)
return origin.point_from_heading(
heading_to_join - theta, self.doctrine.hold_distance.meters
)
ip = self.package.waypoints.ingress
return HoldZoneGeometry(
target, origin, ip, join, self.coalition, self.theater
).find_best_hold_point()
# TODO: Make a model for the waypoint builder and use that in the UI.
def generate_rtb_waypoint(
@ -1806,59 +1732,6 @@ class FlightPlanBuilder:
lead_time=lead_time,
)
def _retreating_rendezvous_point(self, attack_transition: Point) -> Point:
"""Creates a rendezvous point that retreats from the origin airfield."""
return attack_transition.point_from_heading(
self.package.target.position.heading_between_point(
self.package_airfield().position
),
self.doctrine.join_distance.meters,
)
def _advancing_rendezvous_point(self, attack_transition: Point) -> Point:
"""Creates a rendezvous point that advances toward the target."""
heading = self._heading_to_package_airfield(attack_transition)
return attack_transition.point_from_heading(
heading, -self.doctrine.join_distance.meters
)
def _rendezvous_should_retreat(self, attack_transition: Point) -> bool:
transition_target_distance = attack_transition.distance_to_point(
self.package.target.position
)
origin_target_distance = self._distance_to_package_airfield(
self.package.target.position
)
# If the origin point is closer to the target than the ingress point,
# the rendezvous point should be positioned in a position that retreats
# from the origin airfield.
return origin_target_distance < transition_target_distance
def _rendezvous_point(self, attack_transition: Point) -> Point:
"""Returns the position of the rendezvous point.
Args:
attack_transition: The ingress or target point for this rendezvous.
"""
if self._rendezvous_should_retreat(attack_transition):
return self._retreating_rendezvous_point(attack_transition)
return self._advancing_rendezvous_point(attack_transition)
def _ingress_point(self, heading: float) -> Point:
return self.package.target.position.point_from_heading(
heading - 180, self.doctrine.ingress_distance.meters
)
def _target_heading_to_package_airfield(self) -> float:
return self._heading_to_package_airfield(self.package.target.position)
def _heading_to_package_airfield(self, point: Point) -> float:
return self.package_airfield().position.heading_between_point(point)
def _distance_to_package_airfield(self, point: Point) -> float:
return self.package_airfield().position.distance_to_point(point)
def package_airfield(self) -> ControlPoint:
# We'll always have a package, but if this is being planned via the UI
# it could be the first flight in the package.

View File

@ -1,9 +1,10 @@
from __future__ import annotations
import datetime
from typing import Optional, List, Iterator, TYPE_CHECKING, Mapping
from collections import Iterable
from typing import Optional, Iterator, TYPE_CHECKING, Mapping
from game.data.weapons import Weapon, Pylon
from game.data.weapons import Weapon, Pylon, WeaponType
from game.dcs.aircrafttype import AircraftType
if TYPE_CHECKING:
@ -30,9 +31,28 @@ class Loadout:
def derive_custom(self, name: str) -> Loadout:
return Loadout(name, self.pylons, self.date, is_custom=True)
@staticmethod
def _fallback_for(
weapon: Weapon,
pylon: Pylon,
date: datetime.date,
skip_types: Optional[Iterable[WeaponType]] = None,
) -> Optional[Weapon]:
if skip_types is None:
skip_types = set()
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date):
continue
if fallback.weapon_group.type in skip_types:
continue
return fallback
return None
def degrade_for_date(self, unit_type: AircraftType, date: datetime.date) -> Loadout:
if self.date is not None and self.date <= date:
return Loadout(self.name, self.pylons, self.date)
return Loadout(self.name, self.pylons, self.date, self.is_custom)
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
@ -41,16 +61,41 @@ class Loadout:
continue
if not weapon.available_on(date):
pylon = Pylon.for_aircraft(unit_type, pylon_number)
for fallback in weapon.fallbacks:
if not pylon.can_equip(fallback):
continue
if not fallback.available_on(date):
continue
new_pylons[pylon_number] = fallback
break
else:
fallback = self._fallback_for(weapon, pylon, date)
if fallback is None:
del new_pylons[pylon_number]
return Loadout(f"{self.name} ({date.year})", new_pylons, date)
else:
new_pylons[pylon_number] = fallback
loadout = Loadout(self.name, new_pylons, date, self.is_custom)
# If this is not a custom loadout, we should replace any LGBs with iron bombs if
# the loadout lost its TGP.
#
# If the loadout was chosen explicitly by the user, assume they know what
# they're doing. They may be coordinating buddy-lase.
if not loadout.is_custom:
loadout.replace_lgbs_if_no_tgp(unit_type, date)
return loadout
def replace_lgbs_if_no_tgp(
self, unit_type: AircraftType, date: datetime.date
) -> None:
for weapon in self.pylons.values():
if weapon is not None and weapon.weapon_group.type is WeaponType.TGP:
# Have a TGP. Nothing to do.
return
new_pylons = dict(self.pylons)
for pylon_number, weapon in self.pylons.items():
if weapon is not None and weapon.weapon_group.type is WeaponType.LGB:
pylon = Pylon.for_aircraft(unit_type, pylon_number)
fallback = self._fallback_for(
weapon, pylon, date, skip_types={WeaponType.LGB}
)
if fallback is None:
del new_pylons[pylon_number]
else:
new_pylons[pylon_number] = fallback
self.pylons = new_pylons
@classmethod
def iter_for(cls, flight: Flight) -> Iterator[Loadout]:
@ -72,10 +117,6 @@ class Loadout:
date=None,
)
@classmethod
def all_for(cls, flight: Flight) -> List[Loadout]:
return list(cls.iter_for(flight))
@classmethod
def default_loadout_names_for(cls, flight: Flight) -> Iterator[str]:
from gen.flights.flight import FlightType

View File

@ -55,7 +55,7 @@ from game.theater.theatergroundobject import (
SceneryGroundObject,
)
from game.unitmap import UnitMap
from game.utils import feet, knots, mps
from game.utils import Heading, feet, knots, mps
from .radios import RadioFrequency, RadioRegistry
from .runways import RunwayData
from .tacan import TacanBand, TacanChannel, TacanRegistry
@ -166,7 +166,7 @@ class MissileSiteGenerator(GenericGroundObjectGenerator[MissileSiteGroundObject]
if targets:
target = random.choice(targets)
real_target = target.point_from_heading(
random.randint(0, 360), random.randint(0, 2500)
Heading.random().degrees, random.randint(0, 2500)
)
vg.points[0].add_task(FireAtPoint(real_target))
logging.info("Set up fire task for missile group.")
@ -246,7 +246,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]):
name=self.ground_object.group_name,
_type=unit_type,
position=self.ground_object.position,
heading=self.ground_object.heading,
heading=self.ground_object.heading.degrees,
)
self._register_fortification(group)
@ -256,7 +256,7 @@ class BuildingSiteGenerator(GenericGroundObjectGenerator[BuildingGroundObject]):
name=self.ground_object.group_name,
_type=static_type,
position=self.ground_object.position,
heading=self.ground_object.heading,
heading=self.ground_object.heading.degrees,
dead=self.ground_object.is_dead,
)
self._register_building(group)
@ -387,7 +387,9 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
# time as the recovery window.
brc = self.steam_into_wind(ship_group)
self.activate_beacons(ship_group, tacan, tacan_callsign, icls)
self.add_runway_data(brc or 0, atc, tacan, tacan_callsign, icls)
self.add_runway_data(
brc or Heading.from_degrees(0), atc, tacan, tacan_callsign, icls
)
self._register_unit_group(group, ship_group)
def get_carrier_type(self, group: ShipGroup) -> Type[ShipType]:
@ -422,14 +424,14 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
ship.set_frequency(atc_channel.hertz)
return ship
def steam_into_wind(self, group: ShipGroup) -> Optional[int]:
def steam_into_wind(self, group: ShipGroup) -> Optional[Heading]:
wind = self.game.conditions.weather.wind.at_0m
brc = wind.direction + 180
brc = Heading.from_degrees(wind.direction).opposite
# Aim for 25kts over the deck.
carrier_speed = knots(25) - mps(wind.speed)
for attempt in range(5):
point = group.points[0].position.point_from_heading(
brc, 100000 - attempt * 20000
brc.degrees, 100000 - attempt * 20000
)
if self.game.theater.is_in_sea(point):
group.points[0].speed = carrier_speed.meters_per_second
@ -459,7 +461,7 @@ class GenericCarrierGenerator(GenericGroundObjectGenerator[GenericCarrierGroundO
def add_runway_data(
self,
brc: int,
brc: Heading,
atc: RadioFrequency,
tacan: TacanChannel,
callsign: str,
@ -593,7 +595,7 @@ class HelipadGenerator:
logging.info("Generating helipad : " + name)
pad = SingleHeliPad(name=(name + "_unit"))
pad.position = Point(helipad.x, helipad.y)
pad.heading = helipad.heading
pad.heading = helipad.heading.degrees
# pad.heliport_frequency = self.radio_registry.alloc_uhf() TODO : alloc radio & callsign
sg = unitgroup.StaticGroup(self.m.next_group_id(), name)
sg.add_unit(pad)

View File

@ -23,6 +23,7 @@ only be added per airframe, so PvP missions where each side have the same
aircraft will be able to see the enemy's kneeboard for the same airframe.
"""
import datetime
import math
import textwrap
from collections import defaultdict
from dataclasses import dataclass
@ -39,15 +40,14 @@ from game.db import unit_type_from_name
from game.dcs.aircrafttype import AircraftType
from game.theater import ConflictTheater, TheaterGroundObject, LatLon
from game.theater.bullseye import Bullseye
from game.weather import Weather
from game.utils import meters
from game.weather import Weather
from .aircraft import FlightData
from .airsupportgen import AwacsInfo, TankerInfo
from .briefinggen import CommInfo, JtacInfo, MissionInfoGenerator
from .flights.flight import FlightWaypoint, FlightWaypointType, FlightType
from .radios import RadioFrequency
from .runways import RunwayData
from .units import inches_hg_to_mm_hg, inches_hg_to_hpa
if TYPE_CHECKING:
from game import Game
@ -112,12 +112,17 @@ class KneeboardPageWriter:
self.text(text, font=self.heading_font, fill=self.foreground_fill)
def table(
self, cells: List[List[str]], headers: Optional[List[str]] = None
self,
cells: List[List[str]],
headers: Optional[List[str]] = None,
font: Optional[ImageFont.FreeTypeFont] = None,
) -> None:
if headers is None:
headers = []
if font is None:
font = self.table_font
table = tabulate(cells, headers=headers, numalign="right")
self.text(table, font=self.table_font, fill=self.foreground_fill)
self.text(table, font, fill=self.foreground_fill)
def write(self, path: Path) -> None:
self.image.save(path)
@ -200,6 +205,7 @@ class FlightPlanBuilder:
self._ground_speed(self.target_points[0].waypoint),
self._format_time(self.target_points[0].waypoint.tot),
self._format_time(self.target_points[0].waypoint.departure_time),
self._format_min_fuel(self.target_points[0].waypoint.min_fuel),
]
)
self.last_waypoint = self.target_points[-1].waypoint
@ -217,6 +223,7 @@ class FlightPlanBuilder:
self._ground_speed(waypoint.waypoint),
self._format_time(waypoint.waypoint.tot),
self._format_time(waypoint.waypoint.departure_time),
self._format_min_fuel(waypoint.waypoint.min_fuel),
]
)
@ -255,6 +262,12 @@ class FlightPlanBuilder:
duration = (waypoint.tot - last_time).total_seconds() / 3600
return f"{int(distance.nautical_miles / duration)} kt"
@staticmethod
def _format_min_fuel(min_fuel: Optional[float]) -> str:
if min_fuel is None:
return ""
return str(math.ceil(min_fuel / 100) * 100)
def build(self) -> List[List[str]]:
return self.rows
@ -277,6 +290,11 @@ class BriefingPage(KneeboardPage):
self.weather = weather
self.start_time = start_time
self.dark_kneeboard = dark_kneeboard
self.flight_plan_font = ImageFont.truetype(
"resources/fonts/Inconsolata.otf",
16,
layout_engine=ImageFont.LAYOUT_BASIC,
)
def write(self, path: Path) -> None:
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
@ -303,18 +321,24 @@ class BriefingPage(KneeboardPage):
flight_plan_builder.add_waypoint(num, waypoint)
writer.table(
flight_plan_builder.build(),
headers=["#", "Action", "Alt", "Dist", "GSPD", "Time", "Departure"],
headers=[
"#",
"Action",
"Alt",
"Dist",
"GSPD",
"Time",
"Departure",
"Min fuel",
],
font=self.flight_plan_font,
)
writer.text(f"Bullseye: {self.bullseye.to_lat_lon(self.theater).format_dms()}")
qnh_in_hg = "{:.2f}".format(self.weather.atmospheric.qnh_inches_mercury)
qnh_mm_hg = "{:.1f}".format(
inches_hg_to_mm_hg(self.weather.atmospheric.qnh_inches_mercury)
)
qnh_hpa = "{:.1f}".format(
inches_hg_to_hpa(self.weather.atmospheric.qnh_inches_mercury)
)
qnh_in_hg = f"{self.weather.atmospheric.qnh.inches_hg:.2f}"
qnh_mm_hg = f"{self.weather.atmospheric.qnh.mm_hg:.1f}"
qnh_hpa = f"{self.weather.atmospheric.qnh.hecto_pascals:.1f}"
writer.text(
f"Temperature: {round(self.weather.atmospheric.temperature_celsius)} °C at sea level"
)
@ -578,20 +602,16 @@ class NotesPage(KneeboardPage):
def __init__(
self,
game: "Game",
notes: str,
dark_kneeboard: bool,
) -> None:
self.game = game
self.notes = notes
self.dark_kneeboard = dark_kneeboard
def write(self, path: Path) -> None:
writer = KneeboardPageWriter(dark_theme=self.dark_kneeboard)
writer.title(f"Notes")
try:
writer.text(self.game.notes)
except AttributeError: # old saves may not have .notes ;)
writer.text("")
writer.text(self.notes)
writer.write(path)
@ -663,12 +683,12 @@ class KneeboardGenerator(MissionInfoGenerator):
self.mission.start_time,
self.dark_kneeboard,
),
NotesPage(
self.game,
self.dark_kneeboard,
),
]
# Only create the notes page if there are notes to show.
if notes := self.game.notes:
pages.append(NotesPage(notes, self.dark_kneeboard))
if (target_page := self.generate_task_page(flight)) is not None:
pages.append(target_page)

View File

@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence
from game import Game
from game.factions.faction import Faction
from game.theater.theatergroundobject import MissileSiteGroundObject
from game.utils import Heading
from gen.sam.group_generator import VehicleGroupGenerator
@ -63,5 +64,5 @@ class ScudGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
"STRELA#0",
self.position.x + 200,
self.position.y + 15,
90,
Heading.from_degrees(90),
)

View File

@ -5,6 +5,7 @@ from dcs.vehicles import Unarmed, MissilesSS, AirDefence
from game import Game
from game.factions.faction import Faction
from game.theater.theatergroundobject import MissileSiteGroundObject
from game.utils import Heading
from gen.sam.group_generator import VehicleGroupGenerator
@ -65,5 +66,5 @@ class V1GroupGenerator(VehicleGroupGenerator[MissileSiteGroundObject]):
"Blitz#0",
self.position.x + 200,
self.position.y + 15,
90,
Heading.from_degrees(90),
)

View File

@ -8,6 +8,7 @@ from typing import Iterator, Optional
from dcs.terrain.terrain import Airport
from game.weather import Conditions
from game.utils import Heading
from .airfields import AIRFIELD_DATA
from .radios import RadioFrequency
from .tacan import TacanChannel
@ -16,7 +17,7 @@ from .tacan import TacanChannel
@dataclass(frozen=True)
class RunwayData:
airfield_name: str
runway_heading: int
runway_heading: Heading
runway_name: str
atc: Optional[RadioFrequency] = None
tacan: Optional[TacanChannel] = None
@ -26,7 +27,7 @@ class RunwayData:
@classmethod
def for_airfield(
cls, airport: Airport, runway_heading: int, runway_name: str
cls, airport: Airport, runway_heading: Heading, runway_name: str
) -> RunwayData:
"""Creates RunwayData for the given runway of an airfield.
@ -66,12 +67,14 @@ class RunwayData:
runway_number = runway.heading // 10
runway_side = ["", "L", "R"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
yield cls.for_airfield(airport, runway.heading, runway_name)
yield cls.for_airfield(
airport, Heading.from_degrees(runway.heading), runway_name
)
# pydcs only exposes one runway per physical runway, so to expose
# both sides of the runway we need to generate the other.
heading = (runway.heading + 180) % 360
runway_number = heading // 10
heading = Heading.from_degrees(runway.heading).opposite
runway_number = heading.degrees // 10
runway_side = ["", "R", "L"][runway.leftright]
runway_name = f"{runway_number:02}{runway_side}"
yield cls.for_airfield(airport, heading, runway_name)
@ -81,10 +84,10 @@ class RunwayAssigner:
def __init__(self, conditions: Conditions):
self.conditions = conditions
def angle_off_headwind(self, runway: RunwayData) -> int:
wind = self.conditions.weather.wind.at_0m.direction
ideal_heading = (wind + 180) % 360
return abs(runway.runway_heading - ideal_heading)
def angle_off_headwind(self, runway: RunwayData) -> Heading:
wind = Heading.from_degrees(self.conditions.weather.wind.at_0m.direction)
ideal_heading = wind.opposite
return runway.runway_heading.angle_between(ideal_heading)
def get_preferred_runway(self, airport: Airport) -> RunwayData:
"""Returns the preferred runway for the given airport.

View File

@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
from game.utils import Heading
GFLAK = [
AirDefence.Flak38,
@ -88,7 +89,7 @@ class FlakGenerator(AirDefenseGroupGenerator):
"BLITZ#" + str(index),
self.position.x + 125 + 15 * i + random.randint(1, 5),
self.position.y + 15 * j + random.randint(1, 5),
75,
Heading.from_degrees(75),
)
@classmethod

View File

@ -6,6 +6,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
from game.utils import Heading
class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
@ -53,28 +54,28 @@ class AllyWW2FlakGenerator(AirDefenseGroupGenerator):
"CMD#1",
self.position.x,
self.position.y - 20,
random.randint(0, 360),
Heading.random(),
)
self.add_unit(
Unarmed.M30_CC,
"LOG#1",
self.position.x,
self.position.y + 20,
random.randint(0, 360),
Heading.random(),
)
self.add_unit(
Unarmed.M4_Tractor,
"LOG#2",
self.position.x + 20,
self.position.y,
random.randint(0, 360),
Heading.random(),
)
self.add_unit(
Unarmed.Bedford_MWD,
"LOG#3",
self.position.x - 20,
self.position.y,
random.randint(0, 360),
Heading.random(),
)
@classmethod

View File

@ -48,6 +48,7 @@ class AirDefenseGroupGenerator(VehicleGroupGenerator[SamGroundObject], ABC):
self.vg.name = self.group_name_for_role(self.vg.id, self.primary_group_role())
self.auxiliary_groups: List[VehicleGroup] = []
self.heading = self.heading_to_conflict()
def add_auxiliary_group(self, role: SkynetRole) -> VehicleGroup:
gid = self.game.next_group_id()

View File

@ -41,7 +41,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#1",
self.position.x - 40,
self.position.y - 40,
self.heading + 180,
self.heading.opposite,
),
self.add_unit(
AirDefence.S_60_Type59_Artillery,
@ -57,7 +57,7 @@ class EarlyColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#3",
self.position.x - 80,
self.position.y - 40,
self.heading + 180,
self.heading.opposite,
),
self.add_unit(
AirDefence.ZU_23_Emplacement_Closed,
@ -113,7 +113,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#1",
self.position.x - 40,
self.position.y - 40,
self.heading + 180,
self.heading.opposite,
),
self.add_unit(
AirDefence.S_60_Type59_Artillery,
@ -129,7 +129,7 @@ class ColdWarFlakGenerator(AirDefenseGroupGenerator):
"SHO#3",
self.position.x - 80,
self.position.y - 40,
self.heading + 180,
self.heading.opposite,
),
self.add_unit(
AirDefence.ZU_23_Emplacement_Closed,

View File

@ -4,6 +4,7 @@ from gen.sam.airdefensegroupgenerator import (
AirDefenseRange,
AirDefenseGroupGenerator,
)
from game.utils import Heading
class FreyaGenerator(AirDefenseGroupGenerator):
@ -101,7 +102,7 @@ class FreyaGenerator(AirDefenseGroupGenerator):
"Inf#3",
self.position.x + 20,
self.position.y - 24,
self.heading + 45,
self.heading + Heading.from_degrees(45),
)
@classmethod

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import math
import operator
import random
from collections import Iterable
from typing import TYPE_CHECKING, Type, TypeVar, Generic, Any
@ -15,7 +16,9 @@ from dcs.unittype import VehicleType, UnitType, ShipType
from game.dcs.groundunittype import GroundUnitType
from game.factions.faction import Faction
from game.theater import MissionTarget
from game.theater.theatergroundobject import TheaterGroundObject, NavalGroundObject
from game.utils import Heading
if TYPE_CHECKING:
from game.game import Game
@ -37,7 +40,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
self.game = game
self.go = ground_object
self.position = ground_object.position
self.heading = random.randint(0, 359)
self.heading: Heading = Heading.random()
self.price = 0
self.vg: GroupT = group
@ -53,7 +56,7 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
name: str,
pos_x: float,
pos_y: float,
heading: int,
heading: Heading,
) -> UnitT:
return self.add_unit_to_group(
self.vg, unit_type, name, Point(pos_x, pos_y), heading
@ -65,10 +68,33 @@ class GroupGenerator(Generic[GroupT, UnitT, UnitTypeT, TgoT]):
unit_type: UnitTypeT,
name: str,
position: Point,
heading: int,
heading: Heading,
) -> UnitT:
raise NotImplementedError
def heading_to_conflict(self) -> Heading:
# Heading for a Group to the enemy.
# Should be the point between the nearest and the most distant conflict
conflicts: dict[MissionTarget, float] = {}
for conflict in self.game.theater.conflicts():
conflicts[conflict] = conflict.distance_to(self.go)
if len(conflicts) == 0:
return self.heading
closest_conflict = min(conflicts.items(), key=operator.itemgetter(1))[0]
most_distant_conflict = max(conflicts.items(), key=operator.itemgetter(1))[0]
conflict_center = Point(
(closest_conflict.position.x + most_distant_conflict.position.x) / 2,
(closest_conflict.position.y + most_distant_conflict.position.y) / 2,
)
return Heading.from_degrees(
self.go.position.heading_between_point(conflict_center)
)
class VehicleGroupGenerator(
Generic[TgoT], GroupGenerator[VehicleGroup, Vehicle, Type[VehicleType], TgoT]
@ -91,11 +117,11 @@ class VehicleGroupGenerator(
unit_type: Type[VehicleType],
name: str,
position: Point,
heading: int,
heading: Heading,
) -> Vehicle:
unit = Vehicle(self.game.next_unit_id(), f"{group.name}|{name}", unit_type.id)
unit.position = position
unit.heading = heading
unit.heading = heading.degrees
group.add_unit(unit)
# get price of unit to calculate the real price of the whole group
@ -109,7 +135,7 @@ class VehicleGroupGenerator(
def get_circular_position(
self, num_units: int, launcher_distance: int, coverage: int = 90
) -> Iterable[tuple[float, float, int]]:
) -> Iterable[tuple[float, float, Heading]]:
"""
Given a position on the map, array a group of units in a circle a uniform distance from the unit
:param num_units:
@ -131,9 +157,9 @@ class VehicleGroupGenerator(
positions = []
if num_units % 2 == 0:
current_offset = self.heading - ((coverage / (num_units - 1)) / 2)
current_offset = self.heading.degrees - ((coverage / (num_units - 1)) / 2)
else:
current_offset = self.heading
current_offset = self.heading.degrees
current_offset -= outer_offset * (math.ceil(num_units / 2) - 1)
for _ in range(1, num_units + 1):
x: float = self.position.x + launcher_distance * math.cos(
@ -142,8 +168,7 @@ class VehicleGroupGenerator(
y: float = self.position.y + launcher_distance * math.sin(
math.radians(current_offset)
)
heading = current_offset
positions.append((x, y, int(heading)))
positions.append((x, y, Heading.from_degrees(current_offset)))
current_offset += outer_offset
return positions
@ -172,10 +197,10 @@ class ShipGroupGenerator(
unit_type: Type[ShipType],
name: str,
position: Point,
heading: int,
heading: Heading,
) -> Ship:
unit = Ship(self.game.next_unit_id(), f"{self.go.group_name}|{name}", unit_type)
unit.position = position
unit.heading = heading
unit.heading = heading.degrees
group.add_unit(unit)
return unit

View File

@ -1,16 +0,0 @@
"""Unit conversions."""
def meters_to_feet(meters: float) -> float:
"""Converts meters to feet."""
return meters * 3.28084
def inches_hg_to_mm_hg(inches_hg: float) -> float:
"""Converts inches mercury to millimeters mercury."""
return inches_hg * 25.400002776728
def inches_hg_to_hpa(inches_hg: float) -> float:
"""Converts inches mercury to hectopascal."""
return inches_hg * 33.86389

View File

@ -86,7 +86,7 @@ class VisualGenerator:
continue
for offset in range(0, distance, self.game.settings.perf_smoke_spacing):
position = plane_start.point_from_heading(heading, offset)
position = plane_start.point_from_heading(heading.degrees, offset)
for k, v in FRONT_SMOKE_TYPE_CHANCES.items():
if random.randint(0, 100) <= k:

View File

@ -13,8 +13,9 @@ from PySide2.QtWidgets import QApplication, QSplashScreen
from dcs.payloads import PayloadDirectories
from game import Game, VERSION, persistency
from game.data.weapons import WeaponGroup
from game.data.weapons import WeaponGroup, Pylon, Weapon
from game.db import FACTIONS
from game.dcs.aircrafttype import AircraftType
from game.profiling import logged_duration
from game.settings import Settings
from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings
@ -183,8 +184,24 @@ def parse_args() -> argparse.Namespace:
"--inverted", action="store_true", help="Invert the campaign."
)
new_game.add_argument(
"--date",
type=datetime.fromisoformat,
default=datetime.today(),
help="Start date of the campaign.",
)
new_game.add_argument(
"--restrict-weapons-by-date",
action="store_true",
help="Enable campaign date restricted weapons.",
)
new_game.add_argument("--cheats", action="store_true", help="Enable cheats.")
lint_weapons = subparsers.add_parser("lint-weapons")
lint_weapons.add_argument("aircraft", help="Name of the aircraft variant to lint.")
return parser.parse_args()
@ -196,6 +213,8 @@ def create_game(
auto_procurement: bool,
inverted: bool,
cheats: bool,
start_date: datetime,
restrict_weapons_by_date: bool,
) -> Game:
first_start = liberation_install.init()
if first_start:
@ -224,9 +243,10 @@ def create_game(
automate_aircraft_reinforcements=auto_procurement,
enable_frontline_cheats=cheats,
enable_base_capture_cheat=cheats,
restrict_weapons_by_date=restrict_weapons_by_date,
),
GeneratorSettings(
start_date=datetime.today(),
start_date=start_date,
player_budget=DEFAULT_BUDGET,
enemy_budget=DEFAULT_BUDGET,
midgame=False,
@ -246,14 +266,26 @@ def create_game(
high_digit_sams=False,
),
)
return generator.generate()
game = generator.generate()
game.begin_turn_0()
return game
def lint_weapon_data() -> None:
def lint_all_weapon_data() -> None:
for weapon in WeaponGroup.named("Unknown").weapons:
logging.warning(f"No weapon data for {weapon}: {weapon.clsid}")
def lint_weapon_data_for_aircraft(aircraft: AircraftType) -> None:
all_weapons: set[Weapon] = set()
for pylon in Pylon.iter_pylons(aircraft):
all_weapons |= pylon.allowed
for weapon in all_weapons:
if weapon.weapon_group.name == "Unknown":
logging.warning(f'{weapon.clsid} "{weapon.name}" has no weapon data')
def main():
logging_config.init_logging(VERSION)
@ -265,7 +297,7 @@ def main():
# TODO: Flesh out data and then make unconditional.
if args.warn_missing_weapon_data:
lint_weapon_data()
lint_all_weapon_data()
if args.subcommand == "new-game":
with logged_duration("New game creation"):
@ -277,7 +309,12 @@ def main():
args.auto_procurement,
args.inverted,
args.cheats,
args.date,
args.restrict_weapons_by_date,
)
if args.subcommand == "lint-weapons":
lint_weapon_data_for_aircraft(AircraftType.named(args.aircraft))
return
run_ui(game)

View File

@ -8,10 +8,17 @@ from PySide2.QtCore import Property, QObject, Signal, Slot
from dcs import Point
from dcs.unit import Unit
from dcs.vehicles import vehicle_map
from shapely.geometry import LineString, Point as ShapelyPoint, Polygon, MultiPolygon
from shapely.geometry import (
LineString,
Point as ShapelyPoint,
Polygon,
MultiPolygon,
MultiLineString,
)
from game import Game
from game.dcs.groundunittype import GroundUnitType
from game.flightplan import JoinZoneGeometry, HoldZoneGeometry
from game.navmesh import NavMesh, NavMeshPoly
from game.profiling import logged_duration
from game.theater import (
@ -27,7 +34,12 @@ from game.transfers import MultiGroupTransport, TransportMap
from game.utils import meters, nautical_miles
from gen.ato import AirTaskingOrder
from gen.flights.flight import Flight, FlightWaypoint, FlightWaypointType
from gen.flights.flightplan import FlightPlan, PatrollingFlightPlan, CasFlightPlan
from gen.flights.flightplan import (
FlightPlan,
PatrollingFlightPlan,
CasFlightPlan,
)
from game.flightplan.ipzonegeometry import IpZoneGeometry
from qt_ui.dialogs import Dialog
from qt_ui.models import GameModel, AtoModel
from qt_ui.windows.GameUpdateSignal import GameUpdateSignal
@ -39,6 +51,10 @@ LeafletPoly = list[LeafletLatLon]
MAX_SHIP_DISTANCE = nautical_miles(80)
# Set to True to enable computing expensive debugging information. At the time of
# writing this only controls computing the waypoint placement zones.
ENABLE_EXPENSIVE_DEBUG_TOOLS = False
# **EVERY PROPERTY NEEDS A NOTIFY SIGNAL**
#
# https://bugreports.qt.io/browse/PYSIDE-1426
@ -73,6 +89,18 @@ def shapely_to_leaflet_polys(
return [shapely_poly_to_leaflet_points(poly, theater) for poly in polys]
def shapely_line_to_leaflet_points(
line: LineString, theater: ConflictTheater
) -> list[LeafletLatLon]:
return [theater.point_to_ll(Point(x, y)).as_list() for x, y in line.coords]
def shapely_lines_to_leaflet_points(
lines: MultiLineString, theater: ConflictTheater
) -> list[list[LeafletLatLon]]:
return [shapely_line_to_leaflet_points(l, theater) for l in lines.geoms]
class ControlPointJs(QObject):
nameChanged = Signal()
blueChanged = Signal()
@ -389,12 +417,12 @@ class FrontLineJs(QObject):
def extents(self) -> List[LeafletLatLon]:
a = self.theater.point_to_ll(
self.front_line.position.point_from_heading(
self.front_line.attack_heading + 90, nautical_miles(2).meters
self.front_line.attack_heading.right.degrees, nautical_miles(2).meters
)
)
b = self.theater.point_to_ll(
self.front_line.position.point_from_heading(
self.front_line.attack_heading + 270, nautical_miles(2).meters
self.front_line.attack_heading.left.degrees, nautical_miles(2).meters
)
)
return [[a.latitude, a.longitude], [b.latitude, b.longitude]]
@ -512,6 +540,19 @@ class FlightJs(QObject):
selectedChanged = Signal()
commitBoundaryChanged = Signal()
originChanged = Signal()
@Property(list, notify=originChanged)
def origin(self) -> LeafletLatLon:
return self._waypoints[0].position
targetChanged = Signal()
@Property(list, notify=targetChanged)
def target(self) -> LeafletLatLon:
ll = self.theater.point_to_ll(self.flight.package.target.position)
return [ll.latitude, ll.longitude]
def __init__(
self,
flight: Flight,
@ -769,6 +810,209 @@ class UnculledZone(QObject):
)
class IpZonesJs(QObject):
homeBubbleChanged = Signal()
ipBubbleChanged = Signal()
permissibleZoneChanged = Signal()
safeZonesChanged = Signal()
def __init__(
self,
home_bubble: LeafletPoly,
ip_bubble: LeafletPoly,
permissible_zone: LeafletPoly,
safe_zones: list[LeafletPoly],
) -> None:
super().__init__()
self._home_bubble = home_bubble
self._ip_bubble = ip_bubble
self._permissible_zone = permissible_zone
self._safe_zones = safe_zones
@Property(list, notify=homeBubbleChanged)
def homeBubble(self) -> LeafletPoly:
return self._home_bubble
@Property(list, notify=ipBubbleChanged)
def ipBubble(self) -> LeafletPoly:
return self._ip_bubble
@Property(list, notify=permissibleZoneChanged)
def permissibleZone(self) -> LeafletPoly:
return self._permissible_zone
@Property(list, notify=safeZonesChanged)
def safeZones(self) -> list[LeafletPoly]:
return self._safe_zones
@classmethod
def empty(cls) -> IpZonesJs:
return IpZonesJs([], [], [], [])
@classmethod
def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs:
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
return IpZonesJs.empty()
target = flight.package.target
home = flight.departure
geometry = IpZoneGeometry(target.position, home.position, game.blue)
return IpZonesJs(
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.permissible_zone, game.theater),
shapely_to_leaflet_polys(geometry.safe_zones, game.theater),
)
class JoinZonesJs(QObject):
homeBubbleChanged = Signal()
targetBubbleChanged = Signal()
ipBubbleChanged = Signal()
excludedZonesChanged = Signal()
permissibleZonesChanged = Signal()
preferredLinesChanged = Signal()
def __init__(
self,
home_bubble: LeafletPoly,
target_bubble: LeafletPoly,
ip_bubble: LeafletPoly,
excluded_zones: list[LeafletPoly],
permissible_zones: list[LeafletPoly],
preferred_lines: list[list[LeafletLatLon]],
) -> None:
super().__init__()
self._home_bubble = home_bubble
self._target_bubble = target_bubble
self._ip_bubble = ip_bubble
self._excluded_zones = excluded_zones
self._permissible_zones = permissible_zones
self._preferred_lines = preferred_lines
@Property(list, notify=homeBubbleChanged)
def homeBubble(self) -> LeafletPoly:
return self._home_bubble
@Property(list, notify=targetBubbleChanged)
def targetBubble(self) -> LeafletPoly:
return self._target_bubble
@Property(list, notify=ipBubbleChanged)
def ipBubble(self) -> LeafletPoly:
return self._ip_bubble
@Property(list, notify=excludedZonesChanged)
def excludedZones(self) -> list[LeafletPoly]:
return self._excluded_zones
@Property(list, notify=permissibleZonesChanged)
def permissibleZones(self) -> list[LeafletPoly]:
return self._permissible_zones
@Property(list, notify=preferredLinesChanged)
def preferredLines(self) -> list[list[LeafletLatLon]]:
return self._preferred_lines
@classmethod
def empty(cls) -> JoinZonesJs:
return JoinZonesJs([], [], [], [], [], [])
@classmethod
def for_flight(cls, flight: Flight, game: Game) -> JoinZonesJs:
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
return JoinZonesJs.empty()
target = flight.package.target
home = flight.departure
if flight.package.waypoints is None:
return JoinZonesJs.empty()
ip = flight.package.waypoints.ingress
geometry = JoinZoneGeometry(target.position, home.position, ip, game.blue)
return JoinZonesJs(
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.ip_bubble, game.theater),
shapely_to_leaflet_polys(geometry.excluded_zones, game.theater),
shapely_to_leaflet_polys(geometry.permissible_zones, game.theater),
shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater),
)
class HoldZonesJs(QObject):
homeBubbleChanged = Signal()
targetBubbleChanged = Signal()
joinBubbleChanged = Signal()
excludedZonesChanged = Signal()
permissibleZonesChanged = Signal()
preferredLinesChanged = Signal()
def __init__(
self,
home_bubble: LeafletPoly,
target_bubble: LeafletPoly,
join_bubble: LeafletPoly,
excluded_zones: list[LeafletPoly],
permissible_zones: list[LeafletPoly],
preferred_lines: list[list[LeafletLatLon]],
) -> None:
super().__init__()
self._home_bubble = home_bubble
self._target_bubble = target_bubble
self._join_bubble = join_bubble
self._excluded_zones = excluded_zones
self._permissible_zones = permissible_zones
self._preferred_lines = preferred_lines
@Property(list, notify=homeBubbleChanged)
def homeBubble(self) -> LeafletPoly:
return self._home_bubble
@Property(list, notify=targetBubbleChanged)
def targetBubble(self) -> LeafletPoly:
return self._target_bubble
@Property(list, notify=joinBubbleChanged)
def joinBubble(self) -> LeafletPoly:
return self._join_bubble
@Property(list, notify=excludedZonesChanged)
def excludedZones(self) -> list[LeafletPoly]:
return self._excluded_zones
@Property(list, notify=permissibleZonesChanged)
def permissibleZones(self) -> list[LeafletPoly]:
return self._permissible_zones
@Property(list, notify=preferredLinesChanged)
def preferredLines(self) -> list[list[LeafletLatLon]]:
return self._preferred_lines
@classmethod
def empty(cls) -> HoldZonesJs:
return HoldZonesJs([], [], [], [], [], [])
@classmethod
def for_flight(cls, flight: Flight, game: Game) -> HoldZonesJs:
if not ENABLE_EXPENSIVE_DEBUG_TOOLS:
return JoinZonesJs.empty()
target = flight.package.target
home = flight.departure
if flight.package.waypoints is None:
return HoldZonesJs.empty()
ip = flight.package.waypoints.ingress
join = flight.package.waypoints.join
geometry = HoldZoneGeometry(
target.position, home.position, ip, join, game.blue, game.theater
)
return HoldZonesJs(
shapely_poly_to_leaflet_points(geometry.home_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.target_bubble, game.theater),
shapely_poly_to_leaflet_points(geometry.join_bubble, game.theater),
shapely_to_leaflet_polys(geometry.excluded_zones, game.theater),
shapely_to_leaflet_polys(geometry.permissible_zones, game.theater),
shapely_lines_to_leaflet_points(geometry.preferred_lines, game.theater),
)
class MapModel(QObject):
cleared = Signal()
@ -782,6 +1026,9 @@ class MapModel(QObject):
navmeshesChanged = Signal()
mapZonesChanged = Signal()
unculledZonesChanged = Signal()
ipZonesChanged = Signal()
joinZonesChanged = Signal()
holdZonesChanged = Signal()
def __init__(self, game_model: GameModel) -> None:
super().__init__()
@ -798,6 +1045,9 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = []
self._ip_zones = IpZonesJs.empty()
self._join_zones = JoinZonesJs.empty()
self._hold_zones = HoldZonesJs.empty()
self._selected_flight_index: Optional[Tuple[int, int]] = None
GameUpdateSignal.get_instance().game_loaded.connect(self.on_game_load)
GameUpdateSignal.get_instance().flight_paths_changed.connect(self.reset_atos)
@ -821,6 +1071,7 @@ class MapModel(QObject):
self._navmeshes = NavMeshJs([], [])
self._map_zones = MapZonesJs([], [], [])
self._unculled_zones = []
self._ip_zones = IpZonesJs.empty()
self.cleared.emit()
def set_package_selection(self, index: int) -> None:
@ -896,11 +1147,30 @@ class MapModel(QObject):
)
return flights
def _get_selected_flight(self) -> Optional[Flight]:
for p_idx, package in enumerate(self.game.blue.ato.packages):
for f_idx, flight in enumerate(package.flights):
if (p_idx, f_idx) == self._selected_flight_index:
return flight
return None
def reset_atos(self) -> None:
self._flights = self._flights_in_ato(
self.game.blue.ato, blue=True
) + self._flights_in_ato(self.game.red.ato, blue=False)
self.flightsChanged.emit()
selected_flight = self._get_selected_flight()
if selected_flight is None:
self._ip_zones = IpZonesJs.empty()
self._join_zones = JoinZonesJs.empty()
self._hold_zones = HoldZonesJs.empty()
else:
self._ip_zones = IpZonesJs.for_flight(selected_flight, self.game)
self._join_zones = JoinZonesJs.for_flight(selected_flight, self.game)
self._hold_zones = HoldZonesJs.for_flight(selected_flight, self.game)
self.ipZonesChanged.emit()
self.joinZonesChanged.emit()
self.holdZonesChanged.emit()
@Property(list, notify=flightsChanged)
def flights(self) -> List[FlightJs]:
@ -1029,6 +1299,18 @@ class MapModel(QObject):
def unculledZones(self) -> list[UnculledZone]:
return self._unculled_zones
@Property(IpZonesJs, notify=ipZonesChanged)
def ipZones(self) -> IpZonesJs:
return self._ip_zones
@Property(JoinZonesJs, notify=joinZonesChanged)
def joinZones(self) -> JoinZonesJs:
return self._join_zones
@Property(HoldZonesJs, notify=holdZonesChanged)
def holdZones(self) -> HoldZonesJs:
return self._hold_zones
@property
def game(self) -> Game:
if self.game_model.game is None:

View File

@ -0,0 +1,326 @@
from typing import Optional, Callable
from PySide2.QtCore import (
QItemSelectionModel,
QModelIndex,
QSize,
Qt,
QItemSelection,
Signal,
)
from PySide2.QtGui import QStandardItemModel, QStandardItem, QIcon
from PySide2.QtWidgets import (
QAbstractItemView,
QDialog,
QListView,
QVBoxLayout,
QGroupBox,
QLabel,
QWidget,
QScrollArea,
QLineEdit,
QTextEdit,
QCheckBox,
QHBoxLayout,
QStackedLayout,
QTabWidget,
)
from game import Game
from game.dcs.aircrafttype import AircraftType
from game.squadrons import Squadron, AirWing, Pilot
from gen.flights.flight import FlightType
from qt_ui.models import AirWingModel, SquadronModel
from qt_ui.uiconstants import AIRCRAFT_ICONS
from qt_ui.windows.AirWingDialog import SquadronDelegate
from qt_ui.windows.SquadronDialog import SquadronDialog
class SquadronList(QListView):
"""List view for displaying the air wing's squadrons."""
def __init__(self, air_wing_model: AirWingModel) -> None:
super().__init__()
self.air_wing_model = air_wing_model
self.dialog: Optional[SquadronDialog] = None
self.setIconSize(QSize(91, 24))
self.setItemDelegate(SquadronDelegate(self.air_wing_model))
self.setModel(self.air_wing_model)
self.selectionModel().setCurrentIndex(
self.air_wing_model.index(0, 0, QModelIndex()), QItemSelectionModel.Select
)
# self.setIconSize(QSize(91, 24))
self.setSelectionBehavior(QAbstractItemView.SelectItems)
self.doubleClicked.connect(self.on_double_click)
def on_double_click(self, index: QModelIndex) -> None:
if not index.isValid():
return
self.dialog = SquadronDialog(
SquadronModel(self.air_wing_model.squadron_at_index(index)), self
)
self.dialog.show()
class AllowedMissionTypeControls(QVBoxLayout):
def __init__(self, squadron: Squadron) -> None:
super().__init__()
self.squadron = squadron
self.allowed_mission_types = set()
self.addWidget(QLabel("Allowed mission types"))
def make_callback(toggled_task: FlightType) -> Callable[[bool], None]:
def callback(checked: bool) -> None:
self.on_toggled(toggled_task, checked)
return callback
for task in FlightType:
enabled = task in squadron.mission_types
if enabled:
self.allowed_mission_types.add(task)
checkbox = QCheckBox(text=task.value)
checkbox.setChecked(enabled)
checkbox.toggled.connect(make_callback(task))
self.addWidget(checkbox)
self.addStretch()
def on_toggled(self, task: FlightType, checked: bool) -> None:
if checked:
self.allowed_mission_types.add(task)
else:
self.allowed_mission_types.remove(task)
class SquadronConfigurationBox(QGroupBox):
def __init__(self, squadron: Squadron) -> None:
super().__init__()
self.setCheckable(True)
self.squadron = squadron
self.reset_title()
columns = QHBoxLayout()
self.setLayout(columns)
left_column = QVBoxLayout()
columns.addLayout(left_column)
left_column.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(squadron.name)
self.name_edit.textChanged.connect(self.on_name_changed)
left_column.addWidget(self.name_edit)
left_column.addWidget(QLabel("Nickname:"))
self.nickname_edit = QLineEdit(squadron.nickname)
self.nickname_edit.textChanged.connect(self.on_nickname_changed)
left_column.addWidget(self.nickname_edit)
if squadron.player:
player_label = QLabel(
"Players (one per line, leave empty for an AI-only squadron):"
)
else:
player_label = QLabel("Player slots not available for opfor")
left_column.addWidget(player_label)
players = [p for p in squadron.pilot_pool if p.player]
for player in players:
squadron.pilot_pool.remove(player)
if not squadron.player:
players = []
self.player_list = QTextEdit("<br />".join(p.name for p in players))
self.player_list.setAcceptRichText(False)
self.player_list.setEnabled(squadron.player)
left_column.addWidget(self.player_list)
left_column.addStretch()
self.allowed_missions = AllowedMissionTypeControls(squadron)
columns.addLayout(self.allowed_missions)
def on_name_changed(self, text: str) -> None:
self.squadron.name = text
self.reset_title()
def on_nickname_changed(self, text: str) -> None:
self.squadron.nickname = text
def reset_title(self) -> None:
self.setTitle(f"{self.squadron.name} - {self.squadron.aircraft}")
def apply(self) -> Squadron:
player_names = self.player_list.toPlainText().splitlines()
# Prepend player pilots so they get set active first.
self.squadron.pilot_pool = [
Pilot(n, player=True) for n in player_names
] + self.squadron.pilot_pool
self.squadron.mission_types = tuple(self.allowed_missions.allowed_mission_types)
return self.squadron
class SquadronConfigurationLayout(QVBoxLayout):
def __init__(self, squadrons: list[Squadron]) -> None:
super().__init__()
self.squadron_configs = []
for squadron in squadrons:
squadron_config = SquadronConfigurationBox(squadron)
self.squadron_configs.append(squadron_config)
self.addWidget(squadron_config)
def apply(self) -> list[Squadron]:
keep_squadrons = []
for squadron_config in self.squadron_configs:
if squadron_config.isChecked():
keep_squadrons.append(squadron_config.apply())
return keep_squadrons
class AircraftSquadronsPage(QWidget):
def __init__(self, squadrons: list[Squadron]) -> None:
super().__init__()
layout = QVBoxLayout()
self.setLayout(layout)
self.squadrons_config = SquadronConfigurationLayout(squadrons)
scrolling_widget = QWidget()
scrolling_widget.setLayout(self.squadrons_config)
scrolling_area = QScrollArea()
scrolling_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scrolling_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
scrolling_area.setWidgetResizable(True)
scrolling_area.setWidget(scrolling_widget)
layout.addWidget(scrolling_area)
def apply(self) -> list[Squadron]:
return self.squadrons_config.apply()
class AircraftSquadronsPanel(QStackedLayout):
def __init__(self, air_wing: AirWing) -> None:
super().__init__()
self.air_wing = air_wing
self.squadrons_pages: dict[AircraftType, AircraftSquadronsPage] = {}
for aircraft, squadrons in self.air_wing.squadrons.items():
page = AircraftSquadronsPage(squadrons)
self.addWidget(page)
self.squadrons_pages[aircraft] = page
def apply(self) -> None:
for aircraft, page in self.squadrons_pages.items():
self.air_wing.squadrons[aircraft] = page.apply()
class AircraftTypeList(QListView):
page_index_changed = Signal(int)
def __init__(self, air_wing: AirWing) -> None:
super().__init__()
self.setIconSize(QSize(91, 24))
self.setMinimumWidth(300)
model = QStandardItemModel(self)
self.setModel(model)
self.selectionModel().setCurrentIndex(
model.index(0, 0), QItemSelectionModel.Select
)
self.selectionModel().selectionChanged.connect(self.on_selection_changed)
for aircraft in air_wing.squadrons:
aircraft_item = QStandardItem(aircraft.name)
icon = self.icon_for(aircraft)
if icon is not None:
aircraft_item.setIcon(icon)
aircraft_item.setEditable(False)
aircraft_item.setSelectable(True)
model.appendRow(aircraft_item)
def on_selection_changed(
self, selected: QItemSelection, _deselected: QItemSelection
) -> None:
indexes = selected.indexes()
if len(indexes) > 1:
raise RuntimeError("Aircraft list should not allow multi-selection")
if not indexes:
return
self.page_index_changed.emit(indexes[0].row())
@staticmethod
def icon_for(aircraft: AircraftType) -> Optional[QIcon]:
name = aircraft.dcs_id
if name in AIRCRAFT_ICONS:
return QIcon(AIRCRAFT_ICONS[name])
return None
class AirWingConfigurationTab(QWidget):
def __init__(self, air_wing: AirWing) -> None:
super().__init__()
layout = QHBoxLayout()
self.setLayout(layout)
type_list = AircraftTypeList(air_wing)
type_list.page_index_changed.connect(self.on_aircraft_changed)
layout.addWidget(type_list)
self.squadrons_panel = AircraftSquadronsPanel(air_wing)
layout.addLayout(self.squadrons_panel)
def apply(self) -> None:
self.squadrons_panel.apply()
def on_aircraft_changed(self, index: QModelIndex) -> None:
self.squadrons_panel.setCurrentIndex(index)
class AirWingConfigurationDialog(QDialog):
"""Dialog window for air wing configuration."""
def __init__(self, game: Game, parent) -> None:
super().__init__(parent)
self.setMinimumSize(500, 800)
self.setWindowTitle(f"Air Wing Configuration")
# TODO: self.setWindowIcon()
layout = QVBoxLayout()
self.setLayout(layout)
doc_url = (
"https://github.com/dcs-liberation/dcs_liberation/wiki/Squadrons-and-pilots"
)
doc_label = QLabel(
"Use this opportunity to customize the squadrons available to your "
"coalition. <strong>This is your only opportunity to make changes.</strong>"
"<br /><br />"
"To accept your changes and continue, close this window.<br />"
"<br />"
"To remove a squadron from the game, uncheck the box in the title. New "
"squadrons cannot be added via the UI at this time. To add a custom "
"squadron,<br />"
f'see <a style="color:#ffffff" href="{doc_url}">the wiki</a>.'
)
doc_label.setOpenExternalLinks(True)
layout.addWidget(doc_label)
tab_widget = QTabWidget()
layout.addWidget(tab_widget)
self.tabs = []
for coalition in game.coalitions:
coalition_tab = AirWingConfigurationTab(coalition.air_wing)
name = "Blue" if coalition.player else "Red"
tab_widget.addTab(coalition_tab, name)
self.tabs.append(coalition_tab)
def reject(self) -> None:
for tab in self.tabs:
tab.apply()
super().reject()

View File

@ -209,8 +209,6 @@ class QRecruitBehaviour:
if self.pending_deliveries.available_next_turn(unit_type) > 0:
self.budget += unit_type.price
self.pending_deliveries.sell({unit_type: 1})
if self.pending_deliveries.units[unit_type] == 0:
del self.pending_deliveries.units[unit_type]
self.update_purchase_controls()
self.update_available_budget()
return True

View File

@ -15,6 +15,7 @@ from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSe
from game.factions.faction import Faction
from qt_ui.widgets.QLiberationCalendar import QLiberationCalendar
from qt_ui.widgets.spinsliders import TenthsSpinSlider, TimeInputs, CurrencySpinner
from qt_ui.windows.AirWingConfigurationDialog import AirWingConfigurationDialog
from qt_ui.windows.newgame.QCampaignList import (
Campaign,
QCampaignList,
@ -125,6 +126,10 @@ class NewGameWizard(QtWidgets.QWizard):
)
self.generatedGame = generator.generate()
AirWingConfigurationDialog(self.generatedGame, self).exec_()
self.generatedGame.begin_turn_0()
super(NewGameWizard, self).accept()

View File

@ -19,7 +19,7 @@ pathspec==0.8.1
pefile==2019.4.18
Pillow==8.2.0
pre-commit==2.10.1
-e git://github.com/pydcs/dcs@2baba37e32bc55fed59ef977c43dad275c9821eb#egg=pydcs
-e git://github.com/pydcs/dcs@7eb720b341c95ad4c3659cc934be4029d526c36e#egg=pydcs
pyinstaller==4.3
pyinstaller-hooks-contrib==2021.1
pyparsing==2.4.7

View File

@ -4,8 +4,8 @@
"authors": "Colonel Panic",
"recommended_player_faction": "Iran 2015",
"recommended_enemy_faction": "United Arab Emirates 2015",
"description": "<p>You have managed to establish a foothold at Khasab. Continue pushing south.</p>",
"description": "<p>You have managed to establish a foothold near Ras Al Khaima. Continue pushing south.</p>",
"miz": "battle_of_abu_dhabi.miz",
"performance": 2,
"version": "7.0"
"version": "8.0"
}

View File

@ -5,5 +5,5 @@
"description": "<p>A medium sized theater with bases along the coast of the Black Sea.</p>",
"miz": "black_sea.miz",
"performance": 2,
"version": "7.0"
"version": "8.0"
}

View File

@ -61,7 +61,6 @@
"SA8Generator",
"SA8Generator",
"SA9Generator",
"SA10Generator",
"SA11Generator",
"SA13Generator",
"SA17Generator",

View File

@ -1,3 +1,7 @@
// Won't actually enable anything unless the same property is set in
// mapmodel.py.
const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
const Colors = Object.freeze({
Blue: "#0084ff",
Red: "#c85050",
@ -124,26 +128,26 @@ const map = L.map("map", {
L.control.scale({ maxWidth: 200 }).addTo(map);
const rulerOptions = {
position: 'topleft',
position: "topleft",
circleMarker: {
color: Colors.Highlight,
radius: 2
radius: 2,
},
lineStyle: {
color: Colors.Highlight,
dashArray: '1,6'
dashArray: "1,6",
},
lengthUnit: {
display: "nm",
decimal: "2",
factor: 0.539956803,
label: "Distance:"
label: "Distance:",
},
angleUnit: {
display: '&deg;',
display: "&deg;",
decimal: 0,
label: "Bearing:"
}
label: "Bearing:",
},
};
L.control.ruler(rulerOptions).addTo(map);
@ -194,6 +198,48 @@ const exclusionZones = L.layerGroup();
const seaZones = L.layerGroup();
const unculledZones = L.layerGroup();
const noWaypointZones = L.layerGroup();
const ipZones = L.layerGroup();
const joinZones = L.layerGroup();
const holdZones = L.layerGroup().addTo(map);
const debugControlGroups = {
"Blue Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: blueFullThreatZones,
Aircraft: blueAircraftThreatZones,
"Air Defenses": blueAirDefenseThreatZones,
"Radar SAMs": blueRadarSamThreatZones,
},
"Red Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: redFullThreatZones,
Aircraft: redAircraftThreatZones,
"Air Defenses": redAirDefenseThreatZones,
"Radar SAMs": redRadarSamThreatZones,
},
Navmeshes: {
Hide: L.layerGroup().addTo(map),
Blue: blueNavmesh,
Red: redNavmesh,
},
"Map Zones": {
"Inclusion zones": inclusionZones,
"Exclusion zones": exclusionZones,
"Sea zones": seaZones,
"Culling exclusion zones": unculledZones,
},
};
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
debugControlGroups["Waypoint Zones"] = {
None: noWaypointZones,
"IP Zones": ipZones,
"Join Zones": joinZones,
"Hold Zones": holdZones,
};
}
// Main map controls. These are the ones that we expect users to interact with.
// These are always open, which unfortunately means that the scroll bar will not
// appear if the menu doesn't fit. This fits in the smallest window size we
@ -239,41 +285,16 @@ L.control
// Debug map controls. Hover over to open. Not something most users will want or
// need to interact with.
L.control
.groupedLayers(
null,
{
"Blue Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: blueFullThreatZones,
Aircraft: blueAircraftThreatZones,
"Air Defenses": blueAirDefenseThreatZones,
"Radar SAMs": blueRadarSamThreatZones,
},
"Red Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: redFullThreatZones,
Aircraft: redAircraftThreatZones,
"Air Defenses": redAirDefenseThreatZones,
"Radar SAMs": redRadarSamThreatZones,
},
Navmeshes: {
Hide: L.layerGroup().addTo(map),
Blue: blueNavmesh,
Red: redNavmesh,
},
"Map Zones": {
"Inclusion zones": inclusionZones,
"Exclusion zones": exclusionZones,
"Sea zones": seaZones,
"Culling exclusion zones": unculledZones,
},
},
{
position: "topleft",
exclusiveGroups: ["Blue Threat Zones", "Red Threat Zones", "Navmeshes"],
groupCheckboxes: true,
}
)
.groupedLayers(null, debugControlGroups, {
position: "topleft",
exclusiveGroups: [
"Blue Threat Zones",
"Red Threat Zones",
"Navmeshes",
"Waypoint Zones",
],
groupCheckboxes: true,
})
.addTo(map);
let game;
@ -291,6 +312,9 @@ new QWebChannel(qt.webChannelTransport, function (channel) {
game.navmeshesChanged.connect(drawNavmeshes);
game.mapZonesChanged.connect(drawMapZones);
game.unculledZonesChanged.connect(drawUnculledZones);
game.ipZonesChanged.connect(drawIpZones);
game.joinZonesChanged.connect(drawJoinZones);
game.holdZonesChanged.connect(drawHoldZones);
});
function recenterMap(center) {
@ -570,7 +594,11 @@ class TheaterGroundObject {
}
L.marker(this.tgo.position, { icon: this.icon() })
.bindTooltip(`${this.tgo.name} (${this.tgo.controlPointName})<br />${this.tgo.units.join("<br />")}`)
.bindTooltip(
`${this.tgo.name} (${
this.tgo.controlPointName
})<br />${this.tgo.units.join("<br />")}`
)
.on("click", () => this.tgo.showInfoDialog())
.on("contextmenu", () => this.tgo.showPackageDialog())
.addTo(this.layer());
@ -970,6 +998,138 @@ function drawUnculledZones() {
}
}
function drawIpZones() {
ipZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
L.polygon(game.ipZones.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
L.polygon(game.ipZones.ipBubble, {
color: "#bb89ff",
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
L.polygon(game.ipZones.permissibleZone, {
color: "#ffffff",
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
for (const zone of game.ipZones.safeZones) {
L.polygon(zone, {
color: Colors.Green,
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
}
}
function drawJoinZones() {
joinZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
L.polygon(game.joinZones.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
L.polygon(game.joinZones.targetBubble, {
color: "#bb89ff",
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
L.polygon(game.joinZones.ipBubble, {
color: "#ffffff",
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
for (const zone of game.joinZones.excludedZones) {
L.polygon(zone, {
color: "#ffa500",
fillOpacity: 0.2,
stroke: false,
interactive: false,
}).addTo(joinZones);
}
for (const zone of game.joinZones.permissibleZones) {
L.polygon(zone, {
color: Colors.Green,
interactive: false,
}).addTo(joinZones);
}
for (const line of game.joinZones.preferredLines) {
L.polyline(line, {
color: Colors.Green,
interactive: false,
}).addTo(joinZones);
}
}
function drawHoldZones() {
holdZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
L.polygon(game.holdZones.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
L.polygon(game.holdZones.targetBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
L.polygon(game.holdZones.joinBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
for (const zone of game.holdZones.excludedZones) {
L.polygon(zone, {
color: "#ffa500",
fillOpacity: 0.2,
stroke: false,
interactive: false,
}).addTo(holdZones);
}
for (const zone of game.holdZones.permissibleZones) {
L.polygon(zone, {
color: Colors.Green,
interactive: false,
}).addTo(holdZones);
}
for (const line of game.holdZones.preferredLines) {
L.polyline(line, {
color: Colors.Green,
interactive: false,
}).addTo(holdZones);
}
}
function drawInitialMap() {
recenterMap(game.mapCenter);
drawControlPoints();
@ -981,6 +1141,9 @@ function drawInitialMap() {
drawNavmeshes();
drawMapZones();
drawUnculledZones();
drawIpZones();
drawJoinZones();
drawHoldZones();
}
function clearAllLayers() {

View File

@ -1,5 +1,6 @@
description: The A-50 is an AWACS plane.
max_group_size: 1
max_range: 2000
price: 50
patrol:
altitude: 33000

View File

@ -27,6 +27,7 @@ manufacturer: McDonnell Douglas
origin: USA/UK
price: 15
role: V/STOL Attack
max_range: 100
variants:
AV-8B Harrier II Night Attack: {}
radios:

View File

@ -1,4 +1,5 @@
description: The An-26B is a military transport aircraft.
price: 15
max_range: 800
variants:
An-26B: null

View File

@ -1,4 +1,5 @@
description: The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber
description:
The Rockwell B-1 Lancer is a supersonic variable-sweep wing, heavy bomber
used by the United States Air Force. It is commonly called the 'Bone' (from 'B-One').It
is one of three strategic bombers in the U.S. Air Force fleet as of 2021, the other
two being the B-2 Spirit and the B-52 Stratofortress. It first served in combat
@ -12,5 +13,6 @@ manufacturer: Rockwell
origin: USA
price: 45
role: Supersonic Strategic Bomber
max_range: 2000
variants:
B-1B Lancer: {}

View File

@ -1,4 +1,5 @@
description: The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds
description:
The Boeing B-52 Stratofortress is capable of carrying up to 70,000 pounds
(32,000 kg) of weapons, and has a typical combat range of more than 8,800 miles
(14,080 km) without aerial refueling. The B-52 completed sixty years of continuous
service with its original operator in 2015. After being upgraded between 2013 and
@ -8,5 +9,6 @@ manufacturer: Boeing
origin: USA
price: 35
role: Strategic Bomber
max_range: 2000
variants:
B-52H Stratofortress: {}

View File

@ -1,4 +1,5 @@
description: The C-130 is a military transport aircraft.
price: 15
max_range: 1000
variants:
C-130: null

View File

@ -1,4 +1,5 @@
description: The C-17 is a military transport aircraft.
price: 18
max_range: 2000
variants:
C-17A: null

View File

@ -8,6 +8,7 @@ manufacturer: Northrop Grumman
origin: USA
price: 50
role: AEW&C
max_range: 2000
patrol:
altitude: 30000
variants:

View File

@ -1,6 +1,7 @@
description: The E-3A is a AWACS aicraft.
price: 50
max_group_size: 1
max_range: 2000
patrol:
altitude: 35000
variants:

View File

@ -21,6 +21,7 @@ manufacturer: Grumman
origin: USA
price: 22
role: Carrier-based Air-Superiority Fighter/Fighter Bomber
max_range: 250
variants:
F-14A Tomcat (Block 135-GR Late): {}
radios:

View File

@ -21,6 +21,7 @@ manufacturer: Grumman
origin: USA
price: 26
role: Carrier-based Air-Superiority Fighter/Fighter Bomber
max_range: 250
variants:
F-14B Tomcat: {}
radios:

View File

@ -1,4 +1,5 @@
description: The early verison of the F-16. It flew in Desert Storm.
price: 15
max_range: 200
variants:
F-16A: null

View File

@ -27,6 +27,7 @@ manufacturer: General Dynamics
origin: USA
price: 22
role: Multirole Fighter
max_range: 200
variants:
F-16CM Fighting Falcon (Block 50): {}
F-2A: {}

View File

@ -21,6 +21,16 @@ manufacturer: McDonnell Douglas
origin: USA
price: 24
role: Carrier-based Multirole Fighter
fuel:
# Parking A1 to RWY 32 at Akrotiri.
taxi: 170
# AB takeoff to 350/0.85, reduce to MIL and maintain 350 to 25k ft.
climb_ppm: 44.25
# 0.85 mach for 100NM.
cruise_ppm: 22.1
# ~0.9 mach for 100NM. Occasional AB use.
combat_ppm: 27.5
min_safe: 2000
variants:
CF-188 Hornet: {}
EF-18A+ Hornet: {}

View File

@ -1,4 +1,5 @@
description: The Lockheed Martin C-130J Super Hercules is a four-engine turboprop
description:
The Lockheed Martin C-130J Super Hercules is a four-engine turboprop
military transport aircraft. The C-130J is a comprehensive update of the Lockheed
C-130 Hercules, with new engines, flight deck, and other systems. As of February
2018, 400 C-130J aircraft have been delivered to 17 nations.
@ -7,5 +8,6 @@ manufacturer: Lockheed
origin: USA
price: 18
role: Transport
max_range: 1000
variants:
C-130J-30 Super Hercules: {}

View File

@ -1,3 +1,4 @@
price: 20
max_range: 1000
variants:
IL-76MD: null

View File

@ -1,5 +1,6 @@
price: 20
max_group_size: 1
max_range: 1000
patrol:
# ~280 knots IAS.
speed: 400

View File

@ -8,6 +8,7 @@ manufacturer: Beoing
origin: USA
price: 25
role: Tanker
max_range: 1000
patrol:
# ~300 knots IAS.
speed: 445

View File

@ -1,10 +1,12 @@
description: The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range
description:
The Lockheed Martin (previously Lockheed) KC-130 is a family of the extended-range
tanker version of the C-130 Hercules transport aircraft modified for aerial refueling.
introduced: 1962
manufacturer: Lockheed Martin
origin: USA
price: 25
role: Tanker
max_range: 1000
patrol:
# ~210 knots IAS, roughly the max for the KC-130 at altitude.
speed: 370

View File

@ -1,4 +1,5 @@
description: The Boeing KC-135 Stratotanker is a military aerial refueling aircraft
description:
The Boeing KC-135 Stratotanker is a military aerial refueling aircraft
that was developed from the Boeing 367-80 prototype, alongside the Boeing 707 airliner. This
model has the Multi-point Refueling System modification, allowing for probe and
drogue refuelling.
@ -7,6 +8,7 @@ manufacturer: Boeing
origin: USA
price: 25
role: Tanker
max_range: 1000
patrol:
# 300 knots IAS.
speed: 440

View File

@ -1,4 +1,5 @@
price: 50
max_range: 2000
patrol:
altitude: 40000
variants:

View File

@ -1,5 +1,6 @@
carrier_capable: true
description: The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet
description:
The Lockheed S-3 Viking is a 4-crew, twin-engine turbofan-powered jet
aircraft that was used by the U.S. Navy (USN) primarily for anti-submarine warfare.
In the late 1990s, the S-3B's mission focus shifted to surface warfare and aerial
refueling. The Viking also provided electronic warfare and surface surveillance
@ -16,6 +17,7 @@ origin: USA
price: 20
max_group_size: 1
role: Carrier-based Tanker
max_range: 1000
patrol:
# ~265 knots IAS.
speed: 320

View File

@ -1,3 +1,4 @@
price: 25
max_range: 600
variants:
Yak-40: null

View File

@ -1,5 +1,6 @@
name: 2xAIM-120B
year: 1994
fallback: AIM-7MH
# If we've run out of doubles, start over with the singles.
fallback: AIM-120C
clsids:
- "LAU-115_2*LAU-127_AIM-120B"

View File

@ -1,5 +1,6 @@
name: AIM-7E
year: 1963
fallback: AIM-9X
clsids:
- "{AIM-7E}"
- "{LAU-115 - AIM-7E}"

View File

@ -0,0 +1,8 @@
name: 2xAIM-9L
year: 1977
# If we've run out of doubles, start over with the singles.
fallback: AIM-9X
clsids:
- "LAU-105_2*AIM-9L"
- "LAU-115_2*LAU-127_AIM-9L"
- "{F4-2-AIM9L}"

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