diff --git a/game/db.py b/game/db.py index d4ba1006..5f0bd302 100644 --- a/game/db.py +++ b/game/db.py @@ -225,6 +225,11 @@ from this example `Identifier` should be used (which may or may not include cate For example, player accessible Hornet is called `FA_18C_hornet`, and MANPAD Igla is called `AirDefence.SAM_SA_18_Igla_S_MANPADS` """ +# This should probably be much higher, but the AI doesn't rollover their budget +# and isn't smart enough to save to repair a critical runway anyway, so it has +# to be cheap enough to repair with a single turn's income. +RUNWAY_REPAIR_COST = 100 + """ Prices for the aircraft. This defines both price for the player (although only aircraft listed in CAP/CAS/Transport/Armor/AirDefense roles will be purchasable) diff --git a/game/event/event.py b/game/event/event.py index d6a5763b..d3f5857f 100644 --- a/game/event/event.py +++ b/game/event/event.py @@ -118,7 +118,7 @@ class Event: logging.info("Commiting mission results") for damaged_runway in debriefing.damaged_runways: - damaged_runway.damaged = True + damaged_runway.damage_runway() # ------------------------------ # Destroyed aircrafts diff --git a/game/game.py b/game/game.py index 967d118b..a4087ec1 100644 --- a/game/game.py +++ b/game/game.py @@ -26,7 +26,7 @@ from .event.frontlineattack import FrontlineAttackEvent from .factions.faction import Faction from .infos.information import Information from .settings import Settings -from .theater import ConflictTheater, ControlPoint, OffMapSpawn +from .theater import Airfield, ConflictTheater, ControlPoint, OffMapSpawn from .unitmap import UnitMap from .weather import Conditions, TimeOfDay @@ -212,6 +212,9 @@ class Game: else: event.skip() + for control_point in self.theater.controlpoints: + control_point.process_turn() + self._enemy_reinforcement() self._budget_player() @@ -269,9 +272,20 @@ class Game: if g.category in REWARDS.keys() and not g.is_dead: production = production + REWARDS[g.category] - production = production * 0.75 - budget_for_armored_units = production / 2 - budget_for_aircraft = production / 2 + # TODO: Why doesn't the enemy get the full budget? + budget = production * 0.75 + + for control_point in self.theater.enemy_points(): + if budget < db.RUNWAY_REPAIR_COST: + break + if control_point.runway_can_be_repaired: + control_point.begin_runway_repair() + budget -= db.RUNWAY_REPAIR_COST + self.informations.append(Information( + f"OPFOR has begun repairing the runway at {control_point}")) + + budget_for_armored_units = budget / 2 + budget_for_aircraft = budget / 2 potential_cp_armor = [] for cp in self.theater.enemy_points(): diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index 7793b866..c2549f91 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -148,6 +148,45 @@ class PendingOccupancy: return self.present + self.ordered + self.transferring +@dataclass +class RunwayStatus: + damaged: bool = False + repair_turns_remaining: Optional[int] = None + + def damage(self) -> None: + self.damaged = True + # If the runway is already under repair and is damaged again, progress + # is reset. + self.repair_turns_remaining = None + + def begin_repair(self) -> None: + if self.repair_turns_remaining is not None: + logging.error("Runway already under repair. Restarting.") + self.repair_turns_remaining = 4 + + def process_turn(self) -> None: + if self.repair_turns_remaining is not None: + if self.repair_turns_remaining == 1: + self.repair_turns_remaining = None + self.damaged = False + else: + self.repair_turns_remaining -= 1 + + @property + def needs_repair(self) -> bool: + return self.damaged and self.repair_turns_remaining is None + + def __str__(self) -> str: + if not self.damaged: + return "Runway operational" + + turns_remaining = self.repair_turns_remaining + if turns_remaining is None: + return "Runway damaged" + + return f"Runway repairing, {turns_remaining} turns remaining" + + class ControlPoint(MissionTarget, ABC): position = None # type: Point @@ -367,6 +406,26 @@ class ControlPoint(MissionTarget, ABC): def parking_slots(self) -> Iterator[ParkingSlot]: yield from [] + @property + @abstractmethod + def runway_status(self) -> RunwayStatus: + ... + + @property + def runway_can_be_repaired(self) -> bool: + return self.runway_status.needs_repair + + def begin_runway_repair(self) -> None: + if not self.runway_can_be_repaired: + logging.error(f"Cannot repair runway at {self}") + return + self.runway_status.begin_repair() + + def process_turn(self) -> None: + runway_status = self.runway_status + if runway_status is not None: + runway_status.process_turn() + class Airfield(ControlPoint): @@ -376,7 +435,7 @@ class Airfield(ControlPoint): size, importance, has_frontline, cptype=ControlPointType.AIRBASE) self.airport = airport - self.damaged = False + self._runway_status = RunwayStatus() def can_land(self, aircraft: FlyingType) -> bool: return True @@ -404,7 +463,14 @@ class Airfield(ControlPoint): return self.airport.runways[0].heading def has_runway(self) -> bool: - return not self.damaged + return not self.runway_status.damaged + + @property + def runway_status(self) -> RunwayStatus: + return self._runway_status + + def damage_runway(self) -> None: + self.runway_status.damage() def active_runway(self, conditions: Conditions, dynamic_runways: Dict[str, RunwayData]) -> RunwayData: @@ -457,6 +523,14 @@ class NavalControlPoint(ControlPoint, ABC): fallback = RunwayData(self.full_name, runway_heading=0, runway_name="") return dynamic_runways.get(self.name, fallback) + @property + def runway_status(self) -> RunwayStatus: + return RunwayStatus(damaged=not self.has_runway()) + + @property + def runway_can_be_repaired(self) -> bool: + return False + class Carrier(NavalControlPoint): @@ -537,3 +611,7 @@ class OffMapSpawn(ControlPoint): 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="") + + @property + def runway_status(self) -> RunwayStatus: + return RunwayStatus() diff --git a/qt_ui/windows/basemenu/QBaseMenu2.py b/qt_ui/windows/basemenu/QBaseMenu2.py index b17c394e..77457e10 100644 --- a/qt_ui/windows/basemenu/QBaseMenu2.py +++ b/qt_ui/windows/basemenu/QBaseMenu2.py @@ -4,11 +4,13 @@ from PySide2.QtWidgets import ( QDialog, QHBoxLayout, QLabel, + QMessageBox, QPushButton, QVBoxLayout, QWidget, ) +from game import db from game.theater import ControlPoint, ControlPointType from gen.flights.flight import FlightType from qt_ui.dialogs import Dialog @@ -59,17 +61,17 @@ class QBaseMenu2(QDialog): title = QLabel("" + self.cp.name + "") title.setAlignment(Qt.AlignLeft | Qt.AlignTop) title.setProperty("style", "base-title") - aircraft = self.cp.base.total_aircraft - armor = self.cp.base.total_armor - runway_status = "operational" if self.cp.has_runway() else "damaged" - intel_summary = QLabel("\n".join([ - f"{aircraft} aircraft", - f"{armor} ground units", - f"Runway {runway_status}" - ])) + self.intel_summary = QLabel() + self.update_intel_summary() top_layout.addWidget(title) - top_layout.addWidget(intel_summary) + top_layout.addWidget(self.intel_summary) top_layout.setAlignment(Qt.AlignTop) + + self.repair_button = QPushButton() + self.repair_button.clicked.connect(self.begin_runway_repair) + self.update_repair_button() + top_layout.addWidget(self.repair_button) + base_menu_header.setProperty("style", "baseMenuHeader") base_menu_header.setLayout(top_layout) @@ -96,7 +98,66 @@ class QBaseMenu2(QDialog): bottom_row.addWidget(budget_display) self.setLayout(main_layout) - def closeEvent(self, closeEvent:QCloseEvent): + @property + def can_repair_runway(self) -> bool: + return self.cp.captured and self.cp.runway_can_be_repaired + + @property + def can_afford_runway_repair(self) -> bool: + return self.game_model.game.budget >= db.RUNWAY_REPAIR_COST + + def begin_runway_repair(self) -> None: + if not self.can_afford_runway_repair: + QMessageBox.critical( + self, + "Cannot repair runway", + f"Runway repair costs ${db.RUNWAY_REPAIR_COST}M but you have " + f"only ${self.game_model.game.budget}M available.", + QMessageBox.Ok) + return + if not self.can_repair_runway: + QMessageBox.critical( + self, + "Cannot repair runway", + f"Cannot repair this runway.", QMessageBox.Ok) + return + + self.cp.begin_runway_repair() + self.game_model.game.budget -= db.RUNWAY_REPAIR_COST + self.update_repair_button() + self.update_intel_summary() + GameUpdateSignal.get_instance().updateGame(self.game_model.game) + + def update_repair_button(self) -> None: + self.repair_button.setVisible(True) + turns_remaining = self.cp.runway_status.repair_turns_remaining + if self.cp.captured and turns_remaining is not None: + self.repair_button.setText("Repairing...") + self.repair_button.setDisabled(True) + return + + if self.can_repair_runway: + if self.can_afford_runway_repair: + self.repair_button.setText(f"Repair ${db.RUNWAY_REPAIR_COST}M") + self.repair_button.setDisabled(False) + return + else: + self.repair_button.setText( + f"Cannot afford repair ${db.RUNWAY_REPAIR_COST}M") + self.repair_button.setDisabled(True) + return + + self.repair_button.setVisible(False) + self.repair_button.setDisabled(True) + + def update_intel_summary(self) -> None: + self.intel_summary.setText("\n".join([ + f"{self.cp.base.total_aircraft} aircraft", + f"{self.cp.base.total_armor} ground units", + str(self.cp.runway_status) + ])) + + def closeEvent(self, close_event: QCloseEvent): GameUpdateSignal.get_instance().updateGame(self.game_model.game) def get_base_image(self):