diff --git a/src/open_dread_rando/door_locks/door_patcher.py b/src/open_dread_rando/door_locks/door_patcher.py index 9cb25bdac..5383de377 100644 --- a/src/open_dread_rando/door_locks/door_patcher.py +++ b/src/open_dread_rando/door_locks/door_patcher.py @@ -400,7 +400,8 @@ def rename_shields(self, door: Container, scenario: str): continue # get shield actor and cache its sName - shieldActor = self.editor.resolve_actor_reference(self.editor.reference_for_link(link, scenario)) + reference = self.editor.reference_for_link(link, scenario) + shieldActor = self.editor.resolve_actor_reference(reference) old_sName = shieldActor.sName # skip hdoors (doors where the environment covers one side of the door) @@ -411,16 +412,18 @@ def rename_shields(self, door: Container, scenario: str): # reclaim old shield id if this is a RandoShield self.reclaim_old_shield_id(shieldActor.sName, scenario) - # grab the lowest open id and rename it + # grab the lowest open id new_id = self.get_shield_id(scenario) - shieldActor.sName = new_id - life_comp[link_name] = self.editor.build_link(new_id) # make new actor, copy its groups, delete it brfld = self.editor.get_scenario(scenario) brfld.actors_for_sublayer('default')[new_id] = shieldActor self.editor.copy_actor_groups({ "actor": old_sName }, { "actor": new_id }, scenario) - brfld.actors_for_sublayer('default').pop(old_sName) + self.editor.remove_entity(reference, None) + + # actually rename it + shieldActor.sName = new_id + life_comp[link_name] = self.editor.build_link(new_id) # update the minimap entry as well mapBlockages = self.editor.get_scenario_map(scenario).raw.Root.mapBlockages diff --git a/src/open_dread_rando/dread_patcher.py b/src/open_dread_rando/dread_patcher.py index b88a6e088..627668fee 100644 --- a/src/open_dread_rando/dread_patcher.py +++ b/src/open_dread_rando/dread_patcher.py @@ -29,6 +29,7 @@ from open_dread_rando.pickups.split_pickups import patch_split_pickups, update_starting_inventory_split_pickups from open_dread_rando.specific_patches import game_patches from open_dread_rando.specific_patches.environmental_damage import apply_constant_damage +from open_dread_rando.specific_patches.mass_delete_actors import mass_delete_actors from open_dread_rando.specific_patches.objective import apply_objective_patches from open_dread_rando.specific_patches.static_fixes import apply_static_fixes from open_dread_rando.validator_with_default import DefaultValidatingDraft7Validator @@ -271,6 +272,9 @@ def apply_patches(editor: PatcherEditor, lua_editor: LuaEditor, configuration: d # Specific game patches game_patches.apply_game_patches(editor, configuration.get("game_patches", {})) + # Mass delete actors + mass_delete_actors(editor, configuration["mass_delete_actors"]) + # Actor patches apply_actor_patches(editor, configuration.get("actor_patches")) diff --git a/src/open_dread_rando/files/schema.json b/src/open_dread_rando/files/schema.json index dcdd89de7..3f2c786b0 100644 --- a/src/open_dread_rando/files/schema.json +++ b/src/open_dread_rando/files/schema.json @@ -546,6 +546,104 @@ "additionalProperties": false, "default": {} }, + "mass_delete_actors": { + "description": "Deletes actors en masse", + "type": "object", + "properties": { + "to_remove": { + "type": "array", + "items": { + "examples": [ + { + "scenario": "s010_cave", + "actor_layer": "rLightsLayer", + "method": "all" + }, + { + "scenario": "s010_cave", + "method": "remove_from_groups", + "actor_groups": [ + "eg_collision_camera_067_Default" + ] + }, + { + "scenario": "s030_baselab", + "actor_layer": "rLightsLayer", + "method": "keep_from_groups", + "actor_groups": [ + "lg_collision_camera_011_Default" + ] + } + ], + "type": "object", + "properties": { + "scenario": { + "description": "The scenario to remove actors from", + "$ref": "#/$defs/scenario_name" + }, + "actor_layer": { + "description": "The actor layer to remove actors from", + "type": "string", + "enum": [ + "rEntitiesLayer", + "rSoundsLayer", + "rLightsLayer" + ], + "default": "rEntitiesLayer" + }, + "method": { + "description": "The method for removing actors. all removes all in the scenario, remove_from_groups will remove all actors in the provided actor groups, and keep_from_groups will remove from all actor groups not provided", + "type": "string", + "enum": [ + "all", + "remove_from_groups", + "keep_from_groups" + ], + "default": "all" + }, + "actor_groups": { + "description": "The actor group the actors are in. Required if method is not all", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["scenario"], + "if": { + "anyOf": [ + { + "properties": { + "method": { "const": "remove_from_groups" } + }, + "required": ["method"] + }, + { + "properties": { + "method": { "const": "keep_from_groups" } + }, + "required": ["method"] + } + ] + }, + "then": { + "required": ["actor_groups"] + } + }, + "default": [] + }, + "to_keep": { + "description": "A list of actors not to remove. Use this to keep specific actors from a scenario or actor group that has been removed", + "type": "array", + "items": { + "$ref": "#/$defs/actor_reference_with_layer" + }, + "default": [] + } + }, + "required": ["to_remove"], + "default": {} + }, "show_shields_on_minimap": { "type": "boolean", "description": "Deprecated. Used to remove shields from the minimaps in Door Lock Rando.", diff --git a/src/open_dread_rando/specific_patches/mass_delete_actors.py b/src/open_dread_rando/specific_patches/mass_delete_actors.py new file mode 100644 index 000000000..ad8e3393b --- /dev/null +++ b/src/open_dread_rando/specific_patches/mass_delete_actors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import typing + +from open_dread_rando.patcher_editor import PatcherEditor + + +class ActorReferenceTuple(typing.NamedTuple): + scenario: str + actor_layer: str + sublayer: str + actor: str + + def from_dict(reference: dict[str, str]) -> ActorReferenceTuple: + return ActorReferenceTuple( + reference["scenario"], + reference["actor_layer"], + reference.get("sublayer", reference.get("layer")), + reference["actor"] + ) + +def _remove_all_actors(editor: PatcherEditor, scenario_name: str, actor_layer: str) -> set[ActorReferenceTuple]: + to_remove = set() + scenario = editor.get_scenario(scenario_name) + + for sublayer_name, actor_name, actor in scenario.all_actors_in_actor_layer(actor_layer): + to_remove.add(ActorReferenceTuple(scenario_name, actor_layer, sublayer_name, actor_name)) + + return to_remove + +def _remove_actors_from_groups(editor: PatcherEditor, scenario_name: str, actor_layer: str, + actor_groups: list[str]) -> set[ActorReferenceTuple]: + to_remove = set() + scenario = editor.get_scenario(scenario_name) + + for group in actor_groups: + for actor_link in scenario.get_actor_group(group, actor_layer): + to_remove.add(ActorReferenceTuple(**editor.reference_for_link(actor_link, scenario_name))) + + return to_remove + +def _remove_actors_not_in_groups(editor: PatcherEditor, scenario_name: str, actor_layer: str, + actor_groups: list[str]) -> tuple[set[ActorReferenceTuple], set[ActorReferenceTuple]]: + to_remove = set() + to_keep = set() + scenario = editor.get_scenario(scenario_name) + + for group in scenario.actor_groups_for_actor_layer(actor_layer): + if group not in actor_groups: + for actor_link in scenario.get_actor_group(group, actor_layer): + to_remove.add(ActorReferenceTuple(**editor.reference_for_link(actor_link, scenario_name))) + else: + for actor_link in scenario.get_actor_group(group, actor_layer): + to_keep.add(ActorReferenceTuple(**editor.reference_for_link(actor_link, scenario_name))) + + return to_remove, to_keep + +def mass_delete_actors(editor: PatcherEditor, configuration: dict) -> None: + # Sets of tuples will be used to ensure no duplicate entries + to_remove = set() + to_keep = { ActorReferenceTuple.from_dict(reference) for reference in configuration["to_keep"] } + + for scenario_config in configuration["to_remove"]: + scenario_name = scenario_config["scenario"] + actor_layer = scenario_config["actor_layer"] + method = scenario_config["method"] + + if method == "all": + to_remove.update(_remove_all_actors(editor, scenario_name, actor_layer)) + + elif method == "remove_from_groups": + to_remove.update(_remove_actors_from_groups(editor, scenario_name, actor_layer, + scenario_config["actor_groups"])) + + elif method == "keep_from_groups": + new_remove, new_keep = _remove_actors_not_in_groups(editor, scenario_name, actor_layer, + scenario_config["actor_groups"]) + + to_remove.update(new_remove) + to_keep.update(new_keep) + + to_remove.difference_update(to_keep) + + for actor in to_remove: + editor.remove_entity(actor._asdict(), None) diff --git a/tests/conftest.py b/tests/conftest.py index 939bdfdcc..e65b6b5e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import lupa import pytest +from open_dread_rando.patcher_editor import PatcherEditor + _FAIL_INSTEAD_OF_SKIP = True @@ -56,6 +58,11 @@ def lua_runtime(): return runtime +@pytest.fixture() +def patcher_editor(dread_path): + return PatcherEditor(dread_path) + + def pytest_addoption(parser): parser.addoption('--skip-if-missing', action='store_false', dest="fail_if_missing", default=True, help="Skip tests instead of missing, in case any asset is missing") diff --git a/tests/test_dread_patcher.py b/tests/test_dread_patcher.py index c8924eeb4..aec7db6cf 100644 --- a/tests/test_dread_patcher.py +++ b/tests/test_dread_patcher.py @@ -1,6 +1,9 @@ from unittest.mock import MagicMock +from mercury_engine_data_structures.formats.brfld import ActorLayer + from open_dread_rando import dread_patcher +from open_dread_rando.specific_patches.mass_delete_actors import mass_delete_actors def test_cosmetic_options(lua_runtime): @@ -66,3 +69,59 @@ def test_cosmetic_options(lua_runtime): assert lua_runtime.eval("Init.fEnergyPerTank") == 75 assert lua_runtime.eval("Init.sLayoutUUID") == layoutUUID + +def test_mass_delete_actors(patcher_editor): + configuration = { + "to_remove": [ + { + "scenario": "s020_magma", + "actor_layer": "rEntitiesLayer", + "method": "all" + }, + { + "scenario": "s010_cave", + "actor_layer": "rLightsLayer", + "method": "remove_from_groups", + "actor_groups": [ + "lg_collision_camera_001" + ] + }, + { + "scenario": "s030_baselab", + "actor_layer": "rLightsLayer", + "method": "keep_from_groups", + "actor_groups": [ + "lg_collision_camera_011_Default" + ] + } + ], + "to_keep": [ + { + "scenario": "s010_cave", + "actor_layer": "rLightsLayer", + "sublayer": "cave_001_light", + "actor": "spot_001_1" + } + ] + } + + mass_delete_actors(patcher_editor, configuration) + + s010_cave = patcher_editor.get_scenario("s010_cave") + cave_light_001 = s010_cave.get_actor_group("lg_collision_camera_001", "rLightsLayer") + cave_spot_001_1_link = patcher_editor.build_link("spot_001_1", "cave_001_light", ActorLayer.LIGHTS) + + assert cave_light_001 == [cave_spot_001_1_link] + + s020_magma = patcher_editor.get_scenario("s020_magma") + magma_entities = [actor_name for (sublayer_name, actor_name, actor) in s020_magma.all_actors_in_actor_layer()] + + assert len(magma_entities) == 0 + + s030_baselab = patcher_editor.get_scenario("s030_baselab") + lab_light_001 = s030_baselab.get_actor_group("lg_collision_camera_001_Default", "rLightsLayer") + lab_light_010 = s030_baselab.get_actor_group("lg_collision_camera_010_Default", "rLightsLayer") + lab_light_011 = s030_baselab.get_actor_group("lg_collision_camera_011_Default", "rLightsLayer") + cubemap_010_link = patcher_editor.build_link("cubemap_010", "base_010_light", ActorLayer.LIGHTS) + + assert len(lab_light_011) == 3 and len(lab_light_001) == 0 and cubemap_010_link in lab_light_010 diff --git a/tests/test_files/patcher_files/april_fools_patcher.json b/tests/test_files/patcher_files/april_fools_patcher.json index 8645051d4..b2872bebc 100644 --- a/tests/test_files/patcher_files/april_fools_patcher.json +++ b/tests/test_files/patcher_files/april_fools_patcher.json @@ -7448,6 +7448,38 @@ "{c1}Metroid DNA 4{c0} is guarded by {c2}E.M.M.I.-07PB{c0}." ] }, + "mass_delete_actors": { + "to_remove": [ + { + "scenario": "s020_magma", + "method": "all" + }, + { + "scenario": "s010_cave", + "actor_layer": "rLightsLayer", + "method": "remove_from_groups", + "actor_groups": [ + "lg_collision_camera_001" + ] + }, + { + "scenario": "s030_baselab", + "actor_layer": "rLightsLayer", + "method": "keep_from_groups", + "actor_groups": [ + "lg_collision_camera_011_Default" + ] + } + ], + "to_keep": [ + { + "scenario": "s010_cave", + "actor_layer": "rLightsLayer", + "sublayer": "cave_001_light", + "actor": "spot_001_1" + } + ] + }, "layout_uuid": "00000000-0000-1111-0000-000000000000", "mod_compatibility": "ryujinx", "mod_category": "romfs"