zhexu14 df43d2eed6
Simplfy fast forward settings, introduce ability to skip combat instead of resolving. (#3448)
This PR simplifies fast forward settings and introduces the ability to
skip combat instead of resolving.
2024-10-15 20:10:53 +11:00

664 lines
23 KiB
Python

import logging
from collections.abc import Iterator
from dataclasses import Field, dataclass, fields
from datetime import timedelta
from enum import Enum, unique
from pathlib import Path
from typing import Any, Optional, get_type_hints
import yaml
from dcs.forcedoptions import ForcedOptions
from .booleanoption import boolean_option
from .boundedfloatoption import bounded_float_option
from .boundedintoption import bounded_int_option
from .choicesoption import choices_option
from .minutesoption import minutes_option
from .optiondescription import OptionDescription, SETTING_DESCRIPTION_KEY
from .skilloption import skill_option
from ..ato.starttype import StartType
from ..persistence.paths import liberation_user_dir
@unique
class AutoAtoBehavior(Enum):
Disabled = "Disabled"
Never = "Never assign player pilots"
Default = "No preference"
Prefer = "Prefer player pilots"
@unique
class FastForwardStopCondition(Enum):
DISABLED = "Fast forward disabled"
FIRST_CONTACT = "First contact"
PLAYER_TAKEOFF = "Player takeoff time"
PLAYER_TAXI = "Player taxi time"
PLAYER_STARTUP = "Player startup time"
MANUAL = "Manual fast forward control"
@unique
class CombatResolutionMethod(Enum):
PAUSE = "Pause simulation"
RESOLVE = "Resolve combat"
SKIP = "Skip combat"
DIFFICULTY_PAGE = "Difficulty"
AI_DIFFICULTY_SECTION = "AI Difficulty"
MISSION_DIFFICULTY_SECTION = "Mission Difficulty"
MISSION_RESTRICTIONS_SECTION = "Mission Restrictions"
CAMPAIGN_MANAGEMENT_PAGE = "Campaign Management"
GENERAL_SECTION = "General"
PILOTS_AND_SQUADRONS_SECTION = "Pilots and Squadrons"
HQ_AUTOMATION_SECTION = "HQ Automation"
MISSION_GENERATOR_PAGE = "Mission Generator"
COMMANDERS_SECTION = "Battlefield Commanders"
GAMEPLAY_SECTION = "Gameplay"
# TODO: Make sections a type and add headers.
# This section had the header: "Disabling settings below may improve performance, but
# will impact the overall quality of the experience."
PERFORMANCE_SECTION = "Performance"
@dataclass
class Settings:
version: Optional[str] = None
# Difficulty settings
# AI Difficulty
player_skill: str = skill_option(
"Player coalition skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Good",
)
enemy_skill: str = skill_option(
"Enemy coalition skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Average",
)
enemy_vehicle_skill: str = skill_option(
"Enemy AA and vehicles skill",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
default="Average",
)
player_income_multiplier: float = bounded_float_option(
"Player income multiplier",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
min=0,
max=5,
divisor=10,
default=1.0,
)
enemy_income_multiplier: float = bounded_float_option(
"Enemy income multiplier",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
min=0,
max=5,
divisor=10,
default=1.0,
)
invulnerable_player_pilots: bool = boolean_option(
"Player pilots cannot be killed",
page=DIFFICULTY_PAGE,
section=AI_DIFFICULTY_SECTION,
detail=(
"Aircraft are vulnerable, but the player's pilot will be returned to the "
"squadron at the end of the mission"
),
default=True,
)
# Mission Difficulty
manpads: bool = boolean_option(
"Manpads on frontlines",
page=DIFFICULTY_PAGE,
section=MISSION_DIFFICULTY_SECTION,
default=True,
)
night_disabled: bool = boolean_option(
"No night missions",
page=DIFFICULTY_PAGE,
section=MISSION_DIFFICULTY_SECTION,
default=False,
)
# Mission Restrictions
labels: str = choices_option(
"In game labels",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices=["Full", "Abbreviated", "Dot Only", "Neutral Dot", "Off"],
default="Full",
)
map_coalition_visibility: ForcedOptions.Views = choices_option(
"Map visibility options",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices={
"All": ForcedOptions.Views.All,
"Fog of war": ForcedOptions.Views.Allies,
"Allies only": ForcedOptions.Views.OnlyAllies,
"Own aircraft only": ForcedOptions.Views.MyAircraft,
"Map only": ForcedOptions.Views.OnlyMap,
},
default=ForcedOptions.Views.All,
)
external_views_allowed: bool = boolean_option(
"Allow external views",
DIFFICULTY_PAGE,
MISSION_RESTRICTIONS_SECTION,
default=True,
)
easy_communication: Optional[bool] = choices_option(
"Easy Communication",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices={"Player preference": None, "Enforced on": True, "Enforced off": False},
default=None,
)
battle_damage_assessment: Optional[bool] = choices_option(
"Battle damage assessment",
page=DIFFICULTY_PAGE,
section=MISSION_RESTRICTIONS_SECTION,
choices={"Player preference": None, "Enforced on": True, "Enforced off": False},
default=None,
)
# Campaign management
# General
restrict_weapons_by_date: bool = boolean_option(
"Restrict weapons by date (WIP)",
page=CAMPAIGN_MANAGEMENT_PAGE,
section=GENERAL_SECTION,
default=False,
detail=(
"Restricts weapon availability based on the campaign date. Data is "
"extremely incomplete so does not affect all weapons."
),
)
# Pilots and Squadrons
ai_pilot_levelling: bool = boolean_option(
"Allow AI pilot leveling",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=True,
detail=(
"Set whether or not AI pilots will level up after completing a number of"
" sorties. Since pilot level affects the AI skill, you may wish to disable"
" this, lest you face an Ace!"
),
)
#: Feature flag for squadron limits.
enable_squadron_pilot_limits: bool = boolean_option(
"Enable per-squadron pilot limits (WIP)",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=False,
detail=(
"If set, squadrons will be limited to a maximum number of pilots and dead "
"pilots will replenish at a fixed rate, each defined with the settings"
"below. Auto-purchase may buy aircraft for which there are no pilots"
"available, so this feature is still a work-in-progress."
),
)
#: The maximum number of pilots a squadron can have at one time. Changing this after
#: the campaign has started will have no immediate effect; pilots already in the
#: squadron will not be removed if the limit is lowered and pilots will not be
#: immediately created if the limit is raised.
squadron_pilot_limit: int = bounded_int_option(
"Maximum number of pilots per squadron",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=12,
min=12,
max=72,
detail=(
"Sets the maximum number of pilots a squadron may have active. "
"Changing this value will not have an immediate effect, but will alter "
"replenishment for future turns."
),
)
#: The number of pilots a squadron can replace per turn.
squadron_replenishment_rate: int = bounded_int_option(
"Squadron pilot replenishment rate",
CAMPAIGN_MANAGEMENT_PAGE,
PILOTS_AND_SQUADRONS_SECTION,
default=4,
min=1,
max=20,
detail=(
"Sets the maximum number of pilots that will be recruited to each squadron "
"at the end of each turn. Squadrons will not recruit new pilots beyond the "
"pilot limit, but each squadron with room for more pilots will recruit "
"this many pilots each turn up to the limit."
),
)
# HQ Automation
automate_runway_repair: bool = boolean_option(
"Automate runway repairs",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
remember_player_choice=True,
)
automate_front_line_reinforcements: bool = boolean_option(
"Automate front-line purchases",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
remember_player_choice=True,
)
automate_aircraft_reinforcements: bool = boolean_option(
"Automate aircraft purchases",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=False,
remember_player_choice=True,
)
auto_ato_behavior: AutoAtoBehavior = choices_option(
"Automatic package planning behavior",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=AutoAtoBehavior.Default,
choices={v.value: v for v in AutoAtoBehavior},
detail=(
"Aircraft auto-purchase is directed by the auto-planner, so disabling "
"auto-planning disables auto-purchase."
),
)
auto_ato_player_missions_asap: bool = boolean_option(
"Automatically generated packages with players are scheduled ASAP",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=True,
)
automate_front_line_stance: bool = boolean_option(
"Automatically manage front line stances",
CAMPAIGN_MANAGEMENT_PAGE,
HQ_AUTOMATION_SECTION,
default=True,
)
reserves_procurement_target: int = 10
# Mission Generator
# Commanders
game_master_slots: int = bounded_int_option(
"Game master",
page=MISSION_GENERATOR_PAGE,
section=COMMANDERS_SECTION,
default=0,
min=0,
max=100,
)
tactical_commander_slots: int = bounded_int_option(
"Tactical commander",
page=MISSION_GENERATOR_PAGE,
section=COMMANDERS_SECTION,
default=0,
min=0,
max=100,
)
jtac_operator_slots: int = bounded_int_option(
"JTAC/Operator",
page=MISSION_GENERATOR_PAGE,
section=COMMANDERS_SECTION,
default=0,
min=0,
max=100,
)
observer_slots: int = bounded_int_option(
"Observer",
page=MISSION_GENERATOR_PAGE,
section=COMMANDERS_SECTION,
default=0,
min=0,
max=100,
)
# Gameplay
ai_has_unlimited_fuel: bool = boolean_option(
"Unlimited fuel for AI flights",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=True,
detail=(
"If enabled, AI-only flights will have unlimited fuel. This can be "
"disabled to force AI flights to play by the same rules as players, but be "
"warned that the DCS AI is not particularly fuel conscious, so will often "
"run out of fuel when players would not."
),
)
reload_pre_sim_checkpoint_on_abort: bool = boolean_option(
"Reset mission to pre-take off conditions on abort",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=True,
detail=(
"If enabled, the game will automatically reload a pre-take off save when "
"aborting take off. If this is disabled, you will need to manually re-load "
"your game after aborting take off."
),
)
fast_forward_stop_condition: FastForwardStopCondition = choices_option(
"Fast forward until",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=FastForwardStopCondition.DISABLED,
choices={
"No fast forward": FastForwardStopCondition.DISABLED,
"Player startup time": FastForwardStopCondition.PLAYER_STARTUP,
"Player taxi time": FastForwardStopCondition.PLAYER_TAXI,
"Player takeoff time": FastForwardStopCondition.PLAYER_TAKEOFF,
"First contact": FastForwardStopCondition.FIRST_CONTACT,
"Manual": FastForwardStopCondition.MANUAL,
},
detail=(
"Determines when fast forwarding stops: "
"No fast forward: disables fast forward. "
"Player startup time: fast forward until player startup time. "
"Player taxi time: fast forward until player taxi time. "
"Player takeoff time: fast forward until player takeoff time. "
"First contact: fast forward until first contact between blue and red units. "
"Manual: manually control fast forward. Show manual controls with --show-sim-speed-controls."
),
)
combat_resolution_method: CombatResolutionMethod = choices_option(
"Resolve combat when fast forwarding by",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=CombatResolutionMethod.PAUSE,
choices={
"Pause": CombatResolutionMethod.PAUSE,
"Resolving combat (WIP)": CombatResolutionMethod.RESOLVE,
"Skipping combat": CombatResolutionMethod.SKIP,
},
detail=(
"Determines what happens when combat occurs when fast forwarding. "
"Pause: pause fast forward and generate mission. Fast forwarding may stop before the condition specified in the above setting. "
"Resolving combat (WIP): auto resolve combat. This method is very rudimentary and will result in large losses. "
"Skipping combat: skip combat as if it did not occur."
),
)
supercarrier: bool = boolean_option(
"Use supercarrier module",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
remember_player_choice=True,
)
generate_marks: bool = boolean_option(
"Put objective markers on the map",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=True,
)
generate_dark_kneeboard: bool = boolean_option(
"Generate dark kneeboard",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
detail=(
"Dark kneeboard for night missions. This will likely make the kneeboard on "
"the pilot leg unreadable."
),
)
never_delay_player_flights: bool = boolean_option(
"Player flights ignore TOT and spawn immediately",
MISSION_GENERATOR_PAGE,
GAMEPLAY_SECTION,
default=False,
detail=(
"Does not adjust package waypoint times. Should not be used if players "
"have runway or in-air starts."
),
tooltip=(
"Always spawns player aircraft immediately, even if their start time is "
"more than 10 minutes after the start of the mission. <strong>This does "
"not alter the timing of your mission. Your TOT will not change. This "
"option only allows the player to wait on the ground.</strong>"
),
)
default_start_type: StartType = choices_option(
"Default start type for AI aircraft",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
choices={v.value: v for v in StartType},
default=StartType.COLD,
detail=(
"Warning: Options other than Cold will significantly reduce the number of "
"targets available for OCA/Aircraft missions, and OCA/Aircraft flights "
"will not be included in automatically planned OCA packages."
),
)
# Mission specific
desired_player_mission_duration: timedelta = minutes_option(
"Desired mission duration",
page=MISSION_GENERATOR_PAGE,
section=GAMEPLAY_SECTION,
default=timedelta(minutes=60),
min=30,
max=150,
detail=(
"Period of time after the start of a turn where "
"there is coverage by one or more CAP flight(s), "
"refuelling flights not tied to a specific package are on station, "
"and other flights have their TOTs."
),
)
# Performance
perf_smoke_gen: bool = boolean_option(
"Smoke visual effect on the front line",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_smoke_spacing: int = bounded_int_option(
"Smoke generator spacing (higher means less smoke)",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=1600,
min=800,
max=24000,
)
perf_red_alert_state: bool = boolean_option(
"SAM starts in red alert mode",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_artillery: bool = boolean_option(
"Artillery strikes",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
generate_fire_tasks_for_missile_sites: bool = boolean_option(
"Generate fire tasks for missile sites",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
detail=(
"If enabled, missile sites like V2s and Scuds will fire on random targets "
"at the start of the mission."
),
default=True,
)
perf_moving_units: bool = boolean_option(
"Moving ground units",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
convoys_travel_full_distance: bool = boolean_option(
"Convoys drive the full distance between control points",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_infantry: bool = boolean_option(
"Generate infantry squads alongside vehicles",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_destroyed_units: bool = boolean_option(
"Generate carcasses for units destroyed in previous turns",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
# Performance culling
perf_culling: bool = boolean_option(
"Culling of distant units enabled",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=False,
)
perf_culling_distance: int = bounded_int_option(
"Culling distance (km)",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=100,
min=10,
max=10000,
causes_expensive_game_update=True,
)
perf_do_not_cull_threatening_iads: bool = boolean_option(
"Do not cull threatening IADS",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
)
perf_do_not_cull_carrier: bool = boolean_option(
"Do not cull carrier's surroundings",
page=MISSION_GENERATOR_PAGE,
section=PERFORMANCE_SECTION,
default=True,
causes_expensive_game_update=True,
)
# Cheating. Not using auto settings because the same page also has buttons which do
# not alter settings.
show_red_ato: bool = False
enable_frontline_cheats: bool = False
enable_base_capture_cheat: bool = False
enable_runway_state_cheat: bool = False
only_player_takeoff: bool = True # Legacy parameter do not use
def save_player_settings(self) -> None:
"""Saves the player's global settings to the user directory."""
settings: dict[str, Any] = {}
for name, description in self.all_fields():
if description.remember_player_choice:
settings[name] = self.__dict__[name]
with self._player_settings_file.open("w", encoding="utf-8") as settings_file:
yaml.dump(settings, settings_file, sort_keys=False, explicit_start=True)
def merge_player_settings(self) -> None:
"""Updates with the player's global settings."""
settings_path = self._player_settings_file
if not settings_path.exists():
return
with settings_path.open(encoding="utf-8") as settings_file:
data = yaml.safe_load(settings_file)
if data is None:
logging.warning("Saved settings file %s is empty", settings_path)
return
expected_types = get_type_hints(Settings)
for key, value in data.items():
if key not in self.__dict__:
logging.warning(
"Unexpected settings key found in %s: %s. Ignoring.",
settings_path,
key,
)
continue
expected_type = expected_types[key]
if not isinstance(value, expected_type):
logging.error(
"%s in %s does not have the expected type %s (is %s). Ignoring.",
key,
settings_path,
expected_type.__name__,
value.__class__.__name__,
)
continue
self.__dict__[key] = value
@property
def _player_settings_file(self) -> Path:
"""Returns the path to the player's global settings file."""
return liberation_user_dir() / "settings.yaml"
def __setstate__(self, state: dict[str, Any]) -> None:
# __setstate__ is called with the dict of the object being unpickled. We
# can provide save compatibility for new settings options (which
# normally would not be present in the unpickled object) by creating a
# new settings object, updating it with the unpickled state, and
# updating our dict with that.
new_state = Settings().__dict__
new_state.update(state)
self.__dict__.update(new_state)
@classmethod
def _field_description(cls, settings_field: Field[Any]) -> OptionDescription:
return settings_field.metadata[SETTING_DESCRIPTION_KEY]
@classmethod
def pages(cls) -> Iterator[str]:
seen: set[str] = set()
for settings_field in cls._user_fields():
description = cls._field_description(settings_field)
if description.page not in seen:
yield description.page
seen.add(description.page)
@classmethod
def sections(cls, page: str) -> Iterator[str]:
seen: set[str] = set()
for settings_field in cls._user_fields():
description = cls._field_description(settings_field)
if description.page == page and description.section not in seen:
yield description.section
seen.add(description.section)
@classmethod
def all_fields(cls) -> Iterator[tuple[str, OptionDescription]]:
for settings_field in cls._user_fields():
yield settings_field.name, cls._field_description(settings_field)
@classmethod
def fields_for(
cls, page: str, section: str
) -> Iterator[tuple[str, OptionDescription]]:
for name, description in cls.all_fields():
if description.page == page and description.section == section:
yield name, description
@classmethod
def _user_fields(cls) -> Iterator[Field[Any]]:
for settings_field in fields(cls):
if SETTING_DESCRIPTION_KEY in settings_field.metadata:
yield settings_field