diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71d5cf3c..60cbf719 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: - name: update build number run: | - [IO.File]::WriteAllLines($pwd.path + "\buildnumber", $env:GITHUB_RUN_NUMBER) + [IO.File]::WriteAllLines($pwd.path + "\resources\buildnumber", $env:GITHUB_RUN_NUMBER) - name: Build binaries run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21806ed4..ca8a238e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ jobs: # For some reason the shiboken2.abi3.dll is not found properly, so I copy it instead Copy-Item .\venv\Lib\site-packages\shiboken2\shiboken2.abi3.dll .\venv\Lib\site-packages\PySide2\ -Force + - name: Finalize version + run: | + New-Item -ItemType file resources\final + - name: mypy game run: | ./venv/scripts/activate diff --git a/changelog.md b/changelog.md index f3f99b7b..4f71438e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,19 @@ -# 2.2.X +# 2.3.0 + +# Features/Improvements +* **[Flight Planner]** Added fighter sweep missions. +* **[Flight Planner]** Differentiated BARCAP and TARCAP. TARCAP is now for hostile areas and will arrive before the package. + +# 2.2.1 + +# Features/Improvements +* **[Factions]** Added factions : Georgia 2008, USN 1985, France 2005 Frenchpack by HerrTom +* **[Factions]** Added map Persian Gulf full by Plob + +## Fixes : +* **[Flight Planner]** Hold, join, and split points are planned cautiously near enemy airfields. Ascend/descend points are no longer planned. + +# 2.2.0 ## Features/Improvements : * **[Campaign Generator]** Added early warning radar generation @@ -13,10 +28,9 @@ * **[Map]** Highlight the selected flight path on the map * **[Map]** Improved SAM display settings * **[Map]** Improved flight plan display settings +* **[Map]** Caucasus and The Channel map use a new system to generate SAM and strike target location to reduce probability of targets generated in the middle of a forests * **[Misc]** Flexible Dedicated Hosting Options for Mission Files via environment variables * **[Moddability]** Custom campaigns can be designed through json files -* **[Moddability]** Custom campaigns can be designed through json files -* **[Moddability]** Custom factions can be designed through json files * **[Moddability]** LUA plugins can now be injected into Liberation missions. * **[Moddability]** Optional Skynet IADS lua plugin now included * **[New Game]** Starting budget can be freely selected @@ -25,8 +39,7 @@ * **[UI]** Add polygon drawing mode for map background * **[UI]** Added a warning if you press takeoff with no player enabled flights * **[UI]** Packages and flights now visible in the main window sidebar -* **[Units/Factions]** Added bombers to coalitions -* **[Units/Factions]** Added frenchpack mod units +* **[Units/Factions]** Added bombers to some coalitions * **[Units/Factions]** Added support for SU-57 mod by Cubanace * **[Units]** Added Freya EWR sites to german WW2 factions * **[Units]** Added support for many bombers (B-52H, B-1B, Tu-22, Tu-142) diff --git a/game/data/doctrine.py b/game/data/doctrine.py index 8d3e1a91..fce67b1b 100644 --- a/game/data/doctrine.py +++ b/game/data/doctrine.py @@ -16,6 +16,8 @@ class Doctrine: sead_max_range: int rendezvous_altitude: int + hold_distance: int + push_distance: int join_distance: int split_distance: int ingress_egress_distance: int @@ -34,6 +36,8 @@ class Doctrine: cas_duration: timedelta + sweep_distance: int + MODERN_DOCTRINE = Doctrine( cap=True, @@ -44,6 +48,8 @@ MODERN_DOCTRINE = Doctrine( strike_max_range=1500000, sead_max_range=1500000, rendezvous_altitude=feet_to_meter(25000), + hold_distance=nm_to_meter(15), + push_distance=nm_to_meter(20), join_distance=nm_to_meter(20), split_distance=nm_to_meter(20), ingress_egress_distance=nm_to_meter(45), @@ -58,6 +64,7 @@ MODERN_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(10), cap_max_distance_from_cp=nm_to_meter(40), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(60), ) COLDWAR_DOCTRINE = Doctrine( @@ -69,6 +76,8 @@ COLDWAR_DOCTRINE = Doctrine( strike_max_range=1500000, sead_max_range=1500000, rendezvous_altitude=feet_to_meter(22000), + hold_distance=nm_to_meter(10), + push_distance=nm_to_meter(10), join_distance=nm_to_meter(10), split_distance=nm_to_meter(10), ingress_egress_distance=nm_to_meter(30), @@ -83,6 +92,7 @@ COLDWAR_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(8), cap_max_distance_from_cp=nm_to_meter(25), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(40), ) WWII_DOCTRINE = Doctrine( @@ -93,6 +103,8 @@ WWII_DOCTRINE = Doctrine( antiship=True, strike_max_range=1500000, sead_max_range=1500000, + hold_distance=nm_to_meter(5), + push_distance=nm_to_meter(5), join_distance=nm_to_meter(5), split_distance=nm_to_meter(5), rendezvous_altitude=feet_to_meter(10000), @@ -108,4 +120,5 @@ WWII_DOCTRINE = Doctrine( cap_min_distance_from_cp=nm_to_meter(0), cap_max_distance_from_cp=nm_to_meter(5), cas_duration=timedelta(minutes=30), + sweep_distance=nm_to_meter(10), ) diff --git a/game/version.py b/game/version.py index e173526f..5955ac0a 100644 --- a/game/version.py +++ b/game/version.py @@ -1,8 +1,18 @@ from pathlib import Path +def _build_version_string() -> str: + components = ["2.3.0"] + build_number_path = Path("resources/buildnumber") + if build_number_path.exists(): + with build_number_path.open("r") as build_number_file: + components.append(build_number_file.readline()) + + if not Path("resources/final").exists(): + components.append("preview") + + return "-".join(components) + + #: Current version of Liberation. -VERSION = "2.2.0-preview" -if Path("buildnumber").exists(): - with open("buildnumber", "r") as file: - VERSION += f"-{file.readline()}" +VERSION = _build_version_string() diff --git a/gen/aircraft.py b/gen/aircraft.py index 7c4eac80..5aa6ba55 100644 --- a/gen/aircraft.py +++ b/gen/aircraft.py @@ -5,7 +5,7 @@ import random from dataclasses import dataclass from datetime import timedelta from functools import cached_property -from typing import Dict, List, Optional, Type, Union, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union from dcs import helicopters from dcs.action import AITaskPush, ActivateGroup @@ -13,10 +13,12 @@ from dcs.condition import CoalitionHasAirdrome, TimeAfter from dcs.country import Country from dcs.flyingunit import FlyingUnit from dcs.helicopters import UH_1H, helicopter_map +from dcs.mapping import Point from dcs.mission import Mission, StartType from dcs.planes import ( AJS37, B_17G, + B_52H, Bf_109K_4, FW_190A8, FW_190D9, @@ -31,7 +33,8 @@ from dcs.planes import ( P_51D_30_NA, SpitfireLFMkIX, SpitfireLFMkIXCW, - Su_33, A_20G, Tu_22M3, B_52H, + Su_33, + Tu_22M3, ) from dcs.point import MovingPoint, PointAction from dcs.task import ( @@ -49,10 +52,8 @@ from dcs.task import ( OptRTBOnBingoFuel, OptRTBOnOutOfAmmo, OptReactOnThreat, - OptRestrictAfterburner, OptRestrictJettison, OrbitAction, - PinpointStrike, SEAD, StartCommand, Targets, @@ -71,6 +72,7 @@ from game.utils import nm_to_meter from gen.airsupportgen import AirSupport from gen.ato import AirTaskingOrder, Package from gen.callsigns import create_group_callsign_from_unit +from gen.conflictgen import FRONTLINE_LENGTH from gen.flights.flight import ( Flight, FlightType, @@ -79,15 +81,14 @@ from gen.flights.flight import ( ) from gen.radios import MHz, Radio, RadioFrequency, RadioRegistry, get_radio from gen.runways import RunwayData -from gen.conflictgen import FRONTLINE_LENGTH -from dcs.mapping import Point from theater import TheaterGroundObject from theater.controlpoint import ControlPoint, ControlPointType from .conflictgen import Conflict from .flights.flightplan import ( CasFlightPlan, - FormationFlightPlan, + LoiterFlightPlan, PatrollingFlightPlan, + SweepFlightPlan, ) from .flights.traveltime import TotEstimator from .naming import namegen @@ -1035,9 +1036,6 @@ class AircraftConflictGenerator: self.configure_behavior(group, rtb_winchester=ammo_type) - group.points[0].tasks.append(EngageTargets(max_distance=nm_to_meter(50), - targets=[Targets.All.Air])) - def configure_cas(self, group: FlyingGroup, package: Package, flight: Flight, dynamic_runways: Dict[str, RunwayData]) -> None: @@ -1118,7 +1116,7 @@ class AircraftConflictGenerator: dynamic_runways: Dict[str, RunwayData]) -> None: flight_type = flight.flight_type if flight_type in [FlightType.BARCAP, FlightType.TARCAP, - FlightType.INTERCEPTION]: + FlightType.INTERCEPTION, FlightType.SWEEP]: self.configure_cap(group, package, flight, dynamic_runways) elif flight_type in [FlightType.CAS, FlightType.BAI]: self.configure_cas(group, package, flight, dynamic_runways) @@ -1278,6 +1276,7 @@ class PydcsWaypointBuilder: FlightWaypointType.LANDING_POINT: LandingPointBuilder, FlightWaypointType.LOITER: HoldPointBuilder, FlightWaypointType.PATROL_TRACK: RaceTrackBuilder, + FlightWaypointType.INGRESS_SWEEP: SweepIngressBuilder, } builder = builders.get(waypoint.waypoint_type, DefaultWaypointBuilder) return builder(waypoint, group, package, flight, mission) @@ -1314,7 +1313,7 @@ class HoldPointBuilder(PydcsWaypointBuilder): altitude=waypoint.alt, pattern=OrbitAction.OrbitPattern.Circle )) - if not isinstance(self.flight.flight_plan, FormationFlightPlan): + if not isinstance(self.flight.flight_plan, LoiterFlightPlan): flight_plan_type = self.flight.flight_plan.__class__.__name__ logging.error( f"Cannot configure hold for for {self.flight} because " @@ -1458,6 +1457,24 @@ class StrikeIngressBuilder(PydcsWaypointBuilder): return waypoint +class SweepIngressBuilder(PydcsWaypointBuilder): + def build(self) -> MovingPoint: + waypoint = super().build() + + if not isinstance(self.flight.flight_plan, SweepFlightPlan): + flight_plan_type = self.flight.flight_plan.__class__.__name__ + logging.error( + f"Cannot create sweep for {self.flight} because " + f"{flight_plan_type} is not a sweep flight plan.") + return waypoint + + waypoint.tasks.append(EngageTargets( + max_distance=nm_to_meter(50), + targets=[Targets.All.Air.Planes.Fighters])) + + return waypoint + + class JoinPointBuilder(PydcsWaypointBuilder): def build(self) -> MovingPoint: waypoint = super().build() @@ -1532,4 +1549,14 @@ class RaceTrackBuilder(PydcsWaypointBuilder): racetrack.stop_after_time( int(self.flight.flight_plan.patrol_end_time.total_seconds())) waypoint.add_task(racetrack) + + # TODO: Move the properties of this task into the flight plan? + # CAP is the only current user of this so it's not a big deal, but might + # be good to make this usable for things like BAI when we add that + # later. + cap_types = {FlightType.BARCAP, FlightType.TARCAP} + if self.flight.flight_type in cap_types: + waypoint.tasks.append(EngageTargets(max_distance=nm_to_meter(50), + targets=[Targets.All.Air])) + return waypoint diff --git a/gen/ato.py b/gen/ato.py index d814e5ee..a21563dc 100644 --- a/gen/ato.py +++ b/gen/ato.py @@ -159,6 +159,7 @@ class Package: FlightType.TARCAP, FlightType.CAP, FlightType.BARCAP, + FlightType.SWEEP, FlightType.EWAR, FlightType.ESCORT, ] diff --git a/gen/flights/ai_flight_planner.py b/gen/flights/ai_flight_planner.py index 41a4957f..d23923cd 100644 --- a/gen/flights/ai_flight_planner.py +++ b/gen/flights/ai_flight_planner.py @@ -496,7 +496,11 @@ class CoalitionMissionPlanner: error = random.randint(-margin, margin) yield timedelta(minutes=max(0, time + error)) - dca_types = (FlightType.BARCAP, FlightType.INTERCEPTION) + dca_types = { + FlightType.BARCAP, + FlightType.INTERCEPTION, + FlightType.TARCAP, + } non_dca_packages = [p for p in self.ato.packages if p.primary_task not in dca_types] diff --git a/gen/flights/flight.py b/gen/flights/flight.py index 2462a0a5..a19d362c 100644 --- a/gen/flights/flight.py +++ b/gen/flights/flight.py @@ -38,6 +38,8 @@ class FlightType(Enum): RECON = 15 EWAR = 16 + SWEEP = 17 + class FlightWaypointType(Enum): TAKEOFF = 0 # Take off point @@ -61,6 +63,7 @@ class FlightWaypointType(Enum): LOITER = 18 INGRESS_ESCORT = 19 INGRESS_DEAD = 20 + INGRESS_SWEEP = 21 class FlightWaypoint: diff --git a/gen/flights/flightplan.py b/gen/flights/flightplan.py index e0df2b01..b16732d0 100644 --- a/gen/flights/flightplan.py +++ b/gen/flights/flightplan.py @@ -7,6 +7,7 @@ generating the waypoints for the mission. """ from __future__ import annotations +import math from datetime import timedelta from functools import cached_property import logging @@ -104,6 +105,15 @@ class FlightPlan: """ raise NotImplementedError + @property + def tot_offset(self) -> timedelta: + """This flight's offset from the package's TOT. + + Positive values represent later TOTs. An offset of -2 minutes is used + for a flight that has a TOT 2 minutes before the rest of the package. + """ + return timedelta() + # Not cached because changes to the package might alter the formation speed. @property def travel_time_to_target(self) -> Optional[timedelta]: @@ -146,8 +156,33 @@ class FlightPlan: @dataclass(frozen=True) -class FormationFlightPlan(FlightPlan): +class LoiterFlightPlan(FlightPlan): hold: FlightWaypoint + + @property + def waypoints(self) -> List[FlightWaypoint]: + raise NotImplementedError + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + raise NotImplementedError + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + raise NotImplementedError + + @property + def push_time(self) -> timedelta: + raise NotImplementedError + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.push_time + return None + + +@dataclass(frozen=True) +class FormationFlightPlan(LoiterFlightPlan): join: FlightWaypoint split: FlightWaypoint @@ -214,12 +249,6 @@ class FormationFlightPlan(FlightPlan): return self.split_time return None - def depart_time_for_waypoint( - self, waypoint: FlightWaypoint) -> Optional[timedelta]: - if waypoint == self.hold: - return self.push_time - return None - @property def push_time(self) -> timedelta: return self.join_time - TravelTime.between_points( @@ -275,18 +304,14 @@ class PatrollingFlightPlan(FlightPlan): @dataclass(frozen=True) class BarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.patrol_end, - self.descent, self.land, ] @@ -294,20 +319,16 @@ class BarCapFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) class CasFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint target: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.target, self.patrol_end, - self.descent, self.land, ] @@ -319,23 +340,24 @@ class CasFlightPlan(PatrollingFlightPlan): @dataclass(frozen=True) -class FrontLineCapFlightPlan(PatrollingFlightPlan): +class TarCapFlightPlan(PatrollingFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint + lead_time: timedelta @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.patrol_start, self.patrol_end, - self.descent, self.land, ] + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + def depart_time_for_waypoint( self, waypoint: FlightWaypoint) -> Optional[timedelta]: if waypoint == self.patrol_end: @@ -346,8 +368,8 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): def patrol_start_time(self) -> timedelta: start = self.package.escort_start_time if start is not None: - return start - return super().patrol_start_time + return start + self.tot_offset + return super().patrol_start_time + self.tot_offset @property def patrol_end_time(self) -> timedelta: @@ -357,31 +379,31 @@ class FrontLineCapFlightPlan(PatrollingFlightPlan): return super().patrol_end_time +# TODO: Remove when breaking save compat. +FrontLineCapFlightPlan = TarCapFlightPlan + + @dataclass(frozen=True) class StrikeFlightPlan(FormationFlightPlan): takeoff: FlightWaypoint - ascent: FlightWaypoint hold: FlightWaypoint join: FlightWaypoint ingress: FlightWaypoint targets: List[FlightWaypoint] egress: FlightWaypoint split: FlightWaypoint - descent: FlightWaypoint land: FlightWaypoint @property def waypoints(self) -> List[FlightWaypoint]: return [ self.takeoff, - self.ascent, self.hold, self.join, self.ingress ] + self.targets + [ self.egress, self.split, - self.descent, self.land, ] @@ -476,6 +498,64 @@ class StrikeFlightPlan(FormationFlightPlan): return super().tot_for_waypoint(waypoint) +@dataclass(frozen=True) +class SweepFlightPlan(LoiterFlightPlan): + takeoff: FlightWaypoint + sweep_start: FlightWaypoint + sweep_end: FlightWaypoint + land: FlightWaypoint + lead_time: timedelta + + @property + def waypoints(self) -> List[FlightWaypoint]: + return [ + self.takeoff, + self.hold, + self.sweep_start, + self.sweep_end, + self.land, + ] + + @property + def tot_waypoint(self) -> Optional[FlightWaypoint]: + return self.sweep_end + + @property + def tot_offset(self) -> timedelta: + return -self.lead_time + + @property + def sweep_start_time(self) -> timedelta: + travel_time = self.travel_time_between_waypoints( + self.sweep_start, self.sweep_end) + return self.sweep_end_time - travel_time + + @property + def sweep_end_time(self) -> timedelta: + return self.package.time_over_target + self.tot_offset + + def tot_for_waypoint(self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.sweep_start: + return self.sweep_start_time + if waypoint == self.sweep_end: + return self.sweep_end_time + return None + + def depart_time_for_waypoint( + self, waypoint: FlightWaypoint) -> Optional[timedelta]: + if waypoint == self.hold: + return self.push_time + return None + + @property + def push_time(self) -> timedelta: + return self.sweep_end_time - TravelTime.between_points( + self.hold.position, + self.sweep_end.position, + GroundSpeed.for_flight(self.flight, self.hold.alt) + ) + + @dataclass(frozen=True) class CustomFlightPlan(FlightPlan): custom_waypoints: List[FlightWaypoint] @@ -561,8 +641,10 @@ class FlightPlanBuilder: return self.generate_sead(flight, custom_targets) elif task == FlightType.STRIKE: return self.generate_strike(flight) + elif task == FlightType.SWEEP: + return self.generate_sweep(flight) elif task == FlightType.TARCAP: - return self.generate_frontline_cap(flight) + return self.generate_tarcap(flight) elif task == FlightType.TROOP_TRANSPORT: logging.error( "Troop transport flight plan generation not implemented" @@ -631,11 +713,57 @@ class FlightPlanBuilder: if isinstance(location, FrontLine): raise InvalidObjectiveLocation(flight.flight_type, location) + start, end = self.racetrack_for_objective(location) patrol_alt = random.randint( self.doctrine.min_patrol_altitude, self.doctrine.max_patrol_altitude ) + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + start, end = builder.race_track(start, end, patrol_alt) + descent, land = builder.rtb(flight.from_cp) + + return BarCapFlightPlan( + package=self.package, + flight=flight, + patrol_duration=self.doctrine.cap_duration, + takeoff=builder.takeoff(flight.from_cp), + patrol_start=start, + patrol_end=end, + land=land + ) + + def generate_sweep(self, flight: Flight) -> SweepFlightPlan: + """Generate a BARCAP flight at a given location. + + Args: + flight: The flight to generate the flight plan for. + """ + target = self.package.target.position + + heading = self._heading_to_package_airfield(target) + start = target.point_from_heading(heading, + -self.doctrine.sweep_distance) + + builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + descent, land = builder.rtb(flight.from_cp) + + start, end = builder.sweep(start, target, + self.doctrine.ingress_altitude) + + return SweepFlightPlan( + package=self.package, + flight=flight, + lead_time=timedelta(minutes=5), + takeoff=builder.takeoff(flight.from_cp), + hold=builder.hold(self._hold_point(flight)), + sweep_start=start, + sweep_end=end, + land=land + ) + + def racetrack_for_objective(self, + location: MissionTarget) -> Tuple[Point, Point]: closest_cache = ObjectiveDistanceCache.get_closest_airfields(location) for airfield in closest_cache.closest_airfields: # If the mission is a BARCAP of an enemy airfield, find the *next* @@ -671,37 +799,11 @@ class FlightPlanBuilder: self.doctrine.cap_max_track_length ) start = end.point_from_heading(heading - 180, diameter) + return start, end - builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) - start, end = builder.race_track(start, end, patrol_alt) - descent, land = builder.rtb(flight.from_cp) - - return BarCapFlightPlan( - package=self.package, - flight=flight, - patrol_duration=self.doctrine.cap_duration, - takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), - patrol_start=start, - patrol_end=end, - descent=descent, - land=land - ) - - def generate_frontline_cap(self, flight: Flight) -> FrontLineCapFlightPlan: - """Generate a CAP flight plan for the given front line. - - Args: - flight: The flight to generate the flight plan for. - """ - location = self.package.target - - if not isinstance(location, FrontLine): - raise InvalidObjectiveLocation(flight.flight_type, location) - - ally_cp, enemy_cp = location.control_points - patrol_alt = random.randint(self.doctrine.min_patrol_altitude, - self.doctrine.max_patrol_altitude) + def racetrack_for_frontline(self, + front_line: FrontLine) -> Tuple[Point, Point]: + ally_cp, enemy_cp = front_line.control_points # Find targets waypoints ingress, heading, distance = Conflict.frontline_vector( @@ -722,24 +824,41 @@ class FlightPlanBuilder: orbit0p = orbit_center.point_from_heading(heading, radius) orbit1p = orbit_center.point_from_heading(heading + 180, radius) + return orbit0p, orbit1p + + def generate_tarcap(self, flight: Flight) -> TarCapFlightPlan: + """Generate a CAP flight plan for the given front line. + + Args: + flight: The flight to generate the flight plan for. + """ + location = self.package.target + + patrol_alt = random.randint(self.doctrine.min_patrol_altitude, + self.doctrine.max_patrol_altitude) + # Create points builder = WaypointBuilder(self.game.conditions, flight, self.doctrine) + if isinstance(location, FrontLine): + orbit0p, orbit1p = self.racetrack_for_frontline(location) + else: + orbit0p, orbit1p = self.racetrack_for_objective(location) + start, end = builder.race_track(orbit0p, orbit1p, patrol_alt) descent, land = builder.rtb(flight.from_cp) - return FrontLineCapFlightPlan( + return TarCapFlightPlan( package=self.package, flight=flight, + lead_time=timedelta(minutes=2), # Note that this duration only has an effect if there are no # flights in the package that have requested escort. If the package # requests an escort the CAP flight will remain on station for the # duration of the escorted mission, or until it is winchester/bingo. patrol_duration=self.doctrine.cap_duration, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), patrol_start=start, patrol_end=end, - descent=descent, land=land ) @@ -805,14 +924,12 @@ class FlightPlanBuilder: package=self.package, flight=flight, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=[target], egress=egress, split=builder.split(self.package.waypoints.split), - descent=descent, land=land ) @@ -842,11 +959,9 @@ class FlightPlanBuilder: flight=flight, patrol_duration=self.doctrine.cas_duration, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), patrol_start=builder.ingress_cas(ingress, location), target=builder.cas(center), patrol_end=builder.egress(egress, location), - descent=descent, land=land ) @@ -871,12 +986,50 @@ class FlightPlanBuilder: return builder.strike_area(location) def _hold_point(self, flight: Flight) -> Point: - heading = flight.from_cp.position.heading_between_point( - self.package.target.position - ) - return flight.from_cp.position.point_from_heading( - heading, nm_to_meter(15) + assert self.package.waypoints is not None + origin = flight.from_cp.position + target = self.package.target.position + join = self.package.waypoints.join + origin_to_target = origin.distance_to_point(target) + join_to_target = join.distance_to_point(target) + if origin_to_target < join_to_target: + # If the origin airfield is closer to the target than the join + # point, 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) + + heading_to_join = origin.heading_between_point(join) + hold_point = origin.point_from_heading(heading_to_join, + self.doctrine.push_distance) + if hold_point.distance_to_point(join) >= 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 ** 2 + + origin_to_join ** 2 - + self.doctrine.join_distance ** 2) / + (2 * self.doctrine.hold_distance * 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) + + return origin.point_from_heading(heading_to_join - theta, + self.doctrine.hold_distance) # TODO: Make a model for the waypoint builder and use that in the UI. def generate_ascend_point(self, flight: Flight, @@ -944,23 +1097,37 @@ class FlightPlanBuilder: package=self.package, flight=flight, takeoff=builder.takeoff(flight.from_cp), - ascent=builder.ascent(flight.from_cp), hold=builder.hold(self._hold_point(flight)), join=builder.join(self.package.waypoints.join), ingress=ingress, targets=target_waypoints, egress=builder.egress(self.package.waypoints.egress, location), split=builder.split(self.package.waypoints.split), - descent=descent, land=land ) def _join_point(self, ingress_point: Point) -> Point: + ingress_distance = self._distance_to_package_airfield(ingress_point) + if ingress_distance < self.doctrine.join_distance: + # If the ingress point is close to the origin, plan the join point + # farther back. + return ingress_point.point_from_heading( + self.package.target.position.heading_between_point( + self.package_airfield().position), + self.doctrine.join_distance) heading = self._heading_to_package_airfield(ingress_point) return ingress_point.point_from_heading(heading, -self.doctrine.join_distance) def _split_point(self, egress_point: Point) -> Point: + egress_distance = self._distance_to_package_airfield(egress_point) + if egress_distance < self.doctrine.split_distance: + # If the ingress point is close to the origin, plan the split point + # farther back. + return egress_point.point_from_heading( + self.package.target.position.heading_between_point( + self.package_airfield().position), + self.doctrine.split_distance) heading = self._heading_to_package_airfield(egress_point) return egress_point.point_from_heading(heading, -self.doctrine.split_distance) @@ -983,6 +1150,9 @@ class FlightPlanBuilder: def _heading_to_package_airfield(self, point: Point) -> int: return self.package_airfield().position.heading_between_point(point) + def _distance_to_package_airfield(self, point: Point) -> int: + 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. diff --git a/gen/flights/traveltime.py b/gen/flights/traveltime.py index ee9a6c7e..4756d287 100644 --- a/gen/flights/traveltime.py +++ b/gen/flights/traveltime.py @@ -128,7 +128,14 @@ class TotEstimator: f"time for {flight} will be immediate.") return timedelta() else: - tot = self.package.time_over_target + tot_waypoint = flight.flight_plan.tot_waypoint + if tot_waypoint is None: + tot = self.package.time_over_target + else: + tot = flight.flight_plan.tot_for_waypoint(tot_waypoint) + if tot is None: + logging.error(f"TOT waypoint for {flight} has no TOT") + tot = self.package.time_over_target return tot - travel_time - self.HOLD_TIME def earliest_tot(self) -> timedelta: @@ -165,9 +172,13 @@ class TotEstimator: # Return 0 so this flight's travel time does not affect the rest # of the package. return timedelta() + # Account for TOT offsets for the flight plan. An offset of -2 minutes + # means the flight's TOT is 2 minutes ahead of the package's so it needs + # an extra two minutes. + offset = -flight.flight_plan.tot_offset startup = self.estimate_startup(flight) ground_ops = self.estimate_ground_ops(flight) - return startup + ground_ops + time_to_target + return startup + ground_ops + time_to_target + offset @staticmethod def estimate_startup(flight: Flight) -> timedelta: diff --git a/gen/flights/waypointbuilder.py b/gen/flights/waypointbuilder.py index ddc76b5f..dd82f1aa 100644 --- a/gen/flights/waypointbuilder.py +++ b/gen/flights/waypointbuilder.py @@ -326,6 +326,56 @@ class WaypointBuilder: return (self.race_track_start(start, altitude), self.race_track_end(end, altitude)) + @staticmethod + def sweep_start(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep start waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.INGRESS_SWEEP, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP START" + waypoint.description = "Proceed to the target and engage enemy aircraft" + waypoint.pretty_name = "Sweep start" + return waypoint + + @staticmethod + def sweep_end(position: Point, altitude: int) -> FlightWaypoint: + """Creates a sweep end waypoint. + + Args: + position: Position of the waypoint. + altitude: Altitude of the sweep in meters. + """ + waypoint = FlightWaypoint( + FlightWaypointType.EGRESS, + position.x, + position.y, + altitude + ) + waypoint.name = "SWEEP END" + waypoint.description = "End of sweep" + waypoint.pretty_name = "Sweep end" + return waypoint + + def sweep(self, start: Point, end: Point, + altitude: int) -> Tuple[FlightWaypoint, FlightWaypoint]: + """Creates two waypoint for a racetrack orbit. + + Args: + start: The beginning of the sweep. + end: The end of the sweep. + altitude: The sweep altitude. + """ + return (self.sweep_start(start, altitude), + self.sweep_end(end, altitude)) + def rtb(self, arrival: ControlPoint) -> Tuple[FlightWaypoint, FlightWaypoint]: """Creates descent ant landing waypoints for the given control point. diff --git a/pydcs b/pydcs index 8e74bfb6..fa9195fb 160000 --- a/pydcs +++ b/pydcs @@ -1 +1 @@ -Subproject commit 8e74bfb61b829477d35a80148fc295f2158902dd +Subproject commit fa9195fbccbf96775d108a22c13c3ee2375e4c0b diff --git a/pyinstaller.spec b/pyinstaller.spec index e545d073..839c641f 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -11,7 +11,6 @@ analysis = Analysis( ('resources', 'resources'), ('resources/caucasus.p', 'dcs/terrain/'), ('resources/nevada.p', 'dcs/terrain/'), - ('buildnumber', './') ], hookspath=[], runtime_hooks=[], diff --git a/qt_ui/widgets/combos/QFlightTypeComboBox.py b/qt_ui/widgets/combos/QFlightTypeComboBox.py index d1a27382..8adf0bbc 100644 --- a/qt_ui/widgets/combos/QFlightTypeComboBox.py +++ b/qt_ui/widgets/combos/QFlightTypeComboBox.py @@ -18,9 +18,11 @@ class QFlightTypeComboBox(QComboBox): """Combo box for selecting a flight task type.""" COMMON_ENEMY_MISSIONS = [ + FlightType.TARCAP, FlightType.ESCORT, FlightType.SEAD, FlightType.DEAD, + FlightType.SWEEP, # TODO: FlightType.ELINT, # TODO: FlightType.EWAR, # TODO: FlightType.RECON, @@ -49,7 +51,6 @@ class QFlightTypeComboBox(QComboBox): ] ENEMY_AIRBASE_MISSIONS = [ - FlightType.BARCAP, # TODO: FlightType.STRIKE ] + COMMON_ENEMY_MISSIONS @@ -59,13 +60,11 @@ class QFlightTypeComboBox(QComboBox): ] + COMMON_FRIENDLY_MISSIONS ENEMY_GROUND_OBJECT_MISSIONS = [ - FlightType.BARCAP, FlightType.STRIKE, ] + COMMON_ENEMY_MISSIONS FRONT_LINE_MISSIONS = [ FlightType.CAS, - FlightType.TARCAP, # TODO: FlightType.TROOP_TRANSPORT # TODO: FlightType.EVAC ] + COMMON_ENEMY_MISSIONS diff --git a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py index 43198d28..5f031622 100644 --- a/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py +++ b/qt_ui/windows/mission/flight/waypoints/QFlightWaypointTab.py @@ -149,10 +149,10 @@ class QFlightWaypointTab(QFrame): # departs, whereas BARCAP usually isn't part of a strike package and # has a fixed mission time. if task == FlightType.CAP: - if isinstance(self.package.target, FrontLine): - task = FlightType.TARCAP - else: + if self.package.target.is_friendly(to_player=True): task = FlightType.BARCAP + else: + task = FlightType.TARCAP self.flight.flight_type = task self.planner.populate_flight_plan(self.flight) self.flight_waypoint_list.update_list() diff --git a/resources/briefing/templates/briefingtemplate_EN.j2 b/resources/briefing/templates/briefingtemplate_EN.j2 index 0d32eeae..864fbabc 100644 --- a/resources/briefing/templates/briefingtemplate_EN.j2 +++ b/resources/briefing/templates/briefingtemplate_EN.j2 @@ -16,6 +16,7 @@ We do not have a single vehicle available to hold our position. The situation i {% if frontline.enemy_zero %} The enemy forces have been crushed, we will be able to make significant progress toward {{ frontline.enemy_base.name }} {% endif %} +{% if not frontline.player_zero %} {# Pick a random sentence to describe each frontline #} {% set fl_sent1 %}There are combats between {{ frontline.player_base.name }} and {{frontline.enemy_base.name}}. {%+ endset %} {% set fl_sent2 %}The war on the ground is still going on between {{frontline.player_base.name}} and {{frontline.enemy_base.name}}. {%+ endset %} @@ -57,8 +58,9 @@ On this location, our ground forces have been ordered to hold still, and defend {# TODO: Write a retreat sentence #} {% endif %} {% endif %} +{% endif %} -{% endfor %}{% endif %} +{%+ endfor %}{% endif %} Your flights: ==================== diff --git a/resources/campaigns/persian_gulf_full_map.json b/resources/campaigns/persian_gulf_full_map.json new file mode 100644 index 00000000..82818470 --- /dev/null +++ b/resources/campaigns/persian_gulf_full_map.json @@ -0,0 +1,137 @@ +{ + "name": "Persian Gulf - Full Map", + "theater": "Persian Gulf", + "authors": "Plob", + "description": "

