diff --git a/.github/mapchecker/.gitignore b/.github/mapchecker/.gitignore new file mode 100644 index 00000000000..5ceb3864c29 --- /dev/null +++ b/.github/mapchecker/.gitignore @@ -0,0 +1 @@ +venv diff --git a/.github/mapchecker/README.md b/.github/mapchecker/README.md new file mode 100644 index 00000000000..f31a97246aa --- /dev/null +++ b/.github/mapchecker/README.md @@ -0,0 +1,73 @@ +# MapChecker + +This directory contains tooling contributed by TsjipTsjip, initially to automate the process of checking if map +contributions in PR's are valid. That is to say, it collects a list of prototypes in the `Resources/Prototypes` +directory which are marked as `DO NOT MAP`, `DEBUG`, ... and verifies that map changes indeed do not use them. + +## Usage + +Glad I do not have to write this myself! Get detailed help information by running: +`python3 .github/mapchecker/mapchecker.py --help` + +The following help block is printed: +``` +usage: mapchecker.py [-h] [-v] [-p PROTOTYPES_PATH [PROTOTYPES_PATH ...]] [-m MAP_PATH [MAP_PATH ...]] [-w WHITELIST] + +Map prototype usage checker for Frontier Station 14. + +options: + -h, --help show this help message and exit + -v, --verbose Sets log level to DEBUG if present, spitting out a lot more information. False by default,. + -p PROTOTYPES_PATH [PROTOTYPES_PATH ...], --prototypes_path PROTOTYPES_PATH [PROTOTYPES_PATH ...] + Directory holding entity prototypes. Default: All entity prototypes in the Frontier Station 14 codebase. + -m MAP_PATH [MAP_PATH ...], --map_path MAP_PATH [MAP_PATH ...] + Map PROTOTYPES or directory of map prototypes to check. Can mix and match.Default: All maps in the Frontier Station 14 codebase. + -w WHITELIST, --whitelist WHITELIST + YML file that lists map names and prototypes to allow for them. +``` + +You should generally not need to configure `-p`, `-m` or `-w`, as they are autofilled with sensible defaults. You can do +this: +- Set `-p` to only check against prototypes in a specific directory. +- Set `-m` to just check a specific map. (Make sure to **point it at the prototype**, not the map file itself!) +- Set `-v` with `-m` set as per above to get detailed information about a possible rejection for just that map. + +## Configuration + +Matchers are set in `config.py`. Currently it has a global list of matchers that are not allowed anywhere, and a set +of conditional matchers. + +For each map, a set of applicable matchers is constructed according to this workflow: +1. Add all global illegal matchers. +2. Add all conditional matchers for non-matching shipyard groups +3. Remove all conditional matchers from the matching shipyard group (if it exists), to support duplicates across + shipyard groups + +A match will attempt to match the following during prototype collection: +- Prototype ID (contains matcher, case insensitive) +- Prototype name (contains matcher, case insensitive) +- Prototype suffixes (separated per `, `) (exact, case insensitive) + +## Whitelisting + +If a map has a prototype and you believe it should be whitelisted, add a key for your map name (the `id` field of the +gameMap prototype), and add the prototype ID's to its list. + +The whitelist the checker uses by default is `.github/mapchecker/whitelist.yml`. + +## Shuttle group override + +It is possible that a shuttle is set to group `None` because it is only used in custom shipyard listings. In this case, +you can force the MapChecker script to treat it as a different shipyard group by adding the following to the vessel +prototype: + +```yml + ... + group: None + # Add this line below. + mapchecker_group_override: ShipyardGroupHere + ... +``` + +Note that for now this will cause a warning to be generated, but it will not cause a failure if the shuttle matches the +criteria for the overridden group. diff --git a/.github/mapchecker/config.py b/.github/mapchecker/config.py new file mode 100644 index 00000000000..a864dfe548d --- /dev/null +++ b/.github/mapchecker/config.py @@ -0,0 +1,15 @@ +# List of matchers that are always illegal to use. These always supercede CONDITIONALLY_ILLEGAL_MATCHES. +ILLEGAL_MATCHES = [ + "DO NOT MAP", + "DEBUG", +] +# List of matchers that are illegal to use, unless the map is a ship and the ship belongs to the keyed shipyard. +CONDITIONALLY_ILLEGAL_MATCHES = { + "Security": [ # These matchers are illegal unless the ship is part of the security shipyard. + "Security", # Anything with the word security in it should also only be appearing on security ships. + "Plastitanium", # Plastitanium walls should only be appearing on security ships. + ], + "BlackMarket": [ + "Plastitanium", # And also on blackmarket ships cause syndicate. + ] +} diff --git a/.github/mapchecker/mapchecker.py b/.github/mapchecker/mapchecker.py new file mode 100755 index 00000000000..e57606487ec --- /dev/null +++ b/.github/mapchecker/mapchecker.py @@ -0,0 +1,271 @@ +#! /bin/python3 + +import argparse +import os +import yaml +from typing import List, Dict + +from util import get_logger, YamlLoaderIgnoringTags, check_prototype +from config import CONDITIONALLY_ILLEGAL_MATCHES + +if __name__ == "__main__": + # Set up argument parser. + parser = argparse.ArgumentParser(description="Map prototype usage checker for Frontier Station 14.") + parser.add_argument( + "-v", "--verbose", + action='store_true', + help="Sets log level to DEBUG if present, spitting out a lot more information. False by default,." + ) + parser.add_argument( + "-p", "--prototypes_path", + help="Directory holding entity prototypes.\nDefault: All entity prototypes in the Frontier Station 14 codebase.", + type=str, + nargs="+", # We accept multiple directories, but need at least one. + required=False, + default=[ + "Resources/Prototypes/Entities", # Upstream + "Resources/Prototypes/_NF/Entities", # NF + "Resources/Prototypes/_Nyano/Entities", # Nyanotrasen + "Resources/Prototypes/Nyanotrasen/Entities", # Nyanotrasen, again + "Resources/Prototypes/DeltaV/Entities", # DeltaV + ] + ) + parser.add_argument( + "-m", "--map_path", + help=(f"Map PROTOTYPES or directory of map prototypes to check. Can mix and match." + f"Default: All maps in the Frontier Station 14 codebase."), + type=str, + nargs="+", # We accept multiple pathspecs, but need at least one. + required=False, + default=[ + "Resources/Prototypes/_NF/Maps/Outpost", # Frontier Outpost + "Resources/Prototypes/_NF/Maps/POI", # Points of interest + "Resources/Prototypes/_NF/Shipyard", # Shipyard ships. + ] + ) + parser.add_argument( + "-w", "--whitelist", + help="YML file that lists map names and prototypes to allow for them.", + type=str, # Using argparse.FileType here upsets os.isfile, we work around this. + nargs=1, + required=False, + default=".github/mapchecker/whitelist.yml" + ) + + # ================================================================================================================== + # PHASE 0: Parse arguments and transform them into lists of files to work on. + args = parser.parse_args() + + # Set up logging session. + logger = get_logger(args.verbose) + logger.info("MapChecker starting up.") + logger.debug("Verbosity enabled.") + + # Set up argument collectors. + proto_paths: List[str] = [] + map_proto_paths: List[str] = [] + whitelisted_protos: Dict[str, List[str]] = dict() + whitelisted_maps: List[str] = [] + + # Validate provided arguments and collect file locations. + for proto_path in args.prototypes_path: # All prototype paths must be directories. + if os.path.isdir(proto_path) is False: + logger.warning(f"Prototype path '{proto_path}' is not a directory. Continuing without it.") + continue + # Collect all .yml files in this directory. + for root, dirs, files in os.walk(proto_path): + for file in files: + if file.endswith(".yml"): + proto_paths.append(str(os.path.join(root, file))) + for map_path in args.map_path: # All map paths must be files or directories. + if os.path.isfile(map_path): + # If it's a file, we just add it to the list. + map_proto_paths.append(map_path) + elif os.path.isdir(map_path): + # If it's a directory, we add all .yml files in it to the list. + for root, dirs, files in os.walk(map_path): + for file in files: + if file.endswith(".yml"): + map_proto_paths.append(os.path.join(root, file)) + else: + logger.warning(f"Map path '{map_path}' is not a file or directory. Continuing without it.") + continue + + # Validate whitelist, it has to be a file containing valid yml. + if os.path.isfile(args.whitelist) is False: + logger.warning(f"Whitelist '{args.whitelist}' is not a file. Continuing without it.") + else: + with open(args.whitelist, "r") as whitelist: + file_data = yaml.load(whitelist, Loader=YamlLoaderIgnoringTags) + if file_data is None: + logger.warning(f"Whitelist '{args.whitelist}' is empty. Continuing without it.") + else: + for map_key in file_data: + if file_data[map_key] is True: + whitelisted_maps.append(map_key) + elif file_data[map_key] is False: + continue + else: + whitelisted_protos[map_key] = file_data[map_key] + + # ================================================================================================================== + # PHASE 1: Collect all prototypes in proto_paths that are suffixed with target suffixes. + + # Set up collectors. + illegal_prototypes: List[str] = list() + conditionally_illegal_prototypes: Dict[str, List[str]] = dict() + for key in CONDITIONALLY_ILLEGAL_MATCHES.keys(): # Ensure all keys have empty lists already, less work later. + conditionally_illegal_prototypes[key] = list() + + # Collect all prototypes and sort into the collectors. + for proto_file in proto_paths: + with open(proto_file, "r") as proto: + logger.debug(f"Reading prototype file '{proto_file}'.") + file_data = yaml.load(proto, Loader=YamlLoaderIgnoringTags) + if file_data is None: + continue + + for item in file_data: # File data has blocks of things we need. + if item["type"] != "entity": + continue + proto_id = item["id"] + proto_name = item["name"] if "name" in item.keys() else "" + proto_suffixes = str(item["suffix"]).split(", ") if "suffix" in item.keys() else list() + + check_result = check_prototype(proto_id, proto_name, proto_suffixes) + if check_result is False: + illegal_prototypes.append(proto_id) + elif check_result is not True: + for key in check_result: + conditionally_illegal_prototypes[key].append(proto_id) + + # Log information. + logger.info(f"Collected {len(illegal_prototypes)} illegal prototype matchers.") + for key in conditionally_illegal_prototypes.keys(): + logger.info(f"Collected {len(conditionally_illegal_prototypes[key])} illegal prototype matchers, whitelisted " + f"for shipyard group {key}.") + for item in conditionally_illegal_prototypes[key]: + logger.debug(f" - {item}") + + # ================================================================================================================== + # PHASE 2: Check all maps in map_proto_paths for illegal prototypes. + + # Set up collectors. + violations: Dict[str, List[str]] = dict() + + # Check all maps for illegal prototypes. + for map_proto in map_proto_paths: + with open(map_proto, "r") as map: + file_data = yaml.load(map, Loader=YamlLoaderIgnoringTags) + if file_data is None: + logger.warning(f"Map prototype '{map_proto}' is empty. Continuing without it.") + continue + + map_name = map_proto # The map name that will be reported over output. + map_file_location = None + shipyard_group = None # Shipyard group of this map, if it's a shuttle. + # Shipyard override of this map, in the case it's a custom shipyard shuttle but needs to be treated as a + # specific group. + shipyard_override = None + + for item in file_data: + if item["type"] == "gameMap": + # This yaml entry is the map descriptor. Collect its file location and map name. + if "id" in item.keys(): + map_name = item["id"] + map_file_location = item["mapPath"] if "mapPath" in item.keys() else None + if item["type"] == "vessel": + # This yaml entry is a vessel descriptor! + shipyard_group = item["group"] if "group" in item.keys() else None + shipyard_override = item["mapchecker_group_override"] if "mapchecker_group_override" in item.keys() else None + + if map_file_location is None: + # Silently skip. If the map doesn't have a mapPath, it won't appear in game anyways. + logger.debug(f"Map proto {map_proto} did not specify a map file location. Skipping.") + continue + + # CHECKPOINT - If the map_name is blanket-whitelisted, skip it, but log a warning. + if map_name in whitelisted_maps: + logger.warning(f"Map '{map_name}' (from prototype '{map_proto}') was blanket-whitelisted. Skipping it.") + continue + + if shipyard_override is not None: + # Log a warning, indicating the override and the normal group this shuttle belongs to, then set + # shipyard_group to the override. + logger.warning(f"Map '{map_name}' (from prototype '{map_proto}') is using mapchecker_group_override. " + f"This map will be treated as a '{shipyard_override}' shuttle. (Normally: " + f"'{shipyard_group}'))") + shipyard_group = shipyard_override + + logger.debug(f"Starting checks for '{map_name}' (Path: '{map_file_location}' | Shipyard: '{shipyard_group}')") + + # Now construct a temporary list of all prototype ID's that are illegal for this map based on conditionals. + conditional_checks = set() # Make a set of it. That way we get no duplicates. + for key in conditionally_illegal_prototypes.keys(): + if shipyard_group != key: + for item in conditionally_illegal_prototypes[key]: + conditional_checks.add(item) + # Remove the ones that do match, if they exist. + if shipyard_group is not None and shipyard_group in conditionally_illegal_prototypes.keys(): + for check in conditionally_illegal_prototypes[shipyard_group]: + if check in conditional_checks: + conditional_checks.remove(check) + + logger.debug(f"Conditional checks for {map_name} after removal of shipyard dups: {conditional_checks}") + + # Now we check the map file for these illegal prototypes. I'm being lazy here and just matching against the + # entire file contents, without loading YAML at all. This is fine, because this job only runs after + # Content.YamlLinter runs. TODO: It does not. + with open("Resources" + map_file_location, "r") as map_file: + map_file_contents = map_file.read() + for check in illegal_prototypes: + # Wrap in 'proto: ' and '\n' here, to ensure we only match actual prototypes, not 'part of word' + # prototypes. Example: SignSec is a prefix of SignSecureMed + if 'proto: ' + check + '\n' in map_file_contents: + if violations.get(map_name) is None: + violations[map_name] = list() + violations[map_name].append(check) + for check in conditional_checks: + if 'proto: ' + check + '\n' in map_file_contents: + if violations.get(map_name) is None: + violations[map_name] = list() + violations[map_name].append(check) + + # ================================================================================================================== + # PHASE 3: Filtering findings and reporting. + logger.debug(f"Violations aggregator before whitelist processing: {violations}") + + # Filter out all prototypes that are whitelisted. + for key in whitelisted_protos.keys(): + if violations.get(key) is None: + continue + + for whitelisted_proto in whitelisted_protos[key]: + if whitelisted_proto in violations[key]: + violations[key].remove(whitelisted_proto) + + logger.debug(f"Violations aggregator after whitelist processing: {violations}") + + # Some maps had all their violations whitelisted. Remove them from the count. + total_map_violations = len([viol for viol in violations.keys() if len(violations[viol]) > 0]) + + # Report findings to output, on the ERROR loglevel, so they stand out in Github actions output. + if total_map_violations > 0: + logger.error(f"Found {total_map_violations} maps with illegal prototypes.") + for key in violations.keys(): + if len(violations[key]) == 0: + # If the map has no violations at this point, it's because all of its violations were whitelisted. + # Don't include them in the report. + continue + + logger.error(f"Map '{key}' has {len(violations[key])} illegal prototypes.") + for violation in violations[key]: + logger.error(f" - {violation}") + else: + logger.info("No illegal prototypes found in any maps.") + + logger.info(f"MapChecker finished{' with errors' if total_map_violations > 0 else ''}.") + if total_map_violations > 0: + exit(1) + else: + exit(0) diff --git a/.github/mapchecker/requirements.txt b/.github/mapchecker/requirements.txt new file mode 100644 index 00000000000..be2b74db40f --- /dev/null +++ b/.github/mapchecker/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.1 diff --git a/.github/mapchecker/util.py b/.github/mapchecker/util.py new file mode 100644 index 00000000000..bfa9036cddc --- /dev/null +++ b/.github/mapchecker/util.py @@ -0,0 +1,88 @@ +import logging + +from yaml import SafeLoader +from typing import List, Union +from logging import Logger, getLogger + +from config import ILLEGAL_MATCHES, CONDITIONALLY_ILLEGAL_MATCHES + + +def get_logger(debug: bool = False) -> Logger: + """ + Gets a logger for use by MapChecker. + + :return: A logger. + """ + logger = getLogger("MapChecker") + logger.setLevel("DEBUG" if debug else "INFO") + + sh = logging.StreamHandler() + formatter = logging.Formatter( + "[%(asctime)s %(levelname)7s] %(message)s", + datefmt='%Y-%m-%d %H:%M:%S' + ) + sh.setFormatter(formatter) + logger.addHandler(sh) + + return logger + + +# Snippet taken from https://stackoverflow.com/questions/33048540/pyyaml-safe-load-how-to-ignore-local-tags +class YamlLoaderIgnoringTags(SafeLoader): + def ignore_unknown(self, node): + return None + + +YamlLoaderIgnoringTags.add_constructor(None, YamlLoaderIgnoringTags.ignore_unknown) +# End of snippet + + +def check_prototype(proto_id: str, proto_name: str, proto_suffixes: List[str]) -> Union[bool, List[str]]: + """ + Checks prototype information against the ILLEGAL_MATCHES and CONDITIONALLY_ILLEGAL_MATCHES constants. + + :param proto_id: The prototype's ID. + :param proto_name: The prototype's name. + :param proto_suffixes: The prototype's suffixes. + :return: + - True if the prototype is legal + - False if the prototype is globally illegal (matched by ILLEGAL_MATCHES) + - A list of shipyard keys if the prototype is conditionally illegal (matched by CONDITIONALLY_ILLEGAL_MATCHES) + """ + + # Check against ILLEGAL_MATCHES. + for illegal_match in ILLEGAL_MATCHES: + if illegal_match.lower() in proto_name.lower(): + return False + + if illegal_match.lower() in proto_id.lower(): + return False + + for suffix in proto_suffixes: + if illegal_match.lower() == suffix.lower(): + return False + + # Check against CONDITIONALLY_ILLEGAL_MATCHES. + conditionally_illegal_keys = list() + for key in CONDITIONALLY_ILLEGAL_MATCHES.keys(): + + cond_illegal_matches = CONDITIONALLY_ILLEGAL_MATCHES[key] + for cond_illegal_match in cond_illegal_matches: + + if cond_illegal_match.lower() in proto_name.lower(): + conditionally_illegal_keys.append(key) + break + + if cond_illegal_match.lower() in proto_id.lower(): + conditionally_illegal_keys.append(key) + break + + for suffix in proto_suffixes: + if cond_illegal_match.lower() == suffix.lower(): + conditionally_illegal_keys.append(key) + break + + if len(conditionally_illegal_keys) > 0: + return conditionally_illegal_keys + + return True diff --git a/.github/mapchecker/whitelist.yml b/.github/mapchecker/whitelist.yml new file mode 100644 index 00000000000..a039d209cc1 --- /dev/null +++ b/.github/mapchecker/whitelist.yml @@ -0,0 +1,80 @@ +# POI's +Frontier: true + +anomalouslab: + - WallPlastitaniumIndestructible + - WallPlastitanium + - PlastitaniumWindow +Cove: + - WallPlastitanium + - HighSecDoor +Lodge: + - WallPlastitanium + - HighSecDoor + + +# TECHNICAL DEBT BELOW. These ones were added to this list to ensure other PR's would not break upon merging. It is +# the intention for this list to become empty in separate PR's. +Esquire: + - ToyFigurineSecurity + - AirlockSecurity + - SignSec +Bison: + - WallPlastitanium + - WallPlastitaniumDiagonal +Sprinter: + - WallPlastitanium + - IntercomSecurity + - IntercomCommand +Courser: + - HighSecDoor +Metastable: + - SignSec +DartX: + - HighSecDoor +gourd: + - HoloprojectorSecurity +Praeda: + - ClothingEyesGlassesSecurity + - EncryptionKeyCommand + - EncryptionKeyEngineering +Pheonix: + - DebugSubstation + - WallPlastitanium +Spectre: + - AirlockSecurity + - EncryptionKeyMedicalScience +Bazaar: + - AirlockSecurity +Anchor: + - AirlockSecurity + - IntercomCommand +DecadeDove: + - EncryptionKeyCargo + - EncryptionKeyEngineering +RosebudMKI: + - EncryptionKeyCargo + - EncryptionKeyEngineering + - EncryptionKeyScience + - EncryptionKeyService +Dragonfly: + - EncryptionKeyCargo + - EncryptionKeyEngineering +Opportunity: + - EncryptionKeySecurity +RosebudMKII: + - EncryptionKeyCargo + - EncryptionKeyEngineering + - EncryptionKeyScience + - EncryptionKeyService +Crescent: + - EncryptionKeyScience + - IntercomCommand +Pathfinder: + - IntercomCommand +Schooner: + - IntercomCommand +Marauder: + - IntercomCommand +Empress: + - IntercomAll diff --git a/.github/workflows/frontier-mapchecker.yml b/.github/workflows/frontier-mapchecker.yml new file mode 100644 index 00000000000..e25ba676533 --- /dev/null +++ b/.github/workflows/frontier-mapchecker.yml @@ -0,0 +1,39 @@ +name: Map Prototype Checker + +on: + pull_request: + branches: [ "master" ] + paths: + # Entity pathspecs - If any of these change (i.e. suffix changes etc), this check should run. + - "Resources/Prototypes/Entities/**/*.yml" + - "Resources/Prototypes/_NF/Entities/**/*.yml" + - "Resources/Prototypes/Nyanotrasen/Entities/**/*.yml" + - "Resources/Prototypes/_Nyano/Entities/**/*.yml" + - "Resources/Prototypes/DeltaV/Entities/**/*.yml" + # Map pathspecs - If any maps are changed, this should run. + - "Resources/Maps/**/*.yml" + # Also the mapchecker itself + - ".github/mapchecker/**" + + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r .github/mapchecker/requirements.txt + - name: Run mapchecker + run: | + python3 .github/mapchecker/mapchecker.py diff --git a/Content.Client/Preferences/UI/HumanoidProfileEditor.Random.cs b/Content.Client/Preferences/UI/HumanoidProfileEditor.Random.cs index c9e184dfc23..b82ce91f45b 100644 --- a/Content.Client/Preferences/UI/HumanoidProfileEditor.Random.cs +++ b/Content.Client/Preferences/UI/HumanoidProfileEditor.Random.cs @@ -9,7 +9,7 @@ public sealed partial class HumanoidProfileEditor private void RandomizeEverything() { - Profile = HumanoidCharacterProfile.Random(); + Profile = HumanoidCharacterProfile.Random(balance : Profile?.BankBalance ?? HumanoidCharacterProfile.DefaultBalance); UpdateControls(); IsDirty = true; } diff --git a/Content.Client/Shipyard/BUI/ShipyardConsoleBoundUserInterface.cs b/Content.Client/Shipyard/BUI/ShipyardConsoleBoundUserInterface.cs index d1703159a70..f1a257045f6 100644 --- a/Content.Client/Shipyard/BUI/ShipyardConsoleBoundUserInterface.cs +++ b/Content.Client/Shipyard/BUI/ShipyardConsoleBoundUserInterface.cs @@ -42,12 +42,12 @@ protected override void Open() _menu.TargetIdButton.OnPressed += _ => SendMessage(new ItemSlotButtonPressedEvent("ShipyardConsole-targetId")); } - private void Populate(byte uiKey) + private void Populate(List prototypes, string name) { if (_menu == null) return; - _menu.PopulateProducts((ShipyardConsoleUiKey) uiKey); + _menu.PopulateProducts(prototypes, name); _menu.PopulateCategories(); } @@ -61,7 +61,7 @@ protected override void UpdateState(BoundUserInterfaceState state) Balance = cState.Balance; ShipSellValue = cState.ShipSellValue; var castState = (ShipyardConsoleInterfaceState) state; - Populate(castState.UiKey); + Populate(castState.ShipyardPrototypes, castState.ShipyardName); _menu?.UpdateState(castState); } diff --git a/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs index d6489776385..93ae70fd5ac 100644 --- a/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs +++ b/Content.Client/Shipyard/UI/ShipyardConsoleMenu.xaml.cs @@ -23,6 +23,9 @@ public sealed partial class ShipyardConsoleMenu : FancyWindow private readonly List _categoryStrings = new(); private string? _category; + private List _lastProtos = new(); + private string _lastType = ""; + public ShipyardConsoleMenu(ShipyardConsoleBoundUserInterface owner) { RobustXamlLoader.Load(this); @@ -38,12 +41,12 @@ public ShipyardConsoleMenu(ShipyardConsoleBoundUserInterface owner) private void OnCategoryItemSelected(OptionButton.ItemSelectedEventArgs args) { SetCategoryText(args.Id); - PopulateProducts((ShipyardConsoleUiKey) _menu.UiKey); + PopulateProducts(_lastProtos, _lastType); } private void OnSearchBarTextChanged(LineEdit.LineEditEventArgs args) { - PopulateProducts((ShipyardConsoleUiKey) _menu.UiKey); + PopulateProducts(_lastProtos, _lastType); } private void SetCategoryText(int id) @@ -52,52 +55,35 @@ private void SetCategoryText(int id) Categories.SelectId(id); } - private void GetPrototypes(out IEnumerable vessels) - { - vessels = _protoManager.EnumeratePrototypes(); - } - /// /// Populates the list of products that will actually be shown, using the current filters. /// - public void PopulateProducts(ShipyardConsoleUiKey uiKey) + public void PopulateProducts(List prototypes, string type) { Vessels.RemoveAllChildren(); - GetPrototypes(out var vessels); - var vesselList = vessels.ToList(); - vesselList.Sort((x, y) => - string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase)); - var type = uiKey switch - { - ShipyardConsoleUiKey.Shipyard => "Civilian", - ShipyardConsoleUiKey.Security => "Security", - ShipyardConsoleUiKey.BlackMarket => "BlackMarket", - ShipyardConsoleUiKey.Expedition => "Expedition", - ShipyardConsoleUiKey.Scrap => "Scrap", - _ => "Shipyard", - }; + var newVessels = prototypes.Select(it => _protoManager.TryIndex(it, out var proto) ? proto : null) + .Where(it => it != null) + .ToList(); + + newVessels.Sort((x, y) => + string.Compare(x!.Name, y!.Name, StringComparison.CurrentCultureIgnoreCase)); var search = SearchBar.Text.Trim().ToLowerInvariant(); - foreach (var prototype in vesselList) + foreach (var prototype in newVessels) { - // filter by type for ui key - if (prototype.Group != type) - { - continue; - } // if no search or category // else if search // else if category and not search if (search.Length == 0 && _category == null || - search.Length != 0 && prototype.Name.ToLowerInvariant().Contains(search) || - search.Length == 0 && _category != null && prototype.Category.Equals(_category)) + search.Length != 0 && prototype!.Name.ToLowerInvariant().Contains(search) || + search.Length == 0 && _category != null && prototype!.Category.Equals(_category)) { var vesselEntry = new VesselRow { Vessel = prototype, - VesselName = { Text = prototype.Name }, + VesselName = { Text = prototype!.Name }, Purchase = { ToolTip = prototype.Description, TooltipDelay = 0.2f }, Price = { Text = Loc.GetString("cargo-console-menu-points-amount", ("amount", prototype.Price.ToString())) }, }; @@ -105,6 +91,9 @@ public void PopulateProducts(ShipyardConsoleUiKey uiKey) Vessels.AddChild(vesselEntry); } } + + _lastProtos = prototypes; + _lastType = type; } /// @@ -114,7 +103,7 @@ public void PopulateCategories() { _categoryStrings.Clear(); Categories.Clear(); - GetPrototypes(out var vessels); + var vessels = _protoManager.EnumeratePrototypes(); foreach (var prototype in vessels) { if (!_categoryStrings.Contains(prototype.Category)) diff --git a/Content.Client/Shuttles/UI/RadarConsoleWindow.xaml b/Content.Client/Shuttles/UI/RadarConsoleWindow.xaml index 26aca5da629..32f2a575cec 100644 --- a/Content.Client/Shuttles/UI/RadarConsoleWindow.xaml +++ b/Content.Client/Shuttles/UI/RadarConsoleWindow.xaml @@ -2,7 +2,7 @@ xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls" xmlns:ui="clr-namespace:Content.Client.Shuttles.UI" Title="{Loc 'radar-console-window-title'}" - SetSize="648 648" + SetSize="648 672" MinSize="256 256"> + /// If present, called for every IFF. Must determine if it should or should not be shown. + /// + public Func? IFFFilter { get; set; } = null; + /// /// Currently hovered docked to show on the map. /// @@ -286,19 +291,11 @@ protected override void Draw(DrawingHandleScreen handle) uiPosition = new Vector2(uiX + uiXCentre, uiY + uiYCentre); } - if (!ShowIFFShuttles) - { - if (iff != null && (iff.Flags & IFFFlags.IsPlayerShuttle) != 0x0) - { - label.Visible = false; - } - else - label.Visible = true; - } - else - { - label.Visible = true; - } + label.Visible = ShowIFFShuttles + || iff == null || (iff.Flags & IFFFlags.IsPlayerShuttle) == 0x0; + + if (IFFFilter != null) + label.Visible &= IFFFilter(gUid, grid.Comp, iff); label.Text = Loc.GetString("shuttle-console-iff-label", ("name", name), ("distance", $"{distance:0.0}")); LayoutContainer.SetPosition(label, uiPosition); diff --git a/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml b/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml index 653813f909f..688e3f9ea86 100644 --- a/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml +++ b/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml @@ -2,7 +2,7 @@ xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls" xmlns:ui="clr-namespace:Content.Client.Shuttles.UI" Title="{Loc 'shuttle-console-window-title'}" - SetSize="1180 648" + SetSize="1180 672" MinSize="788 320"> +