diff --git a/.github/mapchecker/mapchecker.py b/.github/mapchecker/mapchecker.py index 31b493f2cc99..c90b9d5e1f08 100755 --- a/.github/mapchecker/mapchecker.py +++ b/.github/mapchecker/mapchecker.py @@ -156,6 +156,7 @@ # Set up collectors. violations: Dict[str, List[str]] = dict() + map_overrides: Dict[str, str] = dict() # Check all maps for illegal prototypes. for map_proto in map_proto_paths: @@ -192,11 +193,6 @@ 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. @@ -204,7 +200,19 @@ f"This map will be treated as a '{shipyard_override}' shuttle. (Normally: " f"'{shipyard_group}'))") shipyard_group = shipyard_override - + map_overrides[map_name] = shipyard_override + + whitelisted = False + if map_name in whitelisted_maps: + logger.warning(f"Map '{map_name}' (from prototype '{map_proto}') was blanket-whitelisted. Skipping it.") + whitelisted = True + elif shipyard_override is not None and shipyard_override in whitelisted_maps: + logger.warning(f"Map '{map_name}' (checking as override '{shipyard_override}') was blanket-whitelisted. Skipping it.") + whitelisted = True + + if whitelisted: + continue + 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. @@ -243,14 +251,20 @@ # 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: + # Filter out all prototypes that are whitelisted.# Filter out all prototypes that are whitelisted. + for map_key in list(violations.keys()): + if len(violations[map_key]) == 0: continue - - for whitelisted_proto in whitelisted_protos[key]: - if whitelisted_proto in violations[key]: - violations[key].remove(whitelisted_proto) + if map_key in whitelisted_protos: + for whitelisted_proto in whitelisted_protos[map_key]: + if whitelisted_proto in violations[map_key]: + violations[map_key].remove(whitelisted_proto) + if map_key in map_overrides: + override_key = map_overrides[map_key] + if override_key in whitelisted_protos: + for whitelisted_proto in whitelisted_protos[override_key]: + if whitelisted_proto in violations[map_key]: + violations[map_key].remove(whitelisted_proto) logger.debug(f"Violations aggregator after whitelist processing: {violations}") diff --git a/.github/workflows/nf-mapchecker.yml b/.github/workflows/nf-mapchecker.yml index aaf29b02ca00..5b11b0f2383d 100644 --- a/.github/workflows/nf-mapchecker.yml +++ b/.github/workflows/nf-mapchecker.yml @@ -7,10 +7,11 @@ on: # 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/_Forge/Entities/**/*.yml" - "Resources/Prototypes/Nyanotrasen/Entities/**/*.yml" - "Resources/Prototypes/_DV/Entities/**/*.yml" # Map pathspecs - If any maps are changed, this should run. - # - "Resources/Maps/**/*.yml" + - "Resources/Maps/**/*.yml" # Also the mapchecker itself - ".github/mapchecker/**" @@ -34,4 +35,6 @@ jobs: pip install -r .github/mapchecker/requirements.txt - name: Run mapchecker run: | - python3 .github/mapchecker/mapchecker.py + python3 .github/mapchecker/mapchecker.py --map_path "Resources/Prototypes/_NF/Maps/Outpost" "Resources/Prototypes/_NF/PointsOfInterest" "Resources/Prototypes/_NF/Shipyard" + + # python3 .github/mapchecker/mapchecker.py --map_path "Resources/Prototypes/_NF/Maps/Outpost" "Resources/Prototypes/_NF/PointsOfInterest" "Resources/Prototypes/_NF/Shipyard" "Resources/Prototypes/_Forge/Maps/Outpost" "Resources/Prototypes/_Forge/PointsOfInterest" "Resources/Prototypes/_Forge/Shipyard" diff --git a/Content.IntegrationTests/Tests/_NF/ShipyardTests.cs b/Content.IntegrationTests/Tests/_NF/ShipyardTests.cs index ac7ad57fb764..2ae55866ee62 100644 --- a/Content.IntegrationTests/Tests/_NF/ShipyardTests.cs +++ b/Content.IntegrationTests/Tests/_NF/ShipyardTests.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; // Forge-Change using System.Linq; using Content.Server.Cargo.Systems; +using Content.Shared._NF.Shipyard; // Forge-Change using Content.Shared._NF.Shipyard.Prototypes; using Robust.Server.GameObjects; using Robust.Shared.EntitySerialization.Systems; @@ -76,6 +78,20 @@ public async Task NoShipyardShipArbitrage() var protoManager = server.ResolveDependency(); var pricing = server.ResolveDependency().GetEntitySystem(); + // Forge-Change start + var factionMarkups = new Dictionary() + { + { ShipyardConsoleUiKey.Security, 1.35f }, + { ShipyardConsoleUiKey.Syndicate, 1.5f }, + { ShipyardConsoleUiKey.BlackMarket, 1.4f }, + { ShipyardConsoleUiKey.Mercenary, 1.3f }, + { ShipyardConsoleUiKey.Medical, 1.3f }, + { ShipyardConsoleUiKey.Scrap, 1.15f } + }; + + const float defaultMarkup = 1.2f; + // Forge-Change end + await server.WaitAssertion(() => { Assert.Multiple(() => @@ -100,7 +116,6 @@ await server.WaitAssertion(() => Assert.That(mapLoaded, Is.True, $"Failed to load shuttle {vessel} ({vessel.ShuttlePath}): TryLoadGrid returned false."); Assert.That(entManager.HasComponent(shuttle.Value), Is.True); - // Grid failed to load, continue to the next map. if (!mapLoaded) continue; @@ -109,10 +124,33 @@ await server.WaitAssertion(() => appraisePrice += price; }); - var idealMinPrice = appraisePrice * vessel.MinPriceMarkup; + // Forge-Change start + var shipyardConsole = vessel.Group; + float minMarkup; + + if (factionMarkups.TryGetValue(shipyardConsole, out var factionMarkup)) + { + minMarkup = factionMarkup; + } + else + { + minMarkup = defaultMarkup; + Console.WriteLine($"Faction '{shipyardConsole}' not found in markup dictionary. Using default markup: {defaultMarkup}"); + } + + var idealMinPrice = appraisePrice * minMarkup; + var markupPercent = (minMarkup - 1.0f) * 100; - Assert.That(vessel.Price, Is.AtLeast(idealMinPrice), - $"Arbitrage possible on {vessel.ID}. Minimal price should be {idealMinPrice}, {(vessel.MinPriceMarkup - 1.0f) * 100}% over the appraise price ({appraisePrice})."); + if (!vessel.MapcheckerException) + { + Assert.That(vessel.Price, Is.AtLeast(idealMinPrice), + $"Arbitrage possible on {vessel.ID}. " + + $"Minimal price should be {idealMinPrice:F2}, " + + $"{markupPercent:F1}% over the appraise price ({appraisePrice:F2}). " + + $"Faction: {shipyardConsole} " + + $"(Markup: {minMarkup:F2}, Prototype MinPriceMarkup: {vessel.MinPriceMarkup:F2})"); + } + // Forge-Change end map.DeleteMap(mapId); } diff --git a/Content.Shared/_NF/Shipyard/Prototypes/VesselPrototype.cs b/Content.Shared/_NF/Shipyard/Prototypes/VesselPrototype.cs index 73afcbf4bc31..daecfb6d4b05 100644 --- a/Content.Shared/_NF/Shipyard/Prototypes/VesselPrototype.cs +++ b/Content.Shared/_NF/Shipyard/Prototypes/VesselPrototype.cs @@ -46,6 +46,9 @@ public sealed class VesselPrototype : IPrototype, IInheritingPrototype [DataField(required: true)] public ShipyardConsoleUiKey Group = ShipyardConsoleUiKey.Shipyard; + [DataField] + public bool MapcheckerException = false; + /// /// The purpose of the vessel. (e.g. Service, Cargo, Engineering etc.) /// diff --git a/Resources/Prototypes/_NF/Shipyard/Base/base.yml b/Resources/Prototypes/_NF/Shipyard/Base/base.yml index 192db93aaac2..d25db36df7a4 100644 --- a/Resources/Prototypes/_NF/Shipyard/Base/base.yml +++ b/Resources/Prototypes/_NF/Shipyard/Base/base.yml @@ -7,6 +7,7 @@ price: 50000 category: Medium group: Shipyard + mapcheckerException: false addComponents: - type: IFF color: '#FFFFFFFF' diff --git a/Tools/_Forge/Checks_fixers/Price_Fixer.py b/Tools/_Forge/Checks_fixers/Price_Fixer.py new file mode 100644 index 000000000000..0e1dd1aa909e --- /dev/null +++ b/Tools/_Forge/Checks_fixers/Price_Fixer.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Обновляет цены шаттлов в прототипах на основе данных из логов +Округляет цены до ближайших сотен в большую сторону +Github: FireFoxPhoenix +""" + +import os +import re +import sys +import argparse + +def find_top_level_dir(start_directory: str, marker_file: str = MARKER_FILE) -> str: + current_dir = start_directory + while True: + try: + if marker_file in os.listdir(current_dir): + return current_dir + except (FileNotFoundError, PermissionError): + pass + parent_dir = os.path.dirname(current_dir) + if parent_dir == current_dir: + print(f"Failed to find {marker_file} starting from {start_directory}") + sys.exit(-1) + current_dir = parent_dir + +def parse_log_file(log_path: str): + shuttle_prices = {} + if not os.path.exists(log_path): + print(f"Log file not found: {log_path}") + return shuttle_prices + try: + with open(log_path, 'r', encoding='utf-8') as f: + log_content = f.read() + except Exception as e: + print(f"Error reading log file: {e}") + return shuttle_prices + pattern = r'Arbitrage possible on (\w+?)\. Minimal price should be ([\d,]+)' + matches = re.findall(pattern, log_content) + for shuttle_name, price_str in matches: + price_str_clean = price_str.replace(',', '').replace('.', '') + try: + price = float(price_str_clean) + except ValueError: + print(f"Failed to parse price for {shuttle_name}: {price_str}") + continue + corrected_price = ((price + 99) // 100) * 100 + shuttle_prices[shuttle_name] = int(corrected_price) + print(f"Found {len(shuttle_prices)} shuttles in log") + return shuttle_prices + +def find_shuttle_prototype(root_dir: str, shuttle_name: str, prototypes_folder: str): + prototypes_path = os.path.join(root_dir, prototypes_folder) + if not os.path.exists(prototypes_path): + print(f"Prototypes folder not found: {prototypes_path}") + return None + for root, dirs, files in os.walk(prototypes_path): + for file in files: + if file.endswith('.yml'): + file_path = os.path.join(root, file) + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + if f"id: {shuttle_name}" in content or f"\n id: {shuttle_name}" in content: + return file_path + except Exception as e: + print(f"Error reading file {file_path}: {e}") + return None + +def update_shuttle_price(file_path: str, shuttle_name: str, new_price: int, dry_run: bool = False): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as e: + print(f"Error reading file {file_path}: {e}") + return False + old_pattern = rf'(id:\s*{shuttle_name}\s*\n(?:[ \t-]*.*\n)*?[ \t-]*price:\s*)(\d+)' + match = re.search(old_pattern, content, re.MULTILINE) + if not match: + pattern = rf'(id:\s*{shuttle_name}.*?\n.*?price:\s*)(\d+)' + match = re.search(pattern, content, re.DOTALL) + if match: + old_price = match.group(2) + new_content = content[:match.start(2)] + str(new_price) + content[match.end(2):] + if content == new_content: + print(f" Price already correct: {old_price}") + return False + if dry_run: + print(f" [DRY RUN] Would update: {old_price} -> {new_price}") + return True + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(new_content) + print(f" Updated: {old_price} -> {new_price}") + return True + except Exception as e: + print(f" Error writing file: {e}") + return False + else: + print(f" Could not find price for shuttle {shuttle_name} in file") + return False + +def main(): + parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + '--log', + required=True, + help=f'Path to test log file' + ) + parser.add_argument( + '--prototypes', + default='Resources/Prototypes/_Forge/Shipyard', + help=f'Shuttle prototypes folder' + ) + parser.add_argument( + '--marker', + default='SpaceStation14.sln', + help=f'Marker file for finding project root' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Test mode - shows changes without applying them' + ) + args = parser.parse_args() + start_directory = os.path.dirname(os.path.abspath(__file__)) + root_dir = find_top_level_dir(start_directory, args.marker) + log_path = os.path.join(root_dir, args.log) + shuttle_prices = parse_log_file(log_path) + if not shuttle_prices: + print("No shuttle data found in log") + sys.exit(0) + print(f"\nProcessing {len(shuttle_prices)} shuttles...") + updated_count = 0 + not_found_count = 0 + already_correct_count = 0 + for shuttle_name, new_price in shuttle_prices.items(): + print(f"\nShuttle: {shuttle_name}") + print(f" New price: {new_price}") + prototype_file = find_shuttle_prototype(root_dir, shuttle_name, args.prototypes) + if prototype_file: + print(f" Found file: {os.path.relpath(prototype_file, root_dir)}") + updated = update_shuttle_price(prototype_file, shuttle_name, new_price, args.dry_run) + if updated: + updated_count += 1 + else: + already_correct_count += 1 + else: + print(f" Prototype file not found for shuttle {shuttle_name}") + not_found_count += 1 + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nInterrupted by user") + sys.exit(0) + except Exception as e: + print(f"\nCritical error: {e}") + sys.exit(1)