In this scenario, you start at Liwa Airfield, and must work your way north through the whole map.

", + "player_points": [ + { + "type": "airbase", + "id": "Liwa Airbase", + "size": 1000, + "importance": 0.2 + }, + { + "type": "lha", + "id": 1002, + "x": -164000, + "y": -257000, + "captured_invert": true + }, + { + "type": "carrier", + "id": 1001, + "x": -124000, + "y": -303000, + "captured_invert": true + } + ], + "enemy_points": [ + { + "type": "airbase", + "id": "Al Ain International Airport", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Al Dhafra AB", + "size": 2000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Al Minhad AB", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Ras Al Khaimah", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Khasab", + "size": 1000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Bandar Abbas Intl", + "size": 2000, + "importance": 1 + }, + { + "type": "airbase", + "id": "Jiroft Airport", + "size": 2000, + "importance": 1.4 + }, + { + "type": "airbase", + "id": "Kerman Airport", + "size": 2000, + "importance": 1.7, + "captured_invert": true + }, + { + "type": "airbase", + "id": "Lar Airbase", + "size": 1000, + "importance": 1.4 + }, + { + "type": "airbase", + "id": "Shiraz International Airport", + "size": 2000, + "importance": 1 + } + ], + "links": [ + [ + "Al Dhafra AB", + "Liwa Airbase" + ], + [ + "Al Dhafra AB", + "Al Ain International Airport" + ], + [ + "Al Ain International Airport", + "Al Minhad AB" + ], + [ + "Al Dhafra AB", + "Al Minhad AB" + ], + [ + "Al Minhad AB", + "Ras Al Khaimah" + ], + [ + "Khasab", + "Ras Al Khaimah" + ], + [ + "Bandar Abbas Intl", + "Lar Airbase" + ], + [ + "Shiraz International Airport", + "Lar Airbase" + ], + [ + "Shiraz International Airport", + "Kerman Airport" + ], + [ + "Jiroft Airport", + "Lar Airbase" + ], + [ + "Jiroft Airport", + "Kerman Airport" + ] + ] +} \ No newline at end of file diff --git a/resources/customized_payloads/AJS37.lua b/resources/customized_payloads/AJS37.lua index c8ebdcf1..9fe5c251 100644 --- a/resources/customized_payloads/AJS37.lua +++ b/resources/customized_payloads/AJS37.lua @@ -5,28 +5,37 @@ local unitPayloads = { ["name"] = "CAS", ["pylons"] = { [1] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 3, + ["CLSID"] = "{RB75}", + ["num"] = 5, }, [2] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 2, + ["CLSID"] = "{RB75}", + ["num"] = 3, }, [3] = { + ["CLSID"] = "{RB75}", + ["num"] = 2, + }, + [4] = { + ["CLSID"] = "{RB75}", + ["num"] = 6, + }, + [5] = { + ["CLSID"] = "{Robot24J}", + ["num"] = 1, + }, + [6] = { + ["CLSID"] = "{Robot24J}", + ["num"] = 7, + }, + [7] = { ["CLSID"] = "{VIGGEN_X-TANK}", ["num"] = 4, }, - [4] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 5, - }, - [5] = { - ["CLSID"] = "{ARAKM70BHE}", - ["num"] = 6, - }, }, ["tasks"] = { - [1] = 31, + [1] = 32, + [2] = 31, }, }, [2] = { diff --git a/resources/factions/france_2005_frenchpack.json b/resources/factions/france_2005_frenchpack.json new file mode 100644 index 00000000..d909c08b --- /dev/null +++ b/resources/factions/france_2005_frenchpack.json @@ -0,0 +1,84 @@ +{ + "country": "France", + "name": "France 2005 (Frenchpack)", + "authors": "HerrTom", + "description": "

French equipment using the Frenchpack, but without the Rafale mod.

", + "aircrafts": [ + "M_2000C", + "Mirage_2000_5", + "SA342M", + "SA342L", + "SA342Mistral" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "KC_135", + "KC130" + ], + "frontline_units": [ + "AMX_10RCR", + "AMX_10RCR_SEPAR", + "ERC_90", + "TRM_2000_PAMELA", + "VAB__50", + "VAB_MEPHISTO", + "VAB_T20_13", + "VAB_T20_13", + "VBL__50", + "VBL_AANF1", + "VBAE_CRAB", + "VBAE_CRAB_MMP", + "AMX_30B2", + "Leclerc_Serie_XXI" + ], + "artillery_units": [ + "MLRS_M270", + "SPH_M109_Paladin" + ], + "logistics_units": [ + "Transport_M818" + ], + "infantry_units": [ + "Infantry_M4", + "Soldier_M249", + "Stinger_MANPADS" + ], + "shorads": [ + "HQ7Generator", + "RolandGenerator" + ], + "sams": [ + "RolandGenerator", + "HawkGenerator" + ], + "aircraft_carrier": [ + "CVN_74_John_C__Stennis" + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "USS_Arleigh_Burke_IIa" + ], + "cruisers": [ + "Ticonderoga_class" + ], + "requirements": { + "frenchpack V3.5": "https://forums.eagle.ru/showthread.php?t=279974" + }, + "carrier_names": [ + "L9013 Mistral", + "L9014 Tonerre", + "L9015 Dixmude" + ], + "helicopter_carrier_names": [ + "Jeanne d'Arc" + ], + "navy_generators": [ + "ArleighBurkeGroupGenerator" + ], + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper" +} diff --git a/resources/factions/georgia_2008.json b/resources/factions/georgia_2008.json new file mode 100644 index 00000000..f575054c --- /dev/null +++ b/resources/factions/georgia_2008.json @@ -0,0 +1,46 @@ +{ + "country": "Georgia", + "name": "Georgia 2008", + "authors": "HerrTom", + "description": "

A faction that represents Georgia during the South Ossetian War. They will have a lot more aircraft than historically, and no real A2A capability.

", + "aircrafts": [ + "L_39ZA", + "Su_25", + "Mi_8MT", + "Mi_24V", + "UH_1H" + ], + "frontline_units": [ + "APC_BTR_80", + "APC_MTLB", + "APC_Cobra", + "IFV_BMP_1", + "IFV_BMP_2", + "MBT_T_72B", + "MBT_T_55" + ], + "artillery_units": [ + "MLRS_BM21_Grad", + "SPH_2S1_Gvozdika", + "SPH_2S3_Akatsia" + ], + "logistics_units": [ + "Transport_Ural_375", + "Transport_UAZ_469" + ], + "infantry_units": [ + "Paratrooper_AKS", + "Paratrooper_RPG_16" + ], + "shorads": [ + "SA13Generator", + "SA8Generator" + ], + "sams": [ + "SA6Generator", + "SA11Generator" + ], + "requirements": {}, + "has_jtac": true, + "jtac_unit": "MQ_9_Reaper" +} diff --git a/resources/factions/usn_1985.json b/resources/factions/usn_1985.json new file mode 100644 index 00000000..6ca2f2f1 --- /dev/null +++ b/resources/factions/usn_1985.json @@ -0,0 +1,73 @@ +{ + "country": "USA", + "name": "US Navy 1985", + "authors": "HerrTom", + "description": "

Highway to the Danger Zone! For Tomcat lovers.

", + "aircrafts": [ + "F_4E", + "F_14B", + "S_3B", + "UH_1H", + "AH_1W" + ], + "awacs": [ + "E_3A" + ], + "tankers": [ + "S_3B_Tanker" + ], + "frontline_units": [ + "MBT_M60A3_Patton", + "APC_M113", + "APC_M1025_HMMWV" + ], + "artillery_units": [ + "SPH_M109_Paladin", + "MLRS_M270" + ], + "logistics_units": [ + "Transport_M818" + ], + "infantry_units": [ + "Infantry_M4", + "Soldier_M249" + ], + "shorads": [ + "VulcanGenerator", + "ChaparralGenerator" + ], + "sams": [ + "HawkGenerator", + "ChaparralGenerator" + ], + "aircraft_carrier": [ + "CVN_74_John_C__Stennis" + ], + "helicopter_carrier": [ + "LHA_1_Tarawa" + ], + "destroyers": [ + "Oliver_Hazzard_Perry_class" + ], + "cruisers": [ + "Ticonderoga_class" + ], + "carrier_names": [ + "CVN-71 Theodore Roosevelt", + "CVN-72 Abraham Lincoln", + "CVN-73 George Washington", + "CVN-74 John C. Stennis" + ], + "helicopter_carrier_names": [ + "LHA-1 Tarawa", + "LHA-2 Saipan", + "LHA-3 Belleau Wood", + "LHA-4 Nassau", + "LHA-5 Peleliu" + ], + "navy_generators": [ + "OliverHazardPerryGroupGenerator" + ], + "requirements": {}, + "doctrine": "coldwar" +} \ No newline at end of file diff --git a/theater/conflicttheater.py b/theater/conflicttheater.py index 863e2add..3be055e9 100644 --- a/theater/conflicttheater.py +++ b/theater/conflicttheater.py @@ -350,6 +350,10 @@ class FrontLine(MissionTarget): self._build_segments() self.name = f"Front line {control_point_a}/{control_point_b}" + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + raise False + @property def position(self): """ diff --git a/theater/frontline.py b/theater/frontline.py index 76e7aac8..6177b6a6 100644 --- a/theater/frontline.py +++ b/theater/frontline.py @@ -1,2 +1,2 @@ """Only here to keep compatibility for save games generated in version 2.2.0""" -from theater.conflicttheater import * \ No newline at end of file +from theater.conflicttheater import * diff --git a/theater/missiontarget.py b/theater/missiontarget.py index fb4da0f3..ea9ccec8 100644 --- a/theater/missiontarget.py +++ b/theater/missiontarget.py @@ -17,3 +17,7 @@ class MissionTarget: def distance_to(self, other: MissionTarget) -> int: """Computes the distance to the given mission target.""" return self.position.distance_to_point(other.position) + + def is_friendly(self, to_player: bool) -> bool: + """Returns True if the objective is in friendly territory.""" + raise NotImplementedError diff --git a/theater/theatergroundobject.py b/theater/theatergroundobject.py index 0e8b3c87..293c392f 100644 --- a/theater/theatergroundobject.py +++ b/theater/theatergroundobject.py @@ -113,6 +113,9 @@ class TheaterGroundObject(MissionTarget): def faction_color(self) -> str: return "BLUE" if self.control_point.captured else "RED" + def is_friendly(self, to_player: bool) -> bool: + return not self.control_point.is_friendly(to_player) + class BuildingGroundObject(TheaterGroundObject): def __init__(self, name: str, category: str, group_id: int, object_id: int